Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb25631799 | ||
|
|
c552ee9f9d | ||
|
|
5853f1f7d6 | ||
|
|
40e35bab0e | ||
|
|
504971ad7f | ||
|
|
a88a052d9a |
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
|||||||
|
---
|
||||||
|
name: material-3-design
|
||||||
|
description: Material 3 Expressive's design system as Livewire Material implements it — colour roles and surface containers, elevation, shape, type, motion, states and targets, window size classes, spacing, icons, accessibility — each M3 name beside the class, prop or token that draws it and Google's source page, for deciding how a screen should look and behave before writing it.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Material 3 design
|
||||||
|
|
||||||
|
## When to use this skill
|
||||||
|
|
||||||
|
Use this skill when deciding how a screen, panel or control should look or behave — which colour, container, corner, type style, motion, breakpoint or spacing — in an application that requires `nonameweb/livewire-material`, and when reviewing a view against Material 3. The props and slots of each component are in the `livewire-material-development` skill; this one is the design language they implement. The rules an agent must always follow are in the `material-3` guideline; the tables here are what those rules compress.
|
||||||
|
|
||||||
|
Every table pairs the M3 name with what the library gives for it. The library is plain CSS with no utility classes, so that is one of three things: a component or layout component prop (`color="error"`, `<x-surface level="surface-container">`, `gap="space200"`), one of the fixed text and interaction classes (`md-type-*`, `md-ink-*`, `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`), or a token the application's own CSS reads with `var()`. The tokens are CSS custom properties (`--md-sys-color-*`, `--md-sys-typescale-*`, `--md-sys-shape-*`, `--md-sys-elevation-*`, `--md-sys-motion-*`, `--md-sys-state-*`, `--md-sys-measurement-*`, `--md-ref-typeface-*`), so a stylesheet names a token and never a value.
|
||||||
|
|
||||||
|
## Colour
|
||||||
|
|
||||||
|
A colour scheme is generated from one seed by Google's colour science (`php artisan material:scheme`); every role below is a slot in that scheme, light and dark, at three contrast levels. A view names a role and nothing else — never a hex, a palette tone or an opacity — because only a role follows the theme, the contrast level and a colour profile.
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
| Role | Purpose | Its `on-` pair | In this library |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| primary | High-emphasis fills, text and icons: the key action on a screen | on-primary | `var(--md-sys-color-primary)`, `md-ink-primary`, `<x-button variant="filled">` |
|
||||||
|
| primary-container | A standout fill for key components (FAB, an emphasised panel) | on-primary-container | `var(--md-sys-color-primary-container)` |
|
||||||
|
| primary-dim | A darker primary for a pressed or contrasting fill (2025 spec) | on-primary | `var(--md-sys-color-primary-dim)` |
|
||||||
|
| secondary | Less prominent fills, text and icons | on-secondary | `var(--md-sys-color-secondary)` |
|
||||||
|
| secondary-container | The recessive fill: tonal buttons, selected navigation, selected chips | on-secondary-container | `var(--md-sys-color-secondary-container)`, `<x-button variant="tonal">` |
|
||||||
|
| tertiary | A complementary accent, used sparingly for contrast | on-tertiary | `var(--md-sys-color-tertiary)`, `color="tertiary"` |
|
||||||
|
| tertiary-container | The complementary fill | on-tertiary-container | `var(--md-sys-color-tertiary-container)` |
|
||||||
|
| error | Urgency and errors; static, does not follow dynamic colour | on-error | `md-ink-error`, `var(--md-sys-color-error)`, `color="error"` |
|
||||||
|
| error-container | An error panel | on-error-container | `var(--md-sys-color-error-container)`, `<x-alert color="error">` |
|
||||||
|
| success, warning, info | This library's custom state colours, built like error on the 2025 spec, with `-container` and `on-` pairs | on-success … | `md-ink-success`, `var(--md-sys-color-warning-container)`, `color="info"` |
|
||||||
|
| surface | The page background | on-surface | the page itself (the foundation paints it), `<x-surface level="surface">` |
|
||||||
|
| on-surface-variant | Lower-emphasis text and icons on any surface | — | `md-ink-variant` |
|
||||||
|
| outline | A boundary that must be read: a text field, a target's edge (3:1 against surface) | — | `md-ink-quiet`, `var(--md-sys-color-outline)` |
|
||||||
|
| outline-variant | Decorative lines: dividers, card edges | — | `<x-divider>`, `<x-surface outlined>`, `var(--md-sys-color-outline-variant)` |
|
||||||
|
| inverse-surface | A surface that contrasts with its surroundings (the snackbar) | inverse-on-surface | `var(--md-sys-color-inverse-surface)` with `md-ink-inverse` |
|
||||||
|
| inverse-primary | An action on an inverse surface (the snackbar's action) | — | `var(--md-sys-color-inverse-primary)` |
|
||||||
|
| scrim | Behind a modal, at 32% | — | `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)` |
|
||||||
|
| shadow | The shadow colour, inside every `--md-sys-elevation-*` | — | — |
|
||||||
|
| surface-dim, surface-bright | Add-on surfaces that keep their relative brightness in both themes | on-surface | `<x-surface level="surface-dim">`, `<x-surface level="surface-bright">` |
|
||||||
|
| primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant (and secondary, tertiary) | Add-on roles with the same tone in light and dark; for a colour that must not change with the theme; never where contrast matters | — | `var(--md-sys-color-primary-fixed)` with `var(--md-sys-color-on-primary-fixed)` |
|
||||||
|
|
||||||
|
Pairing: a role's `on-` pair is the only combination whose contrast is guaranteed at every contrast level. A `primary` fill under `on-primary` text and a `secondary-container` fill under `on-secondary-container` are right; `primary-container` under `on-surface`, or `secondary-container` under `primary`, are not, and break as the contrast level rises. A component sets its own pair; the application's CSS writes both halves (`background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary)`). Google: "Pair and layer color roles only as intended … Don't mix roles improperly."
|
||||||
|
|
||||||
|
### Surface containers
|
||||||
|
|
||||||
|
A hierarchy of emphasis, not of height: the tone separates panels before any shadow does, and a region keeps its role at every breakpoint (body always `surface`, navigation always `surface-container`).
|
||||||
|
|
||||||
|
| Role | Use | In this library |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| surface | The page | the page itself, `<x-surface level="surface">` |
|
||||||
|
| surface-container-lowest | The most recessed panel; an elevated card's body in dark themes | `<x-surface level="surface-container-lowest">` |
|
||||||
|
| surface-container-low | An elevated card, a modal bottom or side sheet, the full-screen search view | `<x-surface level="surface-container-low">` |
|
||||||
|
| surface-container | Navigation bar and rail, docked and floating toolbars, menus, the segmented list | `<x-surface>` (the default level) |
|
||||||
|
| surface-container-high | Dialogs, the search bar, date and time pickers, a rich tooltip | `<x-surface level="surface-container-high">` |
|
||||||
|
| surface-container-highest | A filled card, a filled text field, a filled chip's selected state | `<x-surface level="surface-container-highest">` |
|
||||||
|
|
||||||
|
In the application's CSS each is `var(--md-sys-color-surface-container-low)` and so on; the ink on every one of them is `on-surface`.
|
||||||
|
|
||||||
|
### Emphasis and lines
|
||||||
|
|
||||||
|
- Default ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Emphasis is never an opacity: M3 reserves 38% (`--md-sys-state-disabled-content-opacity`) for disabled content and 12% (`--md-sys-state-disabled-container-opacity`) for a disabled container.
|
||||||
|
- `outline` for a boundary that has to be perceived (a text field's edge, a target's edge — 3:1 against the surface); `outline-variant` for dividers and the edge of a card or any component holding several elements. Google: "Don't use the outline color for dividers … use outline variant instead." `outline-variant` may edge a chip or a button only because the content inside already carries the contrast.
|
||||||
|
- A hyperlink in running text is `primary` (or `tertiary` for a quieter link) **and** underlined: `md-link` with `md-ink-primary`.
|
||||||
|
|
||||||
|
### Contrast
|
||||||
|
|
||||||
|
| Level | Target | How |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Standard | Hierarchy from high- and low-contrast elements together; text 4.5:1, large text and icons 3:1, grouped non-text controls 3:1 | the default scheme |
|
||||||
|
| Medium | 3:1 minimum everywhere, without halation | `<html data-contrast="medium">` |
|
||||||
|
| High | 7:1 | `<html data-contrast="high">`, or the visitor's OS setting (`theme.contrast.default` = `system`) |
|
||||||
|
|
||||||
|
Every role changes with the level automatically; a component built from roles needs nothing else. Disabled states are exempt from contrast. A colour outside the roles (a hex, white, black) does not change and is the one thing that breaks a contrast level.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/styles/color/roles · https://m3.material.io/styles/color/system/how-the-system-works · https://m3.material.io/styles/color/advanced/apply-colors · https://m3.material.io/foundations/designing/color-contrast
|
||||||
|
|
||||||
|
## Surfaces and elevation
|
||||||
|
|
||||||
|
M3 separates surfaces by tone first; a shadow says that something floats over the content or is being interacted with. "When it comes to applying shadows, less is more."
|
||||||
|
|
||||||
|
| Level | Shadow | Rests here | In this library |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 0 | none | The page, cards (filled, outlined), buttons (filled, tonal, outlined), button groups, icon buttons, lists, chips, tabs, sliders, the rail, a docked side sheet, a carousel, a full-screen dialog, a FAB inside the rail, an app bar at rest | — |
|
||||||
|
| 1 | 1dp | Elevated cards, elevated buttons and chips, modal bottom and side sheets, a banner | `box-shadow: var(--md-sys-elevation-1)` |
|
||||||
|
| 2 | 3dp | Menus, the navigation bar, a scrolled app bar, toolbars, rich tooltips | `var(--md-sys-elevation-2)` |
|
||||||
|
| 3 | 6dp | FAB and extended FAB, the FAB menu's close button, dialogs, date and time pickers, the search bar | `var(--md-sys-elevation-3)` |
|
||||||
|
| 4 | 8dp | Interaction only: a level-3 element on hover or while dragged | `var(--md-sys-elevation-4)` |
|
||||||
|
| 5 | 12dp | Interaction only | `var(--md-sys-elevation-5)` |
|
||||||
|
|
||||||
|
- Hover lifts an element one level (a FAB 3 → 4, an elevated card 1 → 2); focus and selection may too; a raised element lowers when something higher appears.
|
||||||
|
- Overlapping panels take different surface-container roles to show separation; the roles are not tied to the levels.
|
||||||
|
- A scrim (`scrim` at 32%) brings focus to a modal over a large surface; it is never a substitute for a shadow on a small floating element.
|
||||||
|
- On a dark surface a shadow is nearly invisible, so the tone does the work there.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/styles/elevation/overview · https://m3.material.io/styles/elevation/applying-elevation · https://m3.material.io/styles/elevation/tokens
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
### The corner scale
|
||||||
|
|
||||||
|
| Style | Value | In this library |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| None | 0 | `var(--md-sys-shape-corner-none)`, `corner="none"` |
|
||||||
|
| Extra small | 4px | `var(--md-sys-shape-corner-xs)`, `corner="xs"` |
|
||||||
|
| Small | 8px | `var(--md-sys-shape-corner-sm)`, `corner="sm"` |
|
||||||
|
| Medium | 12px | `var(--md-sys-shape-corner-md)`, `corner="md"` |
|
||||||
|
| Large | 16px | `var(--md-sys-shape-corner-lg)`, `corner="lg"` |
|
||||||
|
| Large increased | 20px | `var(--md-sys-shape-corner-lg-increased)`, `corner="lg-increased"` |
|
||||||
|
| Extra large | 28px | `var(--md-sys-shape-corner-xl)`, `corner="xl"` |
|
||||||
|
| Extra large increased | 32px | `var(--md-sys-shape-corner-xl-increased)`, `corner="xl-increased"` |
|
||||||
|
| Extra extra large | 48px | `var(--md-sys-shape-corner-xxl)`, `corner="xxl"` |
|
||||||
|
| Full | a stadium or circle | `var(--md-sys-shape-corner-full)`, `corner="full"` |
|
||||||
|
|
||||||
|
`corner` is `<x-surface>`'s prop. In the application's CSS a corner is `border-radius` on a token, and one side at a time a logical longhand (`border-start-start-radius` and `border-start-end-radius` for a bottom sheet's top); a length of your own is off the scale.
|
||||||
|
|
||||||
|
### Corner by component
|
||||||
|
|
||||||
|
| Component | Corner | Note |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Buttons, icon buttons, split button (outer), FAB menu items | full | a press morphs to `md` (xs/sm sizes), `lg` (md), `xl` (lg/xl); a selected toggle swaps round ↔ square |
|
||||||
|
| Connected button group | full outside, `sm` between segments | segments press to `xs` |
|
||||||
|
| FAB | `lg` 16 (baseline 56px), `lg-increased` 20 (medium 80px), `xl` 28 (large 96px) | extended FAB `lg` |
|
||||||
|
| Chips | `sm` 8 | an avatar in a chip `md` 12 |
|
||||||
|
| Cards | `md` 12 | no change on hover |
|
||||||
|
| Text fields | `xs` 4 (outlined: all corners; filled: top corners only) | |
|
||||||
|
| Menus, snackbar, plain tooltip | `xs` 4 | the Expressive vertical menu rounds the focused item |
|
||||||
|
| Rich tooltip | `md` 12 | |
|
||||||
|
| Dialogs | `xl` 28 | full-screen dialog `none` |
|
||||||
|
| Bottom sheet | `xl` 28 on top | |
|
||||||
|
| Side sheet | `lg` 16 on the inner side | |
|
||||||
|
| Search bar | full | search view `xl` 28 when docked, `none` full-screen |
|
||||||
|
| Date and time pickers | `xl` 28 | date cells full |
|
||||||
|
| Carousel items | `xl` 28 | |
|
||||||
|
| Navigation indicator, badges, switch, slider handle, checkbox state layer | full | checkbox box 2px, tab indicator 3px on top |
|
||||||
|
| Navigation bar, app bar, docked toolbar, tabs | none | floating toolbar full |
|
||||||
|
| Segmented list rows | `xs` inner, `lg` outer; a selected row `lg` | |
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Optical roundness: a shape nested in a rounded container takes inner radius = outer radius − padding (48 − 14 = 34), never the container's own radius.
|
||||||
|
- Large and full corners do not belong on information-dense containers (cards, tables, text fields).
|
||||||
|
- A press squares a round shape and rounds a square one (the components carry the morph on the fast spatial spring); nothing morphs on hover.
|
||||||
|
- The 35 Expressive shapes (`<x-shape name="cookie-9">`, also the loading indicator and the standard button group's press shape) are decoration for emphasis and delight — never a carrier of meaning, never behind text-heavy content, and used sparingly.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/styles/shape/corner-radius-scale · https://m3.material.io/styles/shape/shape-morph · https://m3.material.io/styles/shape/overview-principles
|
||||||
|
|
||||||
|
## Type
|
||||||
|
|
||||||
|
The typeface is Google Sans Flex for brand and plain styles (`--md-ref-typeface-brand`, `--md-ref-typeface-plain`); an application may replace it after importing the stylesheet. Each style is one class that sets size, line height, weight, family and tracking together — or, in the application's CSS, `font: var(--md-sys-typescale-body-md)` with `letter-spacing: var(--md-sys-typescale-body-md-tracking)`. A size, weight, line height or letter spacing of your own is off the scale.
|
||||||
|
|
||||||
|
| Role | Style | Size / line | Weight | In this library | Use for |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Display | large / medium / small | 57/64 · 45/52 · 36/44 | 400 | `md-type-display-lg` … | hero figures, one short marketing line; never running text |
|
||||||
|
| Headline | large / medium / small | 32/40 · 28/36 · 24/32 | 400 | `md-type-headline-lg` … | page titles, section titles, a dialog's headline (`headline-sm`) |
|
||||||
|
| Title | large / medium / small | 22/28 · 16/24 · 14/20 | 400 / 500 / 500 | `md-type-title-lg` … | app bar title (`lg`), card and list-section titles (`md`), dense headers (`sm`) |
|
||||||
|
| Body | large / medium / small | 16/24 · 14/20 · 12/16 | 400 | `md-type-body-lg` … | paragraphs (`lg` for reading, `md` in components), supporting text (`sm`) |
|
||||||
|
| Label | large / medium / small | 14/20 · 12/16 · 11/16 | 500 | `md-type-label-lg` … | buttons and tabs (`lg`), chips and navigation (`md`), captions and badges (`sm`) |
|
||||||
|
|
||||||
|
- `md-type-emphasized-*` (`--md-sys-typescale-emphasized-*`) is the same size and line height one weight step heavier (400 → 500, 500 → 700), fully rounded in Google Sans Flex, with its own tracking. M3 uses it deliberately, never by default: a selected list or menu item, a button's label on a primary action, an extended FAB, a badge, a headline given editorial weight.
|
||||||
|
- Tracking follows Compose's `TypeScaleTokens`: display-large −0.2, title-medium 0.2, title-small 0.1, body-large 0.5, body-medium 0.2, body-small 0.4, label-large 0.1, label-medium and small 0.5 (sp; rem = sp/16); the emphasized set tightens a few (display-large 0, title-medium 0.15, body-large 0.15, body-medium 0.25).
|
||||||
|
- Line length 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
|
||||||
|
|
||||||
|
- Native elements before ARIA: `<button>`, `<a href>`, `<dialog>`, `<input>`, `<select>`; a styled `div` that fakes one needs everything re-implemented and tested.
|
||||||
|
- Landmarks: one `main`, one `banner`, one `contentinfo` per page; `nav`, `search`, `complementary`, `form`, `region` labelled when they repeat, never with their own role in the label ("Primary", not "Primary navigation").
|
||||||
|
- Headings: one H1 for the page, then H2–H6 in order without skipping; the level is the document's structure, the `md-type-*` class is the appearance, and they need not match.
|
||||||
|
- Names: an interactive icon, image or ambiguous button ("Save", "Learn more") has a name that says what it does, without the word "button"; decorative images are `alt=""` or `aria-hidden`; text only a screen reader needs is `md-visually-hidden`.
|
||||||
|
- Focus: DOM order is reading order; a dialog moves focus to its first meaningful control and returns it to the opener; grouped controls are one Tab stop with arrows inside; a keyboard shortcut is two keys, or a single key only while its component is focused.
|
||||||
|
- Announcements: an error is tied to its field (`aria-invalid`, `aria-describedby`) and announced; a snackbar is a polite live region that never steals focus and stays while it carries an action; a loading state has a name.
|
||||||
|
- Contrast and states: 4.5:1 text, 3:1 large text and icons and grouped controls, disabled exempt; every state has two indicators; targets 48px with 8px between; text scales to 200%; motion honours reduced motion.
|
||||||
|
|
||||||
|
Sources: https://m3.material.io/foundations/overview/principles · https://m3.material.io/foundations/designing/structure · https://m3.material.io/foundations/designing/flow · https://m3.material.io/foundations/designing/elements · https://m3.material.io/foundations/overview/assistive-technology
|
||||||
|
|
||||||
|
## Don'ts
|
||||||
|
|
||||||
|
What Google's pages say not to do, and this library follows:
|
||||||
|
|
||||||
|
| Don't | Because | Instead |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Put an icon in a snackbar | a snackbar is a short message with at most one action | `<x-toast>` types choose the announcement, not a picture |
|
||||||
|
| Disable a FAB | "if the action is unavailable, the FAB shouldn't appear" | hide it |
|
||||||
|
| Lay radio buttons in a row | a row reads as one control | a vertical group; chips or a connected button group for a horizontal choice |
|
||||||
|
| Morph a card's corners on hover | shape morphs mark a press or a selection | the state layer and one level of elevation |
|
||||||
|
| Use `outline` on a divider | dividers carry no contrast requirement and read too heavy | `<x-divider>` (`outline-variant`) |
|
||||||
|
| Use a hex, white, black or an opacity for ink | it ignores theme, contrast level and profile | a role |
|
||||||
|
| Write a utility class, or a breakpoint, radius, shadow, type size or easing of your own | nothing defines utility classes, and other values are not M3's | the layout components' props, `md-type-*` and `md-ink-*`, and `--md-sys-*` tokens at 600/840/1200/1600px in your own CSS |
|
||||||
|
| Use segmented buttons, a navigation drawer or a bottom app bar | deprecated in M3 Expressive | `<x-button-group connected>`, the expanded rail, `<x-toolbar>` |
|
||||||
|
| Truncate without a way to read the rest | an ellipsis alone is not accessible | wrap, grow the container, or a tooltip |
|
||||||
|
| Animate with a literal duration | it ignores reduced motion | the paired tokens |
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
The rules, tables and wording here are Google's, condensed from the Material Design 3 documentation at https://m3.material.io (Foundations, Styles and Components), which Google publishes under the Creative Commons Attribution 4.0 License except as otherwise noted; the numeric token values are from the Android Open Source Project's Material 3 token files in androidx Compose (Apache License 2.0). Copyright Google LLC; Copyright The Android Open Source Project. The library's `NOTICE` records the same. Dates and page names are those of the site as read on 2026-09-13; the full extracted references, with every source page, are kept in the package repository under `docs/reference/m3/`.
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
---
|
|
||||||
name: tailwindcss-development
|
|
||||||
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
|
||||||
license: MIT
|
|
||||||
metadata:
|
|
||||||
author: laravel
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tailwind CSS Development
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
|
||||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
|
||||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
|
||||||
|
|
||||||
## Tailwind CSS v4 Specifics
|
|
||||||
|
|
||||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
|
||||||
- `corePlugins` is not supported in Tailwind v4.
|
|
||||||
|
|
||||||
### CSS-First Configuration
|
|
||||||
|
|
||||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
|
||||||
|
|
||||||
<!-- CSS-First Config -->
|
|
||||||
```css
|
|
||||||
@theme {
|
|
||||||
--color-brand: oklch(0.72 0.11 178);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Import Syntax
|
|
||||||
|
|
||||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
|
||||||
|
|
||||||
<!-- v4 Import Syntax -->
|
|
||||||
```diff
|
|
||||||
- @tailwind base;
|
|
||||||
- @tailwind components;
|
|
||||||
- @tailwind utilities;
|
|
||||||
+ @import "tailwindcss";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Replaced Utilities
|
|
||||||
|
|
||||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
|
||||||
|
|
||||||
| Deprecated | Replacement |
|
|
||||||
|------------|-------------|
|
|
||||||
| bg-opacity-* | bg-black/* |
|
|
||||||
| text-opacity-* | text-black/* |
|
|
||||||
| border-opacity-* | border-black/* |
|
|
||||||
| divide-opacity-* | divide-black/* |
|
|
||||||
| ring-opacity-* | ring-black/* |
|
|
||||||
| placeholder-opacity-* | placeholder-black/* |
|
|
||||||
| flex-shrink-* | shrink-* |
|
|
||||||
| flex-grow-* | grow-* |
|
|
||||||
| overflow-ellipsis | text-ellipsis |
|
|
||||||
| decoration-slice | box-decoration-slice |
|
|
||||||
| decoration-clone | box-decoration-clone |
|
|
||||||
|
|
||||||
## Spacing
|
|
||||||
|
|
||||||
Use `gap` utilities instead of margins for spacing between siblings:
|
|
||||||
|
|
||||||
<!-- Gap Utilities -->
|
|
||||||
```html
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<div>Item 1</div>
|
|
||||||
<div>Item 2</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dark Mode
|
|
||||||
|
|
||||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
|
||||||
|
|
||||||
<!-- Dark Mode -->
|
|
||||||
```html
|
|
||||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
|
||||||
Content adapts to color scheme
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
|
||||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
|
||||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
|
||||||
- Using margins for spacing between siblings instead of gap utilities
|
|
||||||
- Forgetting to add dark mode variants when the project uses dark mode
|
|
||||||
@@ -69,5 +69,8 @@ VITE_APP_NAME="${APP_NAME}"
|
|||||||
# OCTANE_HTTPS=false
|
# OCTANE_HTTPS=false
|
||||||
# OCTANE_MAX_EXECUTION_TIME=300
|
# OCTANE_MAX_EXECUTION_TIME=300
|
||||||
|
|
||||||
|
# Uploads: each encrypted chunk the browser sends, in MB
|
||||||
|
# UPLOAD_CHUNK_SIZE_MB=16
|
||||||
|
|
||||||
# Docker (used only when deploying with docker-compose.yml)
|
# Docker (used only when deploying with docker-compose.yml)
|
||||||
# SERVER_NAME=share.example.com
|
# SERVER_NAME=share.example.com
|
||||||
|
|||||||
+27
-2
@@ -5,7 +5,32 @@ All notable changes to this project are documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [2.1.0] - 2026-09-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A password generator for share passwords, with a copy button. The password is shown once more beside the new link. Admins can turn it off, or switch between random characters and a passphrase, in Admin settings.
|
||||||
|
- `AUTO_HTTPS` for the Docker image: set it to `"true"` with `SERVER_NAME` to get a Let's Encrypt certificate and serve HTTPS. Without it the container serves plain HTTP on port 80, as before.
|
||||||
|
- `UPLOAD_CHUNK_SIZE_MB` sets the size of each upload chunk (default 16).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Breaking: uploads need HTTPS.** Files are now encrypted in the browser and uploaded in chunks, which browsers only allow over HTTPS or on `localhost`. Over plain HTTP downloads still work, but uploads don't. Use `AUTO_HTTPS` or a reverse proxy that terminates TLS.
|
||||||
|
- Large uploads are much faster: each chunk is written to disk once, already encrypted, and a failed chunk is retried.
|
||||||
|
- Each share has its own random key; with a share password it is protected with Argon2id instead of PBKDF2. Existing shares keep working.
|
||||||
|
- PHP's upload limits no longer cap the share file size. `PHP_UPLOAD_MAX_FILESIZE` and `PHP_POST_MAX_SIZE` default to `64M`, and `LIVEWIRE_MAX_UPLOAD_TIME` is no longer needed.
|
||||||
|
- Unfinished uploads count towards the storage quota and are deleted after 4 hours.
|
||||||
|
- The interface moves to [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material) 2.1.0 and no longer ships Tailwind CSS. Every page uses the same single-column layout of cards, and the admin dashboard lists shares instead of a table. Colour profiles and light/dark choices carry over.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- "Download all" works for large shares: the ZIP is streamed instead of being built in memory and written unencrypted to a temporary file.
|
||||||
|
- Unencrypted copies of uploads no longer stay behind in Livewire's temporary folder; the hourly cleanup removes old ones.
|
||||||
|
- Removed the unused `docker/Caddyfile`.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- An encrypted file with missing or reordered chunks now fails to decrypt.
|
||||||
|
|
||||||
## [2.0.1] - 2026-09-13
|
## [2.0.1] - 2026-09-13
|
||||||
|
|
||||||
@@ -113,6 +138,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI.
|
- 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.1...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.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,87 @@ 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. It replaces UI kits such as maryUI, daisyUI and Flux, and Tailwind CSS, in this application.
|
||||||
|
|
||||||
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
|
- 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`.
|
||||||
|
- Never write a utility class — Tailwind's, the library's 1.x ones or daisyUI's — or a maryUI tag. Nothing defines them, so they compile to nothing and fail silently. Layout is the layout components (`<x-row>`, `<x-stack>`, `<x-grid>`, `<x-surface>`, `<x-pane>`), text is `md-type-*` and `md-ink-*`, and everything else is the application's own CSS on `--md-sys-*` custom properties.
|
||||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
|
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
|
||||||
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
|
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
|
||||||
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
|
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
|
||||||
|
|
||||||
|
=== nonameweb/livewire-material/material-3 rules ===
|
||||||
|
|
||||||
|
## Material 3
|
||||||
|
|
||||||
|
Every view in this application is Material 3 Expressive (m3.material.io), through `nonameweb/livewire-material`. These rules decide what to write; the `material-3-design` skill carries the tables, the numbers and Google's source pages behind each one — activate it before designing a screen.
|
||||||
|
|
||||||
|
The library is plain CSS on M3's tokens, and there are no utility classes: a Tailwind class, or one of the library's 1.x utilities (bg-primary, type-body-md, medium:hidden), compiles to nothing. A view is written three ways:
|
||||||
|
- Components and their props: `<x-button variant="filled">`, and the layout components `<x-row>`, `<x-stack>`, `<x-grid>`, `<x-feed>`, `<x-surface>` and `<x-pane>`, whose `gap` and `padding` take a spacing token (`space200`) and whose `hide-below`, `hide-from` and `stack-below` take a window size class.
|
||||||
|
- A fixed set of classes for text and interaction on plain elements: `md-type-*`, `md-ink-*`, `md-text-*`, `md-truncate`, `md-tabular`, `md-visually-hidden`, `md-state-layer`, `md-focus-ring`, `md-touch-target` and `md-link`.
|
||||||
|
- The application's own CSS, named by the application, whose values are `--md-sys-*` custom properties.
|
||||||
|
|
||||||
|
### Colour
|
||||||
|
|
||||||
|
- A colour is always a role: `md-ink-variant` on text, `var(--md-sys-color-outline-variant)` in the application's CSS, `color="error"` on a component. Never a hex, a palette tone or an opacity.
|
||||||
|
- Pair a role only with its `on-` partner: a `primary` fill takes `on-primary` text, a `secondary-container` fill takes `on-secondary-container`. That pair is the one whose contrast is guaranteed at every contrast level; mixing pairs (`primary-container` under `on-surface`) is not.
|
||||||
|
- `primary` is the one key action on a screen (a filled button; the FAB in `primary-container`). `secondary-container` is the quiet fill (tonal buttons, selected navigation, selected chips). `tertiary` is a contrasting accent, used rarely. `error`, `success`, `warning`, `info` mean state and nothing else: the `-container` for a tinted panel, the role itself for its text and icon.
|
||||||
|
- Ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Never dim ink with an opacity: 38% means disabled.
|
||||||
|
- `outline` is a boundary that must be read (a text field, the edge of a target). `outline-variant` is a divider or a card edge (`<x-divider>`, `<x-surface outlined>`). Never `outline` on a divider.
|
||||||
|
- Fixed and dim roles (`primary-fixed`, `surface-dim`, …) are for a colour that must not change with the theme; if unsure, don't. Inverse roles only on an inverse surface (the snackbar).
|
||||||
|
- A link in running text is underlined (`md-link`, with `md-ink-primary`); colour alone signals nothing.
|
||||||
|
- Contrast: 4.5:1 for text, 3:1 for large text, icons and grouped controls; disabled is exempt. Three contrast levels exist (`<html data-contrast>`: standard, medium, high) and every role changes with them — which is why only roles are allowed.
|
||||||
|
|
||||||
|
### Surfaces and elevation
|
||||||
|
|
||||||
|
- The page is `surface`. Panels separate by tone first: `surface-container-lowest` … `surface-container-highest` is a hierarchy of emphasis, not of height (`<x-surface level="surface-container-high">`). Navigation chrome is `surface-container`; a dialog, a menu, the search bar are `surface-container-high`; a modal sheet is `surface-container-low`; a filled card is `surface-container-highest`. A region keeps its role at every width.
|
||||||
|
- Shadows (`var(--md-sys-elevation-1)` … `-5`) are for what floats or lifts: 1 for elevated cards, buttons and modal sheets; 2 for menus, the navigation bar, a scrolled app bar; 3 for the FAB, dialogs, pickers and search; one level more on hover; nothing rests above 3. Fewer shadows carry more meaning.
|
||||||
|
- A scrim is `scrim` at 32%: `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)`.
|
||||||
|
|
||||||
|
### Shape
|
||||||
|
|
||||||
|
- Corners come from the scale `var(--md-sys-shape-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full})`, or `<x-surface corner="md">`; never a length of your own.
|
||||||
|
- By family: `full` buttons, icon buttons, chips' avatars, badges, switches, sliders, the search bar, navigation indicators; `xs` text fields, menus, snackbars, plain tooltips; `sm` chips; `md` cards, rich tooltips; `lg` the FAB and a side sheet's inner corners; `xl` dialogs, bottom sheets, the search view, pickers, carousel items; `xxl` large hero containers.
|
||||||
|
- Nested shapes: inner radius = outer radius − padding; never the same radius inside and out.
|
||||||
|
- A press squares a round shape (the components do it; nothing morphs on hover). The 35 `<x-shape>`s are decoration, never meaning, used sparingly.
|
||||||
|
|
||||||
|
### Type
|
||||||
|
|
||||||
|
- Every text element carries one `md-type-*` class: `display` for hero figures and short marketing lines; `headline` for page and section titles; `title` for card, dialog and list-section titles; `body` for paragraphs (`md-type-body-lg` for reading); `label` inside components (buttons, chips, tabs, captions). In the application's CSS a style is `font: var(--md-sys-typescale-body-md)` with its `-tracking`; never a size, weight, line height or letter spacing of your own.
|
||||||
|
- `md-type-emphasized-*` is opt-in: a selected item, a primary action, a headline, a badge — not decoration.
|
||||||
|
- 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>
|
||||||
|
|||||||
+3
-6
@@ -71,9 +71,6 @@ ENV APP_NAME="SealShare" \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy Caddyfile
|
|
||||||
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
|
||||||
|
|
||||||
# Copy PHP ini for upload limits
|
# Copy PHP ini for upload limits
|
||||||
COPY docker/php/uploads.ini /usr/local/etc/php/conf.d/99-uploads.ini
|
COPY docker/php/uploads.ini /usr/local/etc/php/conf.d/99-uploads.ini
|
||||||
|
|
||||||
@@ -98,12 +95,12 @@ RUN rm -rf node_modules tests .gitea docker/dev.Dockerfile docker/dev-entrypoint
|
|||||||
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
|
||||||
@@ -42,8 +42,8 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|
|||||||
| **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, Tailwind CSS 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 |
|
||||||
@@ -75,7 +75,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,7 +88,11 @@ 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:**
|
||||||
|
|
||||||
@@ -101,16 +105,15 @@ Migrations run automatically on startup. Open your configured domain — the Set
|
|||||||
|
|
||||||
**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 +155,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).
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ 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;
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
@@ -21,14 +27,49 @@ class CleanupExpiredShares extends Command
|
|||||||
})
|
})
|
||||||
->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,12 @@ 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\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,41 +21,53 @@ class DownloadController extends Controller
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download all files as a ZIP archive.
|
* Download all files as a ZIP archive, streamed file by file as it is decrypted: stored without
|
||||||
|
* compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk.
|
||||||
*/
|
*/
|
||||||
public function download(Share $share): BinaryFileResponse
|
public function download(Share $share): StreamedResponse
|
||||||
{
|
{
|
||||||
abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||||
|
|
||||||
$share->load('files');
|
$share->load('files');
|
||||||
$key = $this->resolveDecryptionKey($share);
|
$key = $this->resolveDecryptionKey($share);
|
||||||
|
|
||||||
$tempPath = tempnam(sys_get_temp_dir(), 'sealshare_');
|
return new StreamedResponse(function () use ($share, $key): void {
|
||||||
|
$zip = new ZipStream(
|
||||||
$zip = new ZipArchive;
|
defaultCompressionMethod: CompressionMethod::STORE,
|
||||||
$zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
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, '..')) {
|
|
||||||
$filename = basename($filename);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$zip->addFromString($filename, $content);
|
if (! $chunks->valid()) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$zip->close();
|
$chunk = $chunks->current();
|
||||||
|
$chunks->next();
|
||||||
|
|
||||||
|
return $chunk;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->finish();
|
||||||
|
|
||||||
$this->shareService->recordDownload($share);
|
$this->shareService->recordDownload($share);
|
||||||
|
}, 200, [
|
||||||
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'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,7 +75,7 @@ class DownloadController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function downloadFile(Share $share, ShareFile $shareFile): StreamedResponse
|
public function downloadFile(Share $share, ShareFile $shareFile): StreamedResponse
|
||||||
{
|
{
|
||||||
abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||||
abort_if($shareFile->share_id !== $share->id, 404);
|
abort_if($shareFile->share_id !== $share->id, 404);
|
||||||
|
|
||||||
$key = $this->resolveDecryptionKey($share);
|
$key = $this->resolveDecryptionKey($share);
|
||||||
@@ -90,6 +103,21 @@ class DownloadController extends Controller
|
|||||||
}, 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,26 +41,38 @@ 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());
|
||||||
})->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(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace App\Livewire\Admin;
|
namespace App\Livewire\Admin;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
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 +37,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 +70,38 @@ 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],
|
'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]),
|
...$this->passwordGeneratorMessages(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($this->systemPassword) {
|
if ($this->systemPassword) {
|
||||||
@@ -116,6 +119,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 +137,87 @@ 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))],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function passwordGeneratorMessages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'passwordCharacterSets.required' => __('Choose at least one kind of character.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the generator settings that passed validation; excluded ones keep their saved value.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
protected function savePasswordGeneratorSettings(array $validated): void
|
||||||
|
{
|
||||||
|
Setting::set('password_generator_mode', $validated['passwordGeneratorMode']);
|
||||||
|
|
||||||
|
if (array_key_exists('passwordGeneratorType', $validated)) {
|
||||||
|
Setting::set('password_generator_type', $validated['passwordGeneratorType']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('passwordLength', $validated)) {
|
||||||
|
Setting::set('password_generator_length', $validated['passwordLength']);
|
||||||
|
Setting::set('password_generator_character_sets', implode(',', $validated['passwordCharacterSets']));
|
||||||
|
Setting::set('password_generator_avoid_ambiguous', $validated['passwordAvoidAmbiguous'] ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('passphraseWords', $validated)) {
|
||||||
|
Setting::set('password_generator_words', $validated['passphraseWords']);
|
||||||
|
Setting::set('password_generator_separator', $validated['passphraseSeparator']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form's generator options while they are valid, for the example; `null` otherwise.
|
||||||
|
*
|
||||||
|
* @return array{type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}|null
|
||||||
|
*/
|
||||||
|
protected function passwordPreviewOptions(): ?array
|
||||||
|
{
|
||||||
|
$values = $this->only(['passwordGeneratorMode', 'passwordGeneratorType', 'passwordLength', 'passwordCharacterSets', 'passwordAvoidAmbiguous', 'passphraseWords', 'passphraseSeparator']);
|
||||||
|
|
||||||
|
if ($this->passwordGeneratorMode === 'off' || Validator::make($values, $this->passwordGeneratorRules())->fails()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $this->passwordGeneratorType,
|
||||||
|
'length' => $this->passwordLength,
|
||||||
|
'characterSets' => array_values($this->passwordCharacterSets),
|
||||||
|
'avoidAmbiguous' => $this->passwordAvoidAmbiguous,
|
||||||
|
'words' => $this->passphraseWords,
|
||||||
|
'separator' => $this->passphraseSeparator,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function removeLogo(): void
|
public function removeLogo(): void
|
||||||
{
|
{
|
||||||
$existingLogo = Setting::get('site_logo');
|
$existingLogo = Setting::get('site_logo');
|
||||||
@@ -157,10 +243,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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-109
@@ -3,24 +3,27 @@
|
|||||||
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 Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
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,90 +41,100 @@ class FileUploader extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle an upload the temporary upload endpoint did not accept.
|
* Register the files a visitor chose and hand the browser what it encrypts and sends them
|
||||||
|
* with. A file an admin limit refuses gets `null` in its place and the reason under `files`.
|
||||||
*
|
*
|
||||||
* Validation errors (a 422) mean the whole file reached the server and was
|
* @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(
|
||||||
throw ValidationException::withMessages([
|
$this->pendingShare(),
|
||||||
'files' => __('Upload failed: the server could not accept the file. Please try again or contact the administrator.'),
|
(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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$maxFileSizeMb = (int) ((int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024));
|
$targets[] = null;
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
continue;
|
||||||
'files' => __('Upload failed: file may be too large (max :max MB) or the connection was interrupted.', ['max' => $maxFileSizeMb]),
|
}
|
||||||
]);
|
|
||||||
|
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a freshly uploaded batch of files.
|
* Take files out of the pending share, whether or not their upload finished.
|
||||||
*
|
*
|
||||||
* Dispatches `files-processed` so the front end can drop its "uploading" state.
|
* @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');
|
$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) {
|
/**
|
||||||
if ($file->getSize() > $maxFileSize) {
|
* Fill in a generated password as protection is switched on, when the admin chose "Prefilled".
|
||||||
$this->addError('files', __('":name" is too large (:size MB). Maximum file size is :max MB.', [
|
* A password already in the field stays.
|
||||||
'name' => $file->getClientOriginalName(),
|
*/
|
||||||
'size' => round($file->getSize() / (1024 * 1024), 1),
|
public function updatedUsePassword(bool $value): void
|
||||||
'max' => (int) $maxFileSizeMb,
|
|
||||||
]));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeFile(int $index): void
|
|
||||||
{
|
{
|
||||||
unset($this->files[$index], $this->relativePaths[$index]);
|
$passwordGenerator = app(PasswordGeneratorService::class);
|
||||||
$this->files = array_values($this->files);
|
|
||||||
$this->relativePaths = array_values($this->relativePaths);
|
if ($value && $this->password === '' && $passwordGenerator->mode() === 'prefill') {
|
||||||
|
$this->password = $passwordGenerator->generate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generatePassword(PasswordGeneratorService $passwordGenerator): void
|
||||||
|
{
|
||||||
|
if ($passwordGenerator->mode() === 'off') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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],
|
|
||||||
'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'];
|
$rules['expiration'] = ['required', 'string', 'in:1h,24h,48h,7d,14d,30d'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,46 +142,23 @@ class FileUploader extends Component
|
|||||||
$rules['password'] = ['required', 'string', 'min:8'];
|
$rules['password'] = ['required', 'string', 'min:8'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($rules !== []) {
|
||||||
$this->validate($rules, [
|
$this->validate($rules, [
|
||||||
'expiration.required' => __('An expiration time is required.'),
|
'expiration.required' => __('An expiration time is required.'),
|
||||||
'files.required' => __('Please select at least one file to upload.'),
|
|
||||||
'files.max' => __('Too many files. Maximum :max files allowed per share.'),
|
|
||||||
'files.*.max' => __('A file exceeds the maximum size of :max KB.'),
|
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($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, [
|
||||||
|
'password' => $this->usePassword ? $this->password : null,
|
||||||
if ($totalSize > $maxSizePerShare) {
|
'expires_at' => match ($this->expiration) {
|
||||||
$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(),
|
'1h' => now()->addHour(),
|
||||||
'24h' => now()->addDay(),
|
'24h' => now()->addDay(),
|
||||||
'48h' => now()->addDays(2),
|
'48h' => now()->addDays(2),
|
||||||
@@ -176,27 +166,47 @@ class FileUploader extends Component
|
|||||||
'14d' => now()->addDays(14),
|
'14d' => now()->addDays(14),
|
||||||
'30d' => now()->addMonth(),
|
'30d' => now()->addMonth(),
|
||||||
default => null,
|
default => null,
|
||||||
};
|
},
|
||||||
|
|
||||||
$share = $shareService->createShare($fileData, [
|
|
||||||
'password' => $this->usePassword ? $this->password : null,
|
|
||||||
'expires_at' => $expiresAt,
|
|
||||||
'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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,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')]
|
||||||
|
|||||||
@@ -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,7 +2,6 @@
|
|||||||
|
|
||||||
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 Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
@@ -24,7 +23,7 @@ class ShareDownload extends Component
|
|||||||
{
|
{
|
||||||
$this->share = $share->load('files');
|
$this->share = $share->load('files');
|
||||||
|
|
||||||
if ($share->isExpired() || $share->hasReachedDownloadLimit()) {
|
if (! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit()) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +65,6 @@ class ShareDownload extends Component
|
|||||||
|
|
||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
return view('livewire.share-download', [
|
return view('livewire.share-download');
|
||||||
'siteTitle' => Setting::get('site_title'),
|
|
||||||
'siteDescription' => Setting::get('site_description'),
|
|
||||||
'siteLogo' => Setting::get('site_logo'),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')]
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ class Share extends Model
|
|||||||
'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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +34,7 @@ class Share extends Model
|
|||||||
'download_count' => 'integer',
|
'download_count' => 'integer',
|
||||||
'total_size' => 'integer',
|
'total_size' => 'integer',
|
||||||
'encryption_key' => 'encrypted',
|
'encryption_key' => 'encrypted',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +46,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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 +36,17 @@ 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
|
||||||
{
|
{
|
||||||
@@ -48,17 +70,147 @@ class FileEncryptionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt a file using chunked AES-256-GCM.
|
* Wrap a share's data key with a key derived from its password (Argon2id).
|
||||||
*
|
*
|
||||||
* Output format:
|
* 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
|
||||||
|
{
|
||||||
|
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
|
||||||
|
$opslimit = SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE;
|
||||||
|
$memlimit = SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE;
|
||||||
|
|
||||||
|
$wrappingKey = $this->deriveWrappingKey($password, $salt, $opslimit, $memlimit);
|
||||||
|
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||||
|
$box = sodium_crypto_secretbox(hex2bin($dataKeyHex), $nonce, $wrappingKey);
|
||||||
|
|
||||||
|
sodium_memzero($wrappingKey);
|
||||||
|
|
||||||
|
return implode('$', [self::WRAPPED_KEY_ALGORITHM, $opslimit, $memlimit, bin2hex($salt), bin2hex($nonce), bin2hex($box)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap a share's data key with its password; returns the key as hex.
|
||||||
|
*/
|
||||||
|
public function unwrapKey(string $wrappedKey, string $password): string
|
||||||
|
{
|
||||||
|
$parts = explode('$', $wrappedKey);
|
||||||
|
|
||||||
|
if (count($parts) !== 6 || $parts[0] !== self::WRAPPED_KEY_ALGORITHM) {
|
||||||
|
throw new RuntimeException('Unsupported wrapped key');
|
||||||
|
}
|
||||||
|
|
||||||
|
[, $opslimit, $memlimit, $salt, $nonce, $box] = $parts;
|
||||||
|
|
||||||
|
$wrappingKey = $this->deriveWrappingKey($password, hex2bin($salt), (int) $opslimit, (int) $memlimit);
|
||||||
|
$dataKey = sodium_crypto_secretbox_open(hex2bin($box), hex2bin($nonce), $wrappingKey);
|
||||||
|
|
||||||
|
sodium_memzero($wrappingKey);
|
||||||
|
|
||||||
|
if ($dataKey === false) {
|
||||||
|
throw new RuntimeException('Unwrapping failed - wrong password or corrupted key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return bin2hex($dataKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header a new encrypted file starts with, with a fresh random nonce prefix.
|
||||||
|
*/
|
||||||
|
public function createHeader(int $chunkSize): string
|
||||||
|
{
|
||||||
|
return self::MAGIC.pack('N', $chunkSize).random_bytes(self::NONCE_PREFIX_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a `SEALCHK2` header.
|
||||||
|
*
|
||||||
|
* @return array{chunkSize: int, noncePrefix: string}
|
||||||
|
*/
|
||||||
|
public function parseHeader(string $header): array
|
||||||
|
{
|
||||||
|
if (strlen($header) !== self::HEADER_LENGTH || ! str_starts_with($header, self::MAGIC)) {
|
||||||
|
throw new RuntimeException('Invalid encrypted file header');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'chunkSize' => unpack('N', substr($header, 8, 4))[1],
|
||||||
|
'noncePrefix' => substr($header, 12, self::NONCE_PREFIX_LENGTH),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many chunks a file of this size is sent in; an empty file is one empty chunk.
|
||||||
|
*/
|
||||||
|
public function chunkCount(int $size, int $chunkSize): int
|
||||||
|
{
|
||||||
|
return max(1, intdiv($size + $chunkSize - 1, $chunkSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where chunk `$index` starts in the encrypted file.
|
||||||
|
*/
|
||||||
|
public function chunkOffset(int $index, int $chunkSize): int
|
||||||
|
{
|
||||||
|
return self::HEADER_LENGTH + $index * ($chunkSize + self::TAG_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt one chunk: its ciphertext followed by its tag, as WebCrypto returns it.
|
||||||
|
*/
|
||||||
|
public function encryptChunk(string $plaintext, string $key, string $noncePrefix, int $index, bool $isLast): string
|
||||||
|
{
|
||||||
|
$tag = '';
|
||||||
|
|
||||||
|
$ciphertext = openssl_encrypt(
|
||||||
|
$plaintext,
|
||||||
|
self::CIPHER,
|
||||||
|
$this->normalizeToBinaryKey($key),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$this->chunkNonce($noncePrefix, $index, $isLast),
|
||||||
|
$tag,
|
||||||
|
'',
|
||||||
|
self::TAG_LENGTH,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($ciphertext === false) {
|
||||||
|
throw new RuntimeException('Encryption failed at chunk '.$index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ciphertext.$tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt one chunk, which fails unless its index and last-chunk flag are the ones it was
|
||||||
|
* encrypted with.
|
||||||
|
*/
|
||||||
|
public function decryptChunk(string $chunk, string $key, string $noncePrefix, int $index, bool $isLast): string
|
||||||
|
{
|
||||||
|
if (strlen($chunk) < self::TAG_LENGTH) {
|
||||||
|
throw new RuntimeException('Invalid encrypted file: truncated chunk '.$index);
|
||||||
|
}
|
||||||
|
|
||||||
|
$plaintext = openssl_decrypt(
|
||||||
|
substr($chunk, 0, -self::TAG_LENGTH),
|
||||||
|
self::CIPHER,
|
||||||
|
$this->normalizeToBinaryKey($key),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$this->chunkNonce($noncePrefix, $index, $isLast),
|
||||||
|
substr($chunk, -self::TAG_LENGTH),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($plaintext === false) {
|
||||||
|
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a file on the server in the `SEALCHK2` format.
|
||||||
|
*/
|
||||||
|
public function encryptFile(string $sourcePath, string $destPath, string $key, int $chunkSize): void
|
||||||
{
|
{
|
||||||
$source = fopen($sourcePath, 'rb');
|
$source = fopen($sourcePath, 'rb');
|
||||||
|
|
||||||
@@ -75,45 +227,16 @@ class FileEncryptionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$binaryKey = $this->normalizeToBinaryKey($key);
|
$header = $this->createHeader($chunkSize);
|
||||||
$baseNonce = random_bytes(self::NONCE_LENGTH);
|
$noncePrefix = $this->parseHeader($header)['noncePrefix'];
|
||||||
$chunkSize = self::DEFAULT_CHUNK_SIZE;
|
$chunkCount = $this->chunkCount((int) filesize($sourcePath), $chunkSize);
|
||||||
|
|
||||||
// Write header
|
fwrite($dest, $header);
|
||||||
fwrite($dest, self::MAGIC_HEADER);
|
|
||||||
fwrite($dest, pack('N', $chunkSize));
|
|
||||||
fwrite($dest, $baseNonce);
|
|
||||||
|
|
||||||
$chunkIndex = 0;
|
for ($index = 0; $index < $chunkCount; $index++) {
|
||||||
|
$plaintext = (string) fread($source, $chunkSize);
|
||||||
|
|
||||||
while (! feof($source)) {
|
fwrite($dest, $this->encryptChunk($plaintext, $key, $noncePrefix, $index, $index === $chunkCount - 1));
|
||||||
$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) {
|
} catch (RuntimeException $e) {
|
||||||
fclose($source);
|
fclose($source);
|
||||||
@@ -127,74 +250,33 @@ class FileEncryptionService
|
|||||||
fclose($dest);
|
fclose($dest);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a file and return the plaintext content.
|
|
||||||
*/
|
|
||||||
public function decryptFile(string $encryptedPath, string $key): string
|
|
||||||
{
|
|
||||||
if ($this->isChunkedFormat($encryptedPath)) {
|
|
||||||
$parts = [];
|
|
||||||
|
|
||||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
|
||||||
$parts[] = $chunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode('', $parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->decryptLegacy($encryptedPath, $key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a file and stream the response.
|
|
||||||
*/
|
|
||||||
public function decryptFileStream(string $encryptedPath, string $key, string $filename, string $mimeType, ?int $fileSize = null): StreamedResponse
|
|
||||||
{
|
|
||||||
$headers = [
|
|
||||||
'Content-Type' => $mimeType ?: 'application/octet-stream',
|
|
||||||
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', $filename, 'download'),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($fileSize !== null) {
|
|
||||||
$headers['Content-Length'] = $fileSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isChunkedFormat($encryptedPath)) {
|
|
||||||
return new StreamedResponse(function () use ($encryptedPath, $key): void {
|
|
||||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
|
||||||
echo $chunk;
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
}, 200, $headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
$content = $this->decryptLegacy($encryptedPath, $key);
|
|
||||||
|
|
||||||
if (! isset($headers['Content-Length'])) {
|
|
||||||
$headers['Content-Length'] = strlen($content);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new StreamedResponse(function () use ($content): void {
|
|
||||||
echo $content;
|
|
||||||
}, 200, $headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream decrypted file content directly to output (echo).
|
* Stream decrypted file content directly to output (echo).
|
||||||
* Use this when you need to add post-streaming logic inside a StreamedResponse callback.
|
|
||||||
*/
|
*/
|
||||||
public function streamDecryptedFile(string $encryptedPath, string $key): void
|
public function streamDecryptedFile(string $encryptedPath, string $key): void
|
||||||
{
|
{
|
||||||
if ($this->isChunkedFormat($encryptedPath)) {
|
foreach ($this->decryptedChunks($encryptedPath, $key) as $chunk) {
|
||||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
|
||||||
echo $chunk;
|
echo $chunk;
|
||||||
flush();
|
flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
echo $this->decryptLegacy($encryptedPath, $key);
|
/**
|
||||||
|
* The decrypted content of a file in any of the three formats, chunk by chunk.
|
||||||
|
*
|
||||||
|
* @return Generator<int, string>
|
||||||
|
*/
|
||||||
|
public function decryptedChunks(string $encryptedPath, string $key): Generator
|
||||||
|
{
|
||||||
|
$magic = (string) file_get_contents($encryptedPath, false, null, 0, 8);
|
||||||
|
|
||||||
|
if ($magic === self::MAGIC) {
|
||||||
|
yield from $this->decryptChunks($encryptedPath, $key);
|
||||||
|
} elseif ($magic === self::LEGACY_CHUNKED_MAGIC) {
|
||||||
|
yield from $this->decryptLegacyChunks($encryptedPath, $key);
|
||||||
|
} else {
|
||||||
|
yield $this->decryptLegacy($encryptedPath, $key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,73 +287,29 @@ class FileEncryptionService
|
|||||||
return strlen($key) === 64 ? hex2bin($key) : $key;
|
return strlen($key) === 64 ? hex2bin($key) : $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function deriveWrappingKey(string $password, string $salt, int $opslimit, int $memlimit): string
|
||||||
* Derive a unique nonce for a chunk by XORing the chunk index into the last 4 bytes.
|
|
||||||
*/
|
|
||||||
private function deriveChunkNonce(string $baseNonce, int $chunkIndex): string
|
|
||||||
{
|
{
|
||||||
$nonce = $baseNonce;
|
return sodium_crypto_pwhash(
|
||||||
$indexBytes = pack('N', $chunkIndex);
|
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
|
||||||
|
$password,
|
||||||
for ($i = 0; $i < 4; $i++) {
|
$salt,
|
||||||
$nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i];
|
$opslimit,
|
||||||
}
|
$memlimit,
|
||||||
|
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13,
|
||||||
return $nonce;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file uses the chunked encryption format.
|
|
||||||
*/
|
|
||||||
private function isChunkedFormat(string $path): bool
|
|
||||||
{
|
|
||||||
$handle = fopen($path, 'rb');
|
|
||||||
|
|
||||||
if ($handle === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$magic = fread($handle, 8);
|
|
||||||
fclose($handle);
|
|
||||||
|
|
||||||
return $magic === self::MAGIC_HEADER;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a legacy single-block encrypted file.
|
|
||||||
* Format: [12-byte nonce][16-byte auth tag][ciphertext]
|
|
||||||
*/
|
|
||||||
private function decryptLegacy(string $encryptedPath, string $key): string
|
|
||||||
{
|
|
||||||
$data = file_get_contents($encryptedPath);
|
|
||||||
|
|
||||||
if ($data === false) {
|
|
||||||
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$binaryKey = $this->normalizeToBinaryKey($key);
|
|
||||||
$nonce = substr($data, 0, self::NONCE_LENGTH);
|
|
||||||
$tag = substr($data, self::NONCE_LENGTH, self::TAG_LENGTH);
|
|
||||||
$ciphertext = substr($data, self::NONCE_LENGTH + self::TAG_LENGTH);
|
|
||||||
|
|
||||||
$plaintext = openssl_decrypt(
|
|
||||||
$ciphertext,
|
|
||||||
self::CIPHER,
|
|
||||||
$binaryKey,
|
|
||||||
OPENSSL_RAW_DATA,
|
|
||||||
$nonce,
|
|
||||||
$tag,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($plaintext === false) {
|
|
||||||
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $plaintext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generator that yields decrypted plaintext chunks from a chunked encrypted file.
|
* A `SEALCHK2` chunk's nonce: the file's prefix, the chunk index and the last-chunk flag.
|
||||||
|
*/
|
||||||
|
private function chunkNonce(string $noncePrefix, int $index, bool $isLast): string
|
||||||
|
{
|
||||||
|
return $noncePrefix.pack('N', $index).($isLast ? "\x01" : "\x00");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a `SEALCHK2` file; the chunk count comes from the file's length, so a file cut short
|
||||||
|
* at a chunk boundary fails on its new last chunk.
|
||||||
*
|
*
|
||||||
* @return Generator<int, string>
|
* @return Generator<int, string>
|
||||||
*/
|
*/
|
||||||
@@ -284,17 +322,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 +385,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 +405,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+276
-48
@@ -5,12 +5,20 @@ 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 Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
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
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -18,67 +26,236 @@ class ShareService
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new share with encrypted files.
|
* Register a file the uploader's browser is about to send, in the given pending share or in a
|
||||||
|
* new one, and write its encrypted file's header.
|
||||||
*
|
*
|
||||||
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $files
|
* @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;
|
|
||||||
|
|
||||||
foreach ($files as $fileData) {
|
|
||||||
/** @var UploadedFile $file */
|
|
||||||
$file = $fileData['file'];
|
|
||||||
$relativePath = $fileData['relativePath'] ?? null;
|
|
||||||
$storedName = Str::uuid().'.enc';
|
$storedName = Str::uuid().'.enc';
|
||||||
$storedPath = 'shares/'.$share->token.'/'.$storedName;
|
|
||||||
|
|
||||||
$tempPath = $file->getRealPath();
|
|
||||||
$destPath = Storage::disk('shares')->path($share->token.'/'.$storedName);
|
|
||||||
|
|
||||||
Storage::disk('shares')->makeDirectory($share->token);
|
Storage::disk('shares')->makeDirectory($share->token);
|
||||||
|
Storage::disk('shares')->put($share->token.'/'.$storedName, $this->encryptionService->createHeader((int) config('uploads.chunk_size')));
|
||||||
|
|
||||||
$this->encryptionService->encryptFile($tempPath, $destPath, $encryptionKeyHex);
|
$file = $share->files()->create([
|
||||||
|
'original_name' => $name,
|
||||||
ShareFile::query()->create([
|
'relative_path' => $this->sanitizeRelativePath($relativePath),
|
||||||
'share_id' => $share->id,
|
'stored_path' => 'shares/'.$share->token.'/'.$storedName,
|
||||||
'original_name' => $file->getClientOriginalName(),
|
'file_size' => $size,
|
||||||
'relative_path' => $relativePath,
|
|
||||||
'stored_path' => $storedPath,
|
|
||||||
'file_size' => $file->getSize(),
|
|
||||||
'mime_type' => $file->getMimeType(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$totalSize += $file->getSize();
|
$share->increment('total_size', $size);
|
||||||
|
|
||||||
|
return $file;
|
||||||
}
|
}
|
||||||
|
|
||||||
$share->update(['total_size' => $totalSize]);
|
/**
|
||||||
|
* Verify the encrypted chunk that comes next for a file and write it into place; returns how
|
||||||
|
* many of the file's chunks are stored. The plaintext only exists in memory, to be checked.
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException when the chunk has the wrong length or fails authentication
|
||||||
|
* @throws ModelNotFoundException when the file was removed meanwhile
|
||||||
|
*/
|
||||||
|
public function storeChunk(ShareFile $file, int $index, string $chunk): int
|
||||||
|
{
|
||||||
|
$share = $file->share;
|
||||||
|
$handle = @fopen($this->storedFilePath($file), 'r+b');
|
||||||
|
|
||||||
return $share->fresh();
|
if ($handle === false) {
|
||||||
|
throw (new ModelNotFoundException)->setModel(ShareFile::class, [$file->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
['chunkSize' => $chunkSize, 'noncePrefix' => $noncePrefix] = $this->encryptionService->parseHeader(
|
||||||
|
(string) fread($handle, FileEncryptionService::HEADER_LENGTH),
|
||||||
|
);
|
||||||
|
|
||||||
|
$chunkCount = $this->encryptionService->chunkCount($file->file_size, $chunkSize);
|
||||||
|
$isLast = $index === $chunkCount - 1;
|
||||||
|
|
||||||
|
if ($index >= $chunkCount) {
|
||||||
|
throw new InvalidArgumentException('Chunk '.$index.' is beyond the end of the file');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plaintextLength = $isLast ? $file->file_size - $index * $chunkSize : $chunkSize;
|
||||||
|
|
||||||
|
if (strlen($chunk) !== $plaintextLength + FileEncryptionService::TAG_LENGTH) {
|
||||||
|
throw new InvalidArgumentException('Chunk '.$index.' has the wrong length');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$plaintext = $this->encryptionService->decryptChunk($chunk, $share->encryption_key, $noncePrefix, $index, $isLast);
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
throw new InvalidArgumentException('Chunk '.$index.' failed authentication');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mimeType = $index === 0
|
||||||
|
? ((new FinfoMimeTypeDetector)->detectMimeType($file->original_name, $plaintext) ?? 'application/octet-stream')
|
||||||
|
: $file->mime_type;
|
||||||
|
|
||||||
|
unset($plaintext);
|
||||||
|
|
||||||
|
if (fseek($handle, $this->encryptionService->chunkOffset($index, $chunkSize)) !== 0
|
||||||
|
|| fwrite($handle, $chunk) !== strlen($chunk)
|
||||||
|
|| ! fflush($handle)) {
|
||||||
|
throw new RuntimeException('Cannot write chunk '.$index.' of file '.$file->id);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counted only once, even when a retry of the same chunk raced this request.
|
||||||
|
$stored = ShareFile::query()
|
||||||
|
->whereKey($file->id)
|
||||||
|
->where('uploaded_chunks', $index)
|
||||||
|
->update([
|
||||||
|
'uploaded_chunks' => $index + 1,
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'completed_at' => $isLast ? now() : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($stored === 0) {
|
||||||
|
return ShareFile::query()->findOrFail($file->id)->uploaded_chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
$share->touch();
|
||||||
|
|
||||||
|
return $index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a file from a pending share, whether or not its upload finished.
|
||||||
|
*/
|
||||||
|
public function removeFile(ShareFile $file): void
|
||||||
|
{
|
||||||
|
Storage::disk('shares')->delete($file->share->token.'/'.basename($file->stored_path));
|
||||||
|
|
||||||
|
$file->share->decrement('total_size', $file->file_size);
|
||||||
|
$file->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a pending share once every file has arrived: with a password the data key is
|
||||||
|
* wrapped and no longer stored as it is.
|
||||||
|
*
|
||||||
|
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
|
||||||
|
*
|
||||||
|
* @throws ValidationException when files are missing, unfinished or break an admin limit
|
||||||
|
*/
|
||||||
|
public function completeShare(Share $share, array $options = []): Share
|
||||||
|
{
|
||||||
|
$files = $share->files()->get();
|
||||||
|
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||||
|
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
||||||
|
|
||||||
|
if ($files->isEmpty()) {
|
||||||
|
$this->rejectFile(__('Please select at least one file to upload.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($files->contains(fn (ShareFile $file): bool => $file->completed_at === null)) {
|
||||||
|
$this->rejectFile(__('Wait until every file has finished uploading, or remove the ones that failed.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($files->count() > $maxFilesPerShare) {
|
||||||
|
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($files->sum('file_size') > $maxSizePerShare) {
|
||||||
|
$this->rejectFile(__('Total file size exceeds the maximum allowed per share.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = $options['password'] ?? null;
|
||||||
|
|
||||||
|
$share->update([
|
||||||
|
'password' => $password ? Hash::make($password) : null,
|
||||||
|
'wrapped_key' => $password ? $this->encryptionService->wrapKey($share->encryption_key, $password) : null,
|
||||||
|
'encryption_key' => $password ? null : $share->encryption_key,
|
||||||
|
'expires_at' => $options['expires_at'] ?? null,
|
||||||
|
'max_downloads' => $options['max_downloads'] ?? null,
|
||||||
|
'total_size' => $files->sum('file_size'),
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $share;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a share from files already on the server, through the same steps an upload from the
|
||||||
|
* browser takes. Used by tests and demo data.
|
||||||
|
*
|
||||||
|
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $files
|
||||||
|
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
|
||||||
|
*/
|
||||||
|
public function createShare(array $files, array $options = []): Share
|
||||||
|
{
|
||||||
|
$share = null;
|
||||||
|
|
||||||
|
foreach ($files as $fileData) {
|
||||||
|
$file = $fileData['file'];
|
||||||
|
$shareFile = $this->registerFile($share, $file->getClientOriginalName(), $file->getSize(), $fileData['relativePath'] ?? null);
|
||||||
|
$share = $shareFile->share;
|
||||||
|
|
||||||
|
$header = $this->readHeader($shareFile);
|
||||||
|
$source = fopen($file->getRealPath(), 'rb');
|
||||||
|
|
||||||
|
for ($index = 0; $index < $header['chunkCount']; $index++) {
|
||||||
|
$plaintextLength = min($header['chunkSize'], $shareFile->file_size - $index * $header['chunkSize']);
|
||||||
|
|
||||||
|
// A fake upload reports a size its content does not have: zeros make up the rest.
|
||||||
|
$chunk = $this->encryptionService->encryptChunk(
|
||||||
|
str_pad($plaintextLength > 0 ? (string) fread($source, $plaintextLength) : '', $plaintextLength, "\0"),
|
||||||
|
$share->encryption_key,
|
||||||
|
$header['noncePrefix'],
|
||||||
|
$index,
|
||||||
|
$index === $header['chunkCount'] - 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->storeChunk($shareFile->refresh(), $index, $chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($share === null) {
|
||||||
|
$this->rejectFile(__('Please select at least one file to upload.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->completeShare($share, $options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,7 +285,8 @@ class ShareService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the decryption key for a share.
|
* Get the decryption key for a share: unwrapped with the password, derived from it for shares
|
||||||
|
* created before key wrapping, or stored for shares without a password.
|
||||||
*/
|
*/
|
||||||
public function getDecryptionKey(Share $share, ?string $password = null): string
|
public function getDecryptionKey(Share $share, ?string $password = null): string
|
||||||
{
|
{
|
||||||
@@ -117,6 +295,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +330,7 @@ class ShareService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get total used space in bytes.
|
* Get total used space in bytes, files still being uploaded included.
|
||||||
*/
|
*/
|
||||||
public function getTotalUsedSpace(): int
|
public function getTotalUsedSpace(): int
|
||||||
{
|
{
|
||||||
@@ -160,9 +342,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 +352,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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -16,7 +16,8 @@
|
|||||||
"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",
|
||||||
@@ -88,7 +89,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
+351
-185
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -24,8 +24,8 @@ services:
|
|||||||
LOG_CHANNEL: stack
|
LOG_CHANNEL: stack
|
||||||
LOG_LEVEL: debug
|
LOG_LEVEL: debug
|
||||||
OCTANE_MAX_EXECUTION_TIME: "300"
|
OCTANE_MAX_EXECUTION_TIME: "300"
|
||||||
PHP_UPLOAD_MAX_FILESIZE: "4G"
|
PHP_UPLOAD_MAX_FILESIZE: "64M"
|
||||||
PHP_POST_MAX_SIZE: "4G"
|
PHP_POST_MAX_SIZE: "64M"
|
||||||
PHP_MAX_EXECUTION_TIME: "300"
|
PHP_MAX_EXECUTION_TIME: "300"
|
||||||
PHP_MAX_INPUT_TIME: "300"
|
PHP_MAX_INPUT_TIME: "300"
|
||||||
PHP_MEMORY_LIMIT: "512M"
|
PHP_MEMORY_LIMIT: "512M"
|
||||||
|
|||||||
+19
-11
@@ -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,7 +22,7 @@ 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
|
||||||
@@ -32,9 +32,15 @@ services:
|
|||||||
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.
|
||||||
|
|
||||||
|
# --- 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
|
||||||
# APP_DEBUG: "false"
|
# APP_DEBUG: "false"
|
||||||
@@ -53,15 +59,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
|
||||||
|
|||||||
+5
-4
@@ -19,6 +19,7 @@ services:
|
|||||||
APP_URL: ${APP_URL:-http://localhost}
|
APP_URL: ${APP_URL:-http://localhost}
|
||||||
APP_ENV: ${APP_ENV:-production}
|
APP_ENV: ${APP_ENV:-production}
|
||||||
APP_DEBUG: ${APP_DEBUG:-false}
|
APP_DEBUG: ${APP_DEBUG:-false}
|
||||||
|
AUTO_HTTPS: ${AUTO_HTTPS:-false}
|
||||||
SERVER_NAME: ${SERVER_NAME:-localhost}
|
SERVER_NAME: ${SERVER_NAME:-localhost}
|
||||||
DB_CONNECTION: ${DB_CONNECTION:-sqlite}
|
DB_CONNECTION: ${DB_CONNECTION:-sqlite}
|
||||||
DB_HOST: ${DB_HOST:-}
|
DB_HOST: ${DB_HOST:-}
|
||||||
@@ -33,14 +34,14 @@ services:
|
|||||||
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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,8 @@ cd /app
|
|||||||
# Generate PHP ini from environment variables (with defaults)
|
# Generate PHP ini from environment variables (with defaults)
|
||||||
echo "[dev] Configuring PHP settings..."
|
echo "[dev] Configuring PHP settings..."
|
||||||
cat > /usr/local/etc/php/conf.d/99-uploads.ini <<EOF
|
cat > /usr/local/etc/php/conf.d/99-uploads.ini <<EOF
|
||||||
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-4G}
|
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-64M}
|
||||||
post_max_size = ${PHP_POST_MAX_SIZE:-4G}
|
post_max_size = ${PHP_POST_MAX_SIZE:-64M}
|
||||||
max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300}
|
max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300}
|
||||||
max_input_time = ${PHP_MAX_INPUT_TIME:-300}
|
max_input_time = ${PHP_MAX_INPUT_TIME:-300}
|
||||||
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
||||||
|
|||||||
+15
-3
@@ -15,8 +15,8 @@ fi
|
|||||||
# Generate PHP ini from environment variables (with defaults)
|
# Generate PHP ini from environment variables (with defaults)
|
||||||
echo "[entrypoint] Configuring PHP settings..."
|
echo "[entrypoint] Configuring PHP settings..."
|
||||||
cat > /usr/local/etc/php/conf.d/99-uploads.ini <<EOF
|
cat > /usr/local/etc/php/conf.d/99-uploads.ini <<EOF
|
||||||
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-4G}
|
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-64M}
|
||||||
post_max_size = ${PHP_POST_MAX_SIZE:-4G}
|
post_max_size = ${PHP_POST_MAX_SIZE:-64M}
|
||||||
max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300}
|
max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300}
|
||||||
max_input_time = ${PHP_MAX_INPUT_TIME:-300}
|
max_input_time = ${PHP_MAX_INPUT_TIME:-300}
|
||||||
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
||||||
@@ -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
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
; These are default values — overridden at runtime by the entrypoint
|
; These are default values — overridden at runtime by the entrypoint
|
||||||
; when PHP_UPLOAD_MAX_FILESIZE / PHP_POST_MAX_SIZE / etc. env vars are set.
|
; when PHP_UPLOAD_MAX_FILESIZE / PHP_POST_MAX_SIZE / etc. env vars are set.
|
||||||
|
|
||||||
upload_max_filesize = 4G
|
upload_max_filesize = 64M
|
||||||
post_max_size = 4G
|
post_max_size = 64M
|
||||||
max_execution_time = 300
|
max_execution_time = 300
|
||||||
max_input_time = 300
|
max_input_time = 300
|
||||||
memory_limit = 512M
|
memory_limit = 512M
|
||||||
|
|||||||
Generated
+142
-776
File diff suppressed because it is too large
Load Diff
+2
-5
@@ -7,15 +7,12 @@
|
|||||||
"dev": "vite"
|
"dev": "vite"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"autoprefixer": "^10.6.1",
|
||||||
"autoprefixer": "^10.5.5",
|
|
||||||
"concurrently": "^10.0.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": {
|
"overrides": {
|
||||||
|
|||||||
+310
-8
@@ -1,22 +1,324 @@
|
|||||||
@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
|
||||||
|
* instead of Tailwind's animate-pulse loop — 2.0.0 keeps no keyframe utility for it. The
|
||||||
|
* transition sits on the code itself so the way back eases too; under reduced motion the token's
|
||||||
|
* duration is 0ms.
|
||||||
|
*/
|
||||||
|
.settings-recovery-code {
|
||||||
|
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-recovery-code--loading {
|
||||||
|
opacity: var(--md-sys-state-disabled-content-opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/pages/settings/appearance.blade.php: the theme picker stays a comfortable
|
||||||
|
* width instead of stretching across the settings card. No `<x-group>` width prop caps it, and
|
||||||
|
* 24rem matches no `<x-pane>` preset.
|
||||||
|
*/
|
||||||
|
.settings-appearance-picker {
|
||||||
|
max-inline-size: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/admin/admin-dashboard.blade.php: the sort select above the shares list
|
||||||
|
* keeps to the width its longest option needs instead of spanning the card. No `<x-select>` width
|
||||||
|
* prop caps it, and 20rem matches no `<x-pane>` preset.
|
||||||
|
*/
|
||||||
|
.admin-shares-sort {
|
||||||
|
max-inline-size: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/admin/admin-dashboard.blade.php: a share's details are two lines of
|
||||||
|
* their own (its files, size and downloads; its expiry), and they wrap rather than clip. The
|
||||||
|
* package clamps a list item's description at two lines with an ellipsis, which on a phone hid
|
||||||
|
* the expiry with no way to read it.
|
||||||
|
*/
|
||||||
|
.admin-shares [data-md-list-item-description] {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-share-detail {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/admin/admin-settings.blade.php: the current and previewed site logo,
|
||||||
|
* at 1.x's 4rem height with its width following the image's own ratio. M3 keeps no size scale for
|
||||||
|
* a plain <img>.
|
||||||
|
*/
|
||||||
|
.admin-settings-logo {
|
||||||
|
block-size: 4rem;
|
||||||
|
border-radius: var(--md-sys-shape-corner-sm);
|
||||||
|
}
|
||||||
|
|||||||
+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,317 @@
|
|||||||
|
/**
|
||||||
|
* `shareUploader`: the upload page's queue. Files are registered with the Livewire component in one
|
||||||
|
* batch per selection, then sent one at a time: each chunk is sliced from the file, encrypted here
|
||||||
|
* with AES-256-GCM and PUT on its own, so the server writes it once, already encrypted.
|
||||||
|
*
|
||||||
|
* The encrypted format is App\Services\FileEncryptionService's SEALCHK2: chunk i's nonce is the
|
||||||
|
* file's 7-byte prefix, i as a big-endian uint32 and a byte that is 1 on the last chunk; WebCrypto
|
||||||
|
* appends the 16-byte tag to the ciphertext, which is how the server stores it.
|
||||||
|
*
|
||||||
|
* A failed request is retried after 1, 2, 4, 8 and 16 seconds; after that the file waits for its
|
||||||
|
* Retry button, which picks up from the chunk the server last confirmed. The server answers 409
|
||||||
|
* with its own count when a chunk skips ahead, and acknowledges a chunk it already has.
|
||||||
|
*/
|
||||||
|
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
window.Alpine.data('shareUploader', ({ csrfToken, messages }) => ({
|
||||||
|
secure: window.isSecureContext && Boolean(window.crypto?.subtle),
|
||||||
|
|
||||||
|
dragging: false,
|
||||||
|
|
||||||
|
busy: false,
|
||||||
|
|
||||||
|
/** Files waiting to be sent, in order: { id, file, target, nextIndex }. */
|
||||||
|
queue: [],
|
||||||
|
|
||||||
|
/** Every file this page registered, by id: { state: 'queued'|'uploading'|'uploaded'|'failed', sent, size }. */
|
||||||
|
uploads: {},
|
||||||
|
|
||||||
|
/** The files that failed, by id, kept for their Retry button. */
|
||||||
|
failed: {},
|
||||||
|
|
||||||
|
/** The request on its way, so Cancel and Remove can abort it. */
|
||||||
|
request: null,
|
||||||
|
|
||||||
|
get progress() {
|
||||||
|
const unfinished = Object.values(this.uploads).filter((upload) => upload.state !== 'failed')
|
||||||
|
const size = unfinished.reduce((total, upload) => total + upload.size, 0)
|
||||||
|
|
||||||
|
return size === 0 ? 0 : (unfinished.reduce((total, upload) => total + upload.sent, 0) / size) * 100
|
||||||
|
},
|
||||||
|
|
||||||
|
choose(event) {
|
||||||
|
this.add([...event.target.files].map((file) => ({ file, path: null })))
|
||||||
|
|
||||||
|
event.target.value = ''
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDrop(event) {
|
||||||
|
this.dragging = false
|
||||||
|
|
||||||
|
if (! this.secure) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = event.dataTransfer.items
|
||||||
|
const files = []
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const entry = items[i].webkitGetAsEntry?.()
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
this.traverseEntry(entry, '', files)
|
||||||
|
} else if (items[i].kind === 'file') {
|
||||||
|
files.push({ file: items[i].getAsFile(), path: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory entries are read asynchronously.
|
||||||
|
setTimeout(() => this.add(files), 500)
|
||||||
|
},
|
||||||
|
|
||||||
|
traverseEntry(entry, path, files) {
|
||||||
|
if (entry.isFile) {
|
||||||
|
entry.file((file) => files.push({ file, path: path ? `${path}/${file.name}` : null }))
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
entry.createReader().readEntries((entries) => {
|
||||||
|
entries.forEach((child) => this.traverseEntry(child, path ? `${path}/${entry.name}` : entry.name, files))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async add(selection) {
|
||||||
|
if (! this.secure || selection.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await this.$wire.registerFiles(selection.map(({ file, path }) => ({ name: file.name, size: file.size, path })))
|
||||||
|
|
||||||
|
;(targets ?? []).forEach((target, position) => {
|
||||||
|
if (! target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uploads[target.id] = { state: 'queued', sent: 0, size: selection[position].file.size }
|
||||||
|
this.queue.push({ id: target.id, file: selection[position].file, target, nextIndex: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.run()
|
||||||
|
},
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
if (this.busy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.busy = true
|
||||||
|
|
||||||
|
while (this.queue.length > 0) {
|
||||||
|
const item = this.queue[0]
|
||||||
|
const uploaded = await this.upload(item)
|
||||||
|
|
||||||
|
if (this.queue[0] === item) {
|
||||||
|
this.queue.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploaded) {
|
||||||
|
await this.$wire.$refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.busy = false
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send one file's remaining chunks; true once the server has them all.
|
||||||
|
*/
|
||||||
|
async upload(item) {
|
||||||
|
const { id, file, target } = item
|
||||||
|
const upload = this.uploads[id]
|
||||||
|
|
||||||
|
if (! upload) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
upload.state = 'uploading'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = await crypto.subtle.importKey('raw', bytesFromHex(target.key), 'AES-GCM', false, ['encrypt'])
|
||||||
|
const noncePrefix = bytesFromHex(target.noncePrefix)
|
||||||
|
|
||||||
|
while (item.nextIndex < target.chunkCount) {
|
||||||
|
const index = item.nextIndex
|
||||||
|
const start = index * target.chunkSize
|
||||||
|
const plaintext = await file.slice(start, start + target.chunkSize).arrayBuffer()
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv: chunkNonce(noncePrefix, index, index === target.chunkCount - 1), tagLength: 128 },
|
||||||
|
key,
|
||||||
|
plaintext,
|
||||||
|
)
|
||||||
|
|
||||||
|
item.nextIndex = await this.send(upload, `${target.url}/${index}`, ciphertext, start, plaintext.byteLength)
|
||||||
|
upload.sent = Math.min(item.nextIndex * target.chunkSize, upload.size)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
upload.state = 'failed'
|
||||||
|
this.failed[id] = item
|
||||||
|
|
||||||
|
if (error?.status === 419) {
|
||||||
|
window.materialToast(messages.sessionExpired, { type: 'error' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
upload.state = 'uploaded'
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT one encrypted chunk, retrying transient failures; resolves with the number of chunks
|
||||||
|
* the server holds for the file.
|
||||||
|
*/
|
||||||
|
async send(upload, url, body, offset, plaintextLength) {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
const response = await this.put(url, body, (loaded) => {
|
||||||
|
upload.sent = Math.min(offset + (loaded / body.byteLength) * plaintextLength, upload.size)
|
||||||
|
})
|
||||||
|
|
||||||
|
if ((response.status === 200 || response.status === 409) && Number.isInteger(response.uploadedChunks)) {
|
||||||
|
return response.uploadedChunks
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404 || response.status === 419 || attempt === RETRY_DELAYS.length) {
|
||||||
|
throw Object.assign(new Error('Upload failed'), { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt]))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
put(url, body, onProgress) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
|
||||||
|
xhr.open('PUT', url)
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json')
|
||||||
|
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken)
|
||||||
|
xhr.upload.onprogress = (event) => onProgress(event.loaded)
|
||||||
|
xhr.onload = () => {
|
||||||
|
this.request = null
|
||||||
|
resolve({ status: xhr.status, uploadedChunks: parseUploadedChunks(xhr.responseText) })
|
||||||
|
}
|
||||||
|
xhr.onerror = () => {
|
||||||
|
this.request = null
|
||||||
|
resolve({ status: 0 })
|
||||||
|
}
|
||||||
|
xhr.onabort = () => {
|
||||||
|
this.request = null
|
||||||
|
reject(new DOMException('Upload cancelled', 'AbortError'))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.request = xhr
|
||||||
|
// Chromium sends a Blob body about eight times faster than the same ArrayBuffer.
|
||||||
|
xhr.send(new Blob([body]))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
retry(id) {
|
||||||
|
const item = this.failed[id]
|
||||||
|
|
||||||
|
if (! item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.failed[id]
|
||||||
|
this.uploads[id].state = 'queued'
|
||||||
|
this.queue.push(item)
|
||||||
|
this.run()
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(id) {
|
||||||
|
this.forget([id])
|
||||||
|
this.$wire.removeFiles([id])
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop everything still to send and take those files out of the share.
|
||||||
|
*/
|
||||||
|
cancel() {
|
||||||
|
const unfinished = Object.entries(this.uploads)
|
||||||
|
.filter(([, upload]) => upload.state !== 'uploaded')
|
||||||
|
.map(([id]) => Number(id))
|
||||||
|
|
||||||
|
this.forget(unfinished)
|
||||||
|
this.$wire.removeFiles(unfinished)
|
||||||
|
},
|
||||||
|
|
||||||
|
forget(ids) {
|
||||||
|
const current = this.queue[0]
|
||||||
|
|
||||||
|
this.queue = this.queue.filter((item) => ! ids.includes(item.id))
|
||||||
|
|
||||||
|
ids.forEach((id) => {
|
||||||
|
delete this.uploads[id]
|
||||||
|
delete this.failed[id]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (current && ids.includes(current.id)) {
|
||||||
|
this.request?.abort()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
statusOf(id, uploaded) {
|
||||||
|
const upload = this.uploads[id]
|
||||||
|
|
||||||
|
if (uploaded || upload?.state === 'uploaded') {
|
||||||
|
return messages.uploaded
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upload?.state === 'uploading') {
|
||||||
|
return `${Math.round((upload.sent / Math.max(upload.size, 1)) * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
return upload?.state === 'failed' ? messages.failed : messages.queued
|
||||||
|
},
|
||||||
|
|
||||||
|
warnBeforeLeaving(event) {
|
||||||
|
if (this.busy) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function bytesFromHex(hex) {
|
||||||
|
return Uint8Array.from(hex.match(/.{2}/g), (pair) => parseInt(pair, 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk `index`'s 12-byte nonce: the file's prefix, the index as a big-endian uint32 and the
|
||||||
|
* last-chunk flag.
|
||||||
|
*/
|
||||||
|
function chunkNonce(prefix, index, isLast) {
|
||||||
|
const nonce = new Uint8Array(12)
|
||||||
|
|
||||||
|
nonce.set(prefix, 0)
|
||||||
|
new DataView(nonce.buffer).setUint32(7, index)
|
||||||
|
nonce[11] = isLast ? 1 : 0
|
||||||
|
|
||||||
|
return nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUploadedChunks(responseText) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(responseText).uploaded_chunks
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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,60 +1,65 @@
|
|||||||
<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 select above the list instead of column headers. --}}
|
||||||
|
<x-card :title="__('All Shares')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space200">
|
||||||
@if ($shares->total() === 0)
|
@if ($shares->total() === 0)
|
||||||
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
|
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
|
||||||
@else
|
@else
|
||||||
<div class="-mx-4 overflow-x-auto">
|
<div class="admin-shares-sort">
|
||||||
<x-table>
|
<x-select
|
||||||
<thead>
|
wire:model.live="sort"
|
||||||
<tr>
|
:label="__('Sort by')"
|
||||||
<x-sort-header column="token" :sort-by="$sortBy">{{ __('Token') }}</x-sort-header>
|
:options="[
|
||||||
<x-sort-header column="files_count" :sort-by="$sortBy" class="text-end">{{ __('Files') }}</x-sort-header>
|
['id' => 'newest', 'name' => __('Newest first')],
|
||||||
<x-sort-header column="total_size" :sort-by="$sortBy" class="text-end">{{ __('Size') }}</x-sort-header>
|
['id' => 'oldest', 'name' => __('Oldest first')],
|
||||||
<x-sort-header column="download_count" :sort-by="$sortBy" class="text-end">{{ __('Downloads') }}</x-sort-header>
|
['id' => 'expiring', 'name' => __('Expiring soonest')],
|
||||||
<x-sort-header column="expires_at" :sort-by="$sortBy">{{ __('Expires') }}</x-sort-header>
|
['id' => 'largest', 'name' => __('Largest')],
|
||||||
<x-sort-header column="created_at" :sort-by="$sortBy">{{ __('Created') }}</x-sort-header>
|
['id' => 'most-downloaded', 'name' => __('Most downloads')],
|
||||||
<th><span class="sr-only">{{ __('Actions') }}</span></th>
|
['id' => 'most-files', 'name' => __('Most files')],
|
||||||
</tr>
|
]"
|
||||||
</thead>
|
data-test="shares-sort"
|
||||||
<tbody>
|
/>
|
||||||
@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>
|
||||||
|
|
||||||
<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;
|
||||||
|
the details are two short lines that never clip (admin-shares, app.css). --}}
|
||||||
|
<x-list dividers :label="__('All Shares')" class="admin-shares">
|
||||||
|
@foreach ($shares as $share)
|
||||||
|
<x-list-item :overline="__('Created :time', ['time' => $share->created_at->diffForHumans()])" wire:key="share-{{ $share->id }}" data-test="share-row">
|
||||||
|
<a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a>
|
||||||
|
|
||||||
|
<x-slot:description>
|
||||||
|
<span class="admin-share-detail md-tabular">{{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ trans_choice(':count download|:count downloads', $share->download_count) }}</span>
|
||||||
|
|
||||||
|
@if (! $share->expires_at)
|
||||||
|
<span class="admin-share-detail">{{ __('Never expires') }}</span>
|
||||||
|
@elseif ($share->isExpired())
|
||||||
|
<span class="admin-share-detail md-ink-error">{{ __('Expired :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
|
||||||
|
@else
|
||||||
|
<span class="admin-share-detail">{{ __('Expires :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
|
||||||
@endif
|
@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>
|
||||||
|
|
||||||
<x-modal wire:model="deletingShareId" :title="__('Delete this share?')" icon="delete">
|
<x-modal wire:model="deletingShareId" :title="__('Delete this share?')" icon="delete">
|
||||||
@@ -65,4 +70,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,44 +1,45 @@
|
|||||||
<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">
|
||||||
|
<x-password full
|
||||||
wire:model="systemPassword"
|
wire:model="systemPassword"
|
||||||
:label="__('System Upload Password')"
|
:label="__('System Upload Password')"
|
||||||
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
|
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
|
||||||
@@ -46,15 +47,109 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
@if ($hasSystemPassword)
|
@if ($hasSystemPassword)
|
||||||
<div>
|
|
||||||
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
|
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-stack>
|
||||||
|
</x-stack>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
<x-card :title="__('Upload Limits')" variant="outlined">
|
{{-- How the upload page offers random share passwords (App\Services\PasswordGeneratorService).
|
||||||
<div class="grid gap-5">
|
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 ($passwordGeneratorMode !== 'off')
|
||||||
|
<x-group
|
||||||
|
wire:model.live="passwordGeneratorType"
|
||||||
|
:label="__('Kind')"
|
||||||
|
:hint="$passwordGeneratorType === 'passphrase' ? __('Random words, easy to read out or type on a phone.') : __('Random characters, the most secure for their length.')"
|
||||||
|
:options="[
|
||||||
|
['id' => 'characters', 'name' => __('Characters')],
|
||||||
|
['id' => 'passphrase', 'name' => __('Passphrase')],
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if ($passwordGeneratorType === 'passphrase')
|
||||||
|
<x-input full
|
||||||
|
wire:model.live.blur="passphraseWords"
|
||||||
|
:label="__('Words')"
|
||||||
|
type="number"
|
||||||
|
:min="\App\Services\PasswordGeneratorService::MIN_WORDS"
|
||||||
|
:max="\App\Services\PasswordGeneratorService::MAX_WORDS"
|
||||||
|
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_WORDS, 'max' => \App\Services\PasswordGeneratorService::MAX_WORDS])"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-select full
|
||||||
|
wire:model.live="passphraseSeparator"
|
||||||
|
:label="__('Separator')"
|
||||||
|
:options="[
|
||||||
|
['id' => 'hyphen', 'name' => __('Hyphen (-)')],
|
||||||
|
['id' => 'dot', 'name' => __('Dot (.)')],
|
||||||
|
['id' => 'underscore', 'name' => __('Underscore (_)')],
|
||||||
|
['id' => 'space', 'name' => __('Space')],
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
@else
|
||||||
|
<x-input full
|
||||||
|
wire:model.live.blur="passwordLength"
|
||||||
|
:label="__('Length')"
|
||||||
|
type="number"
|
||||||
|
:min="\App\Services\PasswordGeneratorService::MIN_LENGTH"
|
||||||
|
:max="\App\Services\PasswordGeneratorService::MAX_LENGTH"
|
||||||
|
:suffix="__('characters')"
|
||||||
|
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_LENGTH, 'max' => \App\Services\PasswordGeneratorService::MAX_LENGTH])"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-group
|
||||||
|
multiple
|
||||||
|
wire:model.live="passwordCharacterSets"
|
||||||
|
:label="__('Include')"
|
||||||
|
:hint="__('Uppercase letters, lowercase letters, numbers and symbols.')"
|
||||||
|
:options="[
|
||||||
|
['id' => 'uppercase', 'name' => '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
|
||||||
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
|
||||||
|
<x-card :title="__('Upload Limits')" heading="h2" variant="outlined">
|
||||||
|
<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,7 +157,7 @@
|
|||||||
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"
|
||||||
@@ -76,24 +171,23 @@
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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">
|
||||||
|
<x-input full
|
||||||
wire:model="maxStorageQuota"
|
wire:model="maxStorageQuota"
|
||||||
:label="__('Max storage quota (GB)')"
|
:label="__('Max storage quota (GB)')"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -101,13 +195,16 @@
|
|||||||
suffix="GB"
|
suffix="GB"
|
||||||
:hint="__('When reached, new uploads are blocked.')"
|
: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 +220,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,178 +1,129 @@
|
|||||||
<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
|
|
||||||
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>
|
</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
|
@endif
|
||||||
|
|
||||||
<x-select
|
<x-button
|
||||||
|
:label="__('Copy')"
|
||||||
|
icon="content_copy"
|
||||||
|
variant="tonal"
|
||||||
|
x-on:click="navigator.clipboard.writeText($wire.password).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
|
||||||
|
x-bind:disabled="! $wire.password"
|
||||||
|
data-test="copy-password"
|
||||||
|
/>
|
||||||
|
</x-row>
|
||||||
|
</x-stack>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-select full
|
||||||
wire:model="expiration"
|
wire:model="expiration"
|
||||||
:label="__('Expiration')"
|
:label="__('Expiration')"
|
||||||
:placeholder="$allowNeverExpire ? __('Never') : null"
|
:placeholder="$allowNeverExpire ? __('Never') : null"
|
||||||
@@ -186,27 +137,28 @@
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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-slot:actions>
|
||||||
<x-button
|
<x-button
|
||||||
type="submit"
|
type="submit"
|
||||||
:label="__('Create Share Link')"
|
:label="__('Create Share Link')"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="md"
|
size="md"
|
||||||
class="w-full"
|
|
||||||
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,7 +1,6 @@
|
|||||||
<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">
|
||||||
<form wire:submit="createAdmin" class="flex flex-col gap-6">
|
|
||||||
<x-input
|
<x-input
|
||||||
wire:model="name"
|
wire:model="name"
|
||||||
:label="__('Name')"
|
:label="__('Name')"
|
||||||
@@ -35,6 +34,9 @@
|
|||||||
:placeholder="__('Confirm password')"
|
:placeholder="__('Confirm password')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" class="w-full" spinner="createAdmin" />
|
<x-slot:actions>
|
||||||
</form>
|
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" spinner="createAdmin" />
|
||||||
</div>
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
|
</x-card>
|
||||||
|
</x-page>
|
||||||
|
|||||||
@@ -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,20 +17,22 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<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-form>
|
||||||
</x-card>
|
</x-card>
|
||||||
</form>
|
|
||||||
@else
|
@else
|
||||||
<x-card :title="__('Shared Files')" variant="outlined">
|
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space200">
|
||||||
|
<x-stack gap="space100">
|
||||||
<x-list :label="__('Shared Files')">
|
<x-list :label="__('Shared Files')">
|
||||||
@foreach ($share->files as $file)
|
@foreach ($share->files as $file)
|
||||||
<x-list-item
|
<x-list-item
|
||||||
:title="$file->relative_path ?: $file->original_name"
|
:title="$file->relative_path ?: $file->original_name"
|
||||||
:description="Number::fileSize($file->file_size)"
|
|
||||||
icon="description"
|
icon="description"
|
||||||
wire:key="file-{{ $file->id }}"
|
wire:key="file-{{ $file->id }}"
|
||||||
>
|
>
|
||||||
|
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
|
||||||
<x-slot:end>
|
<x-slot:end>
|
||||||
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" />
|
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" />
|
||||||
</x-slot:end>
|
</x-slot:end>
|
||||||
@@ -46,18 +41,18 @@
|
|||||||
</x-list>
|
</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() }}
|
|
||||||
</p>
|
|
||||||
@endif
|
@endif
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
<x-slot:actions>
|
<x-row justify="end">
|
||||||
@if ($share->files->count() > 1)
|
@if ($share->files->count() > 1)
|
||||||
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate class="w-full" />
|
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate />
|
||||||
@else
|
@else
|
||||||
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate class="w-full" />
|
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate />
|
||||||
@endif
|
@endif
|
||||||
</x-slot:actions>
|
</x-row>
|
||||||
|
</x-stack>
|
||||||
</x-card>
|
</x-card>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-page>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<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">
|
||||||
<form wire:submit="verify" class="flex flex-col gap-6">
|
|
||||||
<x-password
|
<x-password
|
||||||
wire:model="password"
|
wire:model="password"
|
||||||
:label="__('Password')"
|
:label="__('Password')"
|
||||||
@@ -9,6 +8,9 @@
|
|||||||
:placeholder="__('System password')"
|
:placeholder="__('System password')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-button type="submit" :label="__('Continue')" variant="filled" class="w-full" spinner="verify" />
|
<x-slot:actions>
|
||||||
</form>
|
<x-button type="submit" :label="__('Continue')" variant="filled" spinner="verify" />
|
||||||
</div>
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
|
</x-card>
|
||||||
|
</x-page>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<x-layouts::auth :title="__('Confirm password')">
|
<x-layouts::app :title="__('Confirm password')">
|
||||||
<x-auth-header
|
<x-page brand>
|
||||||
|
<x-card
|
||||||
:title="__('Confirm password')"
|
:title="__('Confirm password')"
|
||||||
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
|
:subtitle="__('This is a secure area of the application. Please confirm your password before continuing.')"
|
||||||
/>
|
heading="h2"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<x-stack gap="space300">
|
||||||
<x-auth-session-status :status="session('status')" />
|
<x-auth-session-status :status="session('status')" />
|
||||||
|
|
||||||
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-5">
|
<x-form method="POST" action="{{ route('password.confirm.store') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<x-password
|
<x-password
|
||||||
@@ -17,6 +20,11 @@
|
|||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-button type="submit" :label="__('Confirm')" variant="filled" class="w-full" data-test="confirm-password-button" />
|
<x-slot:actions>
|
||||||
</form>
|
<x-button type="submit" :label="__('Confirm')" variant="filled" data-test="confirm-password-button" />
|
||||||
</x-layouts::auth>
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
</x-page>
|
||||||
|
</x-layouts::app>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<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')" />
|
||||||
|
|
||||||
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-5">
|
<x-form method="POST" action="{{ route('password.email') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<x-input
|
<x-input
|
||||||
@@ -17,11 +18,16 @@
|
|||||||
icon="mail"
|
icon="mail"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-button type="submit" :label="__('Email password reset link')" variant="filled" class="w-full" data-test="email-password-reset-link-button" />
|
<x-slot:actions>
|
||||||
</form>
|
<x-button type="submit" :label="__('Email password reset link')" variant="filled" data-test="email-password-reset-link-button" />
|
||||||
|
</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, return to') }}
|
{{ __('Or, return to') }}
|
||||||
<a href="{{ route('login') }}" class="link" wire:navigate>{{ __('log in') }}</a>
|
<a href="{{ route('login') }}" class="md-link" wire:navigate>{{ __('log in') }}</a>
|
||||||
</p>
|
</p>
|
||||||
</x-layouts::auth>
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
</x-page>
|
||||||
|
</x-layouts::app>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<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')" />
|
||||||
|
|
||||||
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-5">
|
<x-form method="POST" action="{{ route('login.store') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<x-input
|
<x-input
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
icon="mail"
|
icon="mail"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="grid gap-1">
|
<x-stack gap="space50">
|
||||||
<x-password
|
<x-password
|
||||||
name="password"
|
name="password"
|
||||||
:label="__('Password')"
|
:label="__('Password')"
|
||||||
@@ -27,14 +28,21 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
@if (Route::has('password.request'))
|
@if (Route::has('password.request'))
|
||||||
<a class="link w-fit justify-self-end type-label-lg" href="{{ route('password.request') }}" wire:navigate>
|
<x-row justify="end">
|
||||||
|
<a class="md-link md-type-label-lg" href="{{ route('password.request') }}" wire:navigate>
|
||||||
{{ __('Forgot your password?') }}
|
{{ __('Forgot your password?') }}
|
||||||
</a>
|
</a>
|
||||||
|
</x-row>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-stack>
|
||||||
|
|
||||||
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
|
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
|
||||||
|
|
||||||
<x-button type="submit" :label="__('Log in')" variant="filled" class="w-full" data-test="login-button" />
|
<x-slot:actions>
|
||||||
</form>
|
<x-button type="submit" :label="__('Log in')" variant="filled" data-test="login-button" />
|
||||||
</x-layouts::auth>
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
</x-page>
|
||||||
|
</x-layouts::app>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<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')" />
|
||||||
|
|
||||||
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-5">
|
<x-form method="POST" action="{{ route('password.update') }}">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
||||||
|
|
||||||
@@ -31,6 +32,11 @@
|
|||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-button type="submit" :label="__('Reset password')" variant="filled" class="w-full" data-test="reset-password-button" />
|
<x-slot:actions>
|
||||||
</form>
|
<x-button type="submit" :label="__('Reset password')" variant="filled" data-test="reset-password-button" />
|
||||||
</x-layouts::auth>
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
</x-page>
|
||||||
|
</x-layouts::app>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<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-stack
|
||||||
|
gap="space300"
|
||||||
x-data="{
|
x-data="{
|
||||||
showRecoveryInput: @js($errors->has('recovery_code')),
|
showRecoveryInput: {{ \Illuminate\Support\Js::from($errors->has('recovery_code')) }},
|
||||||
toggleInput() {
|
toggleInput() {
|
||||||
this.showRecoveryInput = ! this.showRecoveryInput;
|
this.showRecoveryInput = ! this.showRecoveryInput;
|
||||||
$nextTick(() => {
|
$nextTick(() => {
|
||||||
@@ -13,21 +15,15 @@
|
|||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div x-show="! showRecoveryInput">
|
<p class="md-type-body-md md-ink-variant" x-show="! showRecoveryInput">
|
||||||
<x-auth-header
|
{{ __('Enter the authentication code provided by your authenticator application.') }}
|
||||||
:title="__('Authentication Code')"
|
</p>
|
||||||
:description="__('Enter the authentication code provided by your authenticator application.')"
|
|
||||||
/>
|
|
||||||
</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">
|
||||||
@@ -53,13 +49,17 @@
|
|||||||
/>
|
/>
|
||||||
</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>
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
<x-layouts::auth :title="__('Verify email')">
|
<x-layouts::app :title="__('Verify email')">
|
||||||
<x-auth-header
|
<x-page brand>
|
||||||
|
<x-card
|
||||||
:title="__('Verify your email')"
|
:title="__('Verify your email')"
|
||||||
:description="__('Please verify your email address by clicking on the link we just emailed to you.')"
|
:subtitle="__('Please verify your email address by clicking on the link we just emailed to you.')"
|
||||||
/>
|
heading="h2"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<x-stack gap="space300">
|
||||||
@if (session('status') == 'verification-link-sent')
|
@if (session('status') == 'verification-link-sent')
|
||||||
<x-alert color="success">
|
<x-alert color="success">
|
||||||
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
||||||
</x-alert>
|
</x-alert>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex flex-col items-stretch gap-3">
|
<x-stack gap="space100">
|
||||||
<form method="POST" action="{{ route('verification.send') }}">
|
<x-form method="POST" action="{{ route('verification.send') }}">
|
||||||
@csrf
|
@csrf
|
||||||
<x-button type="submit" :label="__('Resend verification email')" variant="filled" class="w-full" />
|
<x-slot:actions>
|
||||||
</form>
|
<x-button type="submit" :label="__('Resend verification email')" variant="filled" />
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('logout') }}" class="self-center">
|
{{-- Log out posts elsewhere, so it is a form of its own; it sits under Resend at the same end
|
||||||
|
edge, the card's two actions end-aligned below its content. --}}
|
||||||
|
<x-row as="form" justify="end" method="POST" action="{{ route('logout') }}">
|
||||||
@csrf
|
@csrf
|
||||||
<x-button type="submit" :label="__('Log out')" data-test="logout-button" />
|
<x-button type="submit" :label="__('Log out')" data-test="logout-button" />
|
||||||
</form>
|
</x-row>
|
||||||
</div>
|
</x-stack>
|
||||||
</x-layouts::auth>
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
</x-page>
|
||||||
|
</x-layouts::app>
|
||||||
|
|||||||
@@ -11,15 +11,17 @@
|
|||||||
$items[] = ['title' => __('Appearance'), 'icon' => 'contrast', 'url' => route('appearance.edit'), 'active' => request()->routeIs('appearance.edit')];
|
$items[] = ['title' => __('Appearance'), 'icon' => 'contrast', 'url' => route('appearance.edit'), 'active' => request()->routeIs('appearance.edit')];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="w-full">
|
<x-page :title="__('Settings')" :description="__('Manage your profile and account settings')">
|
||||||
|
<x-slot:navigation>
|
||||||
<x-section-nav :items="$items" :label="__('Settings')" />
|
<x-section-nav :items="$items" :label="__('Settings')" />
|
||||||
|
</x-slot:navigation>
|
||||||
|
|
||||||
<div class="mt-8">
|
{{-- Every settings page is a card headed by its own title, as the admin's settings are. A page
|
||||||
<h2 class="type-title-lg">{{ $heading ?? '' }}</h2>
|
with a section that stands apart from that one subject — deleting the account, the recovery
|
||||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ $subheading ?? '' }}</p>
|
codes — puts it in `after`, where it becomes a card of its own under this one. --}}
|
||||||
|
<x-card :title="$heading ?? ''" :subtitle="$subheading ?? ''" heading="h2" variant="outlined">
|
||||||
<div class="mt-6 w-full max-w-lg">
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
</x-card>
|
||||||
</div>
|
|
||||||
</div>
|
{{ $after ?? '' }}
|
||||||
|
</x-page>
|
||||||
|
|||||||
@@ -45,48 +45,51 @@ new class extends Component {
|
|||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
|
{{--
|
||||||
|
Recovery codes are one more group within the two-factor settings page's single subject, not
|
||||||
|
content about a subject of their own: a heading and this stack's own spacing give the
|
||||||
|
hierarchy an outlined card would (M3 § Cards: "Don't force content into cards when simple
|
||||||
|
spacing, headlines and dividers would give a clearer hierarchy"). The enclosing page places
|
||||||
|
a divider on each side instead.
|
||||||
|
--}}
|
||||||
<x-card variant="outlined" wire:cloak x-data="{ showRecoveryCodes: false }">
|
<x-card variant="outlined" wire:cloak x-data="{ showRecoveryCodes: false }">
|
||||||
<div class="grid gap-4">
|
<x-stack gap="space200">
|
||||||
<div>
|
<x-stack gap="space50">
|
||||||
<div class="flex items-center gap-2">
|
<x-row gap="space100">
|
||||||
<x-icon name="lock" class="size-5 text-on-surface-variant" />
|
<x-icon name="lock" size="20" class="md-ink-variant" />
|
||||||
<h3 class="type-title-md">{{ __('2FA Recovery Codes') }}</h3>
|
<h2 class="md-type-title-md">{{ __('2FA Recovery Codes') }}</h2>
|
||||||
</div>
|
</x-row>
|
||||||
<p class="mt-1 type-body-md text-on-surface-variant">
|
<p class="md-type-body-md md-ink-variant">
|
||||||
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
|
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</x-stack>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<x-row gap="space100" wrap>
|
||||||
<span x-show="! showRecoveryCodes" class="inline-flex">
|
<x-button icon="visibility" :label="__('View Recovery Codes')" variant="tonal" x-show="! showRecoveryCodes" x-on:click="showRecoveryCodes = true" />
|
||||||
<x-button icon="visibility" :label="__('View Recovery Codes')" variant="tonal" x-on:click="showRecoveryCodes = true" />
|
<x-button icon="visibility_off" :label="__('Hide Recovery Codes')" variant="tonal" x-show="showRecoveryCodes" x-cloak x-on:click="showRecoveryCodes = false" />
|
||||||
</span>
|
|
||||||
<span x-show="showRecoveryCodes" x-cloak class="inline-flex">
|
|
||||||
<x-button icon="visibility_off" :label="__('Hide Recovery Codes')" variant="tonal" x-on:click="showRecoveryCodes = false" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
@if (filled($recoveryCodes))
|
@if (filled($recoveryCodes))
|
||||||
<span x-show="showRecoveryCodes" x-cloak class="inline-flex">
|
<x-button icon="refresh" :label="__('Regenerate Codes')" variant="outlined" x-show="showRecoveryCodes" x-cloak wire:click="regenerateRecoveryCodes" />
|
||||||
<x-button icon="refresh" :label="__('Regenerate Codes')" variant="outlined" wire:click="regenerateRecoveryCodes" />
|
|
||||||
</span>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-row>
|
||||||
|
|
||||||
<div x-show="showRecoveryCodes" x-cloak id="recovery-codes-section" class="grid gap-3">
|
<x-stack gap="space100" x-show="showRecoveryCodes" x-cloak id="recovery-codes-section">
|
||||||
@error('recoveryCodes')
|
@error('recoveryCodes')
|
||||||
<x-alert color="error">{{ $message }}</x-alert>
|
<x-alert color="error">{{ $message }}</x-alert>
|
||||||
@enderror
|
@enderror
|
||||||
|
|
||||||
@if (filled($recoveryCodes))
|
@if (filled($recoveryCodes))
|
||||||
<div class="grid gap-1 rounded-corner-md bg-surface-container-highest p-4 font-mono type-body-md" role="list" aria-label="{{ __('Recovery codes') }}">
|
<x-surface level="surface-container-highest" padding="space200" corner="md" class="md-type-body-md" role="list" :aria-label="__('Recovery codes')">
|
||||||
|
<x-stack gap="space50">
|
||||||
@foreach ($recoveryCodes as $code)
|
@foreach ($recoveryCodes as $code)
|
||||||
<div role="listitem" class="select-text" wire:loading.class="animate-pulse opacity-50">{{ $code }}</div>
|
<code role="listitem" class="settings-recovery-code" wire:loading.class="settings-recovery-code--loading">{{ $code }}</code>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</x-stack>
|
||||||
<p class="type-body-sm text-on-surface-variant">
|
</x-surface>
|
||||||
|
<p class="md-type-body-sm md-ink-variant">
|
||||||
{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }}
|
{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }}
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-stack>
|
||||||
</div>
|
</x-stack>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ new class extends Component {
|
|||||||
//
|
//
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<section class="w-full">
|
|
||||||
@include('partials.settings-heading')
|
|
||||||
|
|
||||||
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
|
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
|
||||||
<x-theme-toggle mode="picker" class="w-full max-w-sm" data-test="appearance-picker" />
|
<x-theme-toggle mode="picker" class="settings-appearance-picker" data-test="appearance-picker" />
|
||||||
</x-pages::settings.layout>
|
</x-pages::settings.layout>
|
||||||
</section>
|
|
||||||
|
|||||||
@@ -26,30 +26,23 @@ new class extends Component {
|
|||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<section class="mt-12 grid gap-4">
|
<x-card :title="__('Delete account')" :subtitle="__('Delete your account and all of its resources')" heading="h2" variant="outlined">
|
||||||
<x-divider />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 class="type-title-md">{{ __('Delete account') }}</h3>
|
|
||||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('Delete your account and all of its resources') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-button :label="__('Delete account')" danger icon="delete" wire:click="$set('showDeleteModal', true)" data-test="delete-user-button" />
|
<x-button :label="__('Delete account')" danger icon="delete" wire:click="$set('showDeleteModal', true)" data-test="delete-user-button" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-modal wire:model="showDeleteModal" :title="__('Are you sure you want to delete your account?')" icon="delete">
|
<x-modal wire:model="showDeleteModal" :title="__('Are you sure you want to delete your account?')" icon="delete">
|
||||||
|
<x-stack gap="space200">
|
||||||
<p>
|
<p>
|
||||||
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
|
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form id="delete-user-form" wire:submit="deleteUser" class="mt-4">
|
<x-form id="delete-user-form" wire:submit="deleteUser">
|
||||||
<x-password wire:model="password" :label="__('Password')" autocomplete="current-password" />
|
<x-password wire:model="password" :label="__('Password')" autocomplete="current-password" />
|
||||||
</form>
|
</x-form>
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||||||
<x-button type="submit" form="delete-user-form" :label="__('Delete account')" danger data-test="confirm-delete-user-button" />
|
<x-button type="submit" form="delete-user-form" :label="__('Delete account')" danger data-test="confirm-delete-user-button" />
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-modal>
|
</x-modal>
|
||||||
</section>
|
</x-card>
|
||||||
|
|||||||
@@ -43,18 +43,14 @@ new class extends Component {
|
|||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<section class="w-full">
|
|
||||||
@include('partials.settings-heading')
|
|
||||||
|
|
||||||
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
|
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
|
||||||
<form method="POST" wire:submit="updatePassword" class="grid gap-5">
|
<x-form method="POST" wire:submit="updatePassword">
|
||||||
<x-password wire:model="current_password" :label="__('Current password')" required autocomplete="current-password" />
|
<x-password full wire:model="current_password" :label="__('Current password')" required autocomplete="current-password" />
|
||||||
<x-password wire:model="password" :label="__('New password')" required autocomplete="new-password" />
|
<x-password full wire:model="password" :label="__('New password')" required autocomplete="new-password" />
|
||||||
<x-password wire:model="password_confirmation" :label="__('Confirm Password')" required autocomplete="new-password" />
|
<x-password full wire:model="password_confirmation" :label="__('Confirm Password')" required autocomplete="new-password" />
|
||||||
|
|
||||||
<div>
|
<x-slot:actions>
|
||||||
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updatePassword" data-test="update-password-button" />
|
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updatePassword" data-test="update-password-button" />
|
||||||
</div>
|
</x-slot:actions>
|
||||||
</form>
|
</x-form>
|
||||||
</x-pages::settings.layout>
|
</x-pages::settings.layout>
|
||||||
</section>
|
|
||||||
|
|||||||
@@ -80,21 +80,18 @@ new class extends Component {
|
|||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<section class="w-full">
|
|
||||||
@include('partials.settings-heading')
|
|
||||||
|
|
||||||
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
|
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
|
||||||
<form wire:submit="updateProfileInformation" class="grid w-full gap-5">
|
<x-form wire:submit="updateProfileInformation">
|
||||||
<x-input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
|
<x-input full wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
|
||||||
|
|
||||||
<div class="grid gap-3">
|
<x-stack gap="space100">
|
||||||
<x-input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
|
<x-input full wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
|
||||||
|
|
||||||
@if ($this->hasUnverifiedEmail)
|
@if ($this->hasUnverifiedEmail)
|
||||||
<p class="type-body-md text-on-surface-variant">
|
<p class="md-type-body-md md-ink-variant">
|
||||||
{{ __('Your email address is unverified.') }}
|
{{ __('Your email address is unverified.') }}
|
||||||
|
|
||||||
<button type="button" class="link" wire:click.prevent="resendVerificationNotification">
|
<button type="button" class="md-link" wire:click.prevent="resendVerificationNotification">
|
||||||
{{ __('Click here to re-send the verification email.') }}
|
{{ __('Click here to re-send the verification email.') }}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
@@ -103,15 +100,16 @@ new class extends Component {
|
|||||||
<x-alert color="success">{{ __('A new verification link has been sent to your email address.') }}</x-alert>
|
<x-alert color="success">{{ __('A new verification link has been sent to your email address.') }}</x-alert>
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</x-stack>
|
||||||
|
|
||||||
<div>
|
<x-slot:actions>
|
||||||
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" />
|
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" />
|
||||||
</div>
|
</x-slot:actions>
|
||||||
</form>
|
</x-form>
|
||||||
|
|
||||||
|
<x-slot:after>
|
||||||
@if ($this->showDeleteUser)
|
@if ($this->showDeleteUser)
|
||||||
<livewire:pages::settings.delete-user-form />
|
<livewire:pages::settings.delete-user-form />
|
||||||
@endif
|
@endif
|
||||||
|
</x-slot:after>
|
||||||
</x-pages::settings.layout>
|
</x-pages::settings.layout>
|
||||||
</section>
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Services\QrCodeService;
|
||||||
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
|
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
|
||||||
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||||
@@ -69,7 +70,7 @@ new class extends Component {
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
|
$this->qrCodeSvg = app(QrCodeService::class)->svg($user?->twoFactorQrCodeUrl());
|
||||||
$this->manualSetupKey = decrypt($user->two_factor_secret);
|
$this->manualSetupKey = decrypt($user->two_factor_secret);
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
$this->addError('setupData', 'Failed to fetch setup data.');
|
$this->addError('setupData', 'Failed to fetch setup data.');
|
||||||
@@ -177,45 +178,42 @@ new class extends Component {
|
|||||||
}
|
}
|
||||||
} ?>
|
} ?>
|
||||||
|
|
||||||
<section class="w-full">
|
|
||||||
@include('partials.settings-heading')
|
|
||||||
|
|
||||||
<x-pages::settings.layout
|
<x-pages::settings.layout
|
||||||
:heading="__('Two Factor Authentication')"
|
:heading="__('Two Factor Authentication')"
|
||||||
:subheading="__('Manage your two-factor authentication settings')"
|
:subheading="__('Manage your two-factor authentication settings')"
|
||||||
>
|
>
|
||||||
<div class="grid w-full gap-6" wire:cloak>
|
<x-stack gap="space300" wire:cloak>
|
||||||
@if ($twoFactorEnabled)
|
@if ($twoFactorEnabled)
|
||||||
<div class="grid justify-items-start gap-4">
|
<x-stack gap="space200" align="start">
|
||||||
<x-badge :value="__('Enabled')" tonal color="success" />
|
<x-badge :value="__('Enabled')" tonal color="success" />
|
||||||
|
|
||||||
<p class="type-body-md text-on-surface-variant">
|
<p class="md-type-body-md md-ink-variant">
|
||||||
{{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
|
{{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-button :label="__('Disable 2FA')" icon="remove_moderator" danger wire:click="disable" />
|
<x-button :label="__('Disable 2FA')" icon="remove_moderator" danger wire:click="disable" />
|
||||||
</div>
|
</x-stack>
|
||||||
@else
|
@else
|
||||||
<div class="grid justify-items-start gap-4">
|
<x-stack gap="space200" align="start">
|
||||||
<x-badge :value="__('Disabled')" tonal color="error" />
|
<x-badge :value="__('Disabled')" tonal color="error" />
|
||||||
|
|
||||||
<p class="type-body-md text-on-surface-variant">
|
<p class="md-type-body-md md-ink-variant">
|
||||||
{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
|
{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<x-button :label="__('Enable 2FA')" icon="shield_lock" variant="filled" wire:click="enable" />
|
<x-button :label="__('Enable 2FA')" icon="shield_lock" variant="filled" wire:click="enable" />
|
||||||
</div>
|
</x-stack>
|
||||||
|
@endif
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
|
{{-- The recovery codes are their own subject, so they are their own card under this one. --}}
|
||||||
|
<x-slot:after>
|
||||||
|
@if ($twoFactorEnabled)
|
||||||
|
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
|
||||||
@endif
|
@endif
|
||||||
</div>
|
|
||||||
</x-pages::settings.layout>
|
|
||||||
|
|
||||||
<x-modal wire:model="showModal" :title="$this->modalConfig['title']" :subtitle="$this->modalConfig['description']" fullscreen>
|
<x-modal wire:model="showModal" :title="$this->modalConfig['title']" :subtitle="$this->modalConfig['description']" fullscreen>
|
||||||
@if ($showVerificationStep)
|
@if ($showVerificationStep)
|
||||||
<div class="mt-2">
|
|
||||||
<x-input
|
<x-input
|
||||||
name="code"
|
name="code"
|
||||||
wire:model="code"
|
wire:model="code"
|
||||||
@@ -226,33 +224,34 @@ new class extends Component {
|
|||||||
mono
|
mono
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-button :label="__('Back')" wire:click="resetVerification" />
|
<x-button :label="__('Back')" wire:click="resetVerification" />
|
||||||
<x-button :label="__('Confirm')" variant="filled" wire:click="confirmTwoFactor" x-bind:disabled="$wire.code.length < 6" />
|
<x-button :label="__('Confirm')" variant="filled" wire:click="confirmTwoFactor" x-bind:disabled="$wire.code.length < 6" />
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
@else
|
@else
|
||||||
|
<x-stack gap="space300">
|
||||||
@error('setupData')
|
@error('setupData')
|
||||||
<x-alert color="error" class="mt-2">{{ $message }}</x-alert>
|
<x-alert color="error">{{ $message }}</x-alert>
|
||||||
@enderror
|
@enderror
|
||||||
|
|
||||||
<div class="mt-2 flex justify-center">
|
|
||||||
{{-- The QR code keeps a white ground in both themes: scanners read dark on light. --}}
|
{{-- The QR code keeps a white ground in both themes: scanners read dark on light. --}}
|
||||||
<div class="grid aspect-square w-64 place-items-center overflow-hidden rounded-corner-lg bg-white p-4">
|
<x-row justify="center">
|
||||||
|
<div class="settings-two-factor-qr">
|
||||||
@empty($qrCodeSvg)
|
@empty($qrCodeSvg)
|
||||||
<x-loading :label="__('Loading')" />
|
<x-loading :label="__('Loading')" />
|
||||||
@else
|
@else
|
||||||
{!! $qrCodeSvg !!}
|
{!! $qrCodeSvg !!}
|
||||||
@endempty
|
@endempty
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-row>
|
||||||
|
|
||||||
<div class="mt-6 grid gap-3">
|
<x-stack gap="space200">
|
||||||
<p class="text-center type-label-lg text-on-surface-variant">{{ __('or, enter the code manually') }}</p>
|
<p class="md-type-label-lg md-ink-variant md-text-center">{{ __('or, enter the code manually') }}</p>
|
||||||
|
|
||||||
<x-input :label="__('Setup key')" :value="$manualSetupKey" readonly copyable mono />
|
<x-input :label="__('Setup key')" :value="$manualSetupKey" readonly copyable mono />
|
||||||
</div>
|
</x-stack>
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-button
|
<x-button
|
||||||
@@ -264,4 +263,5 @@ new class extends Component {
|
|||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
@endif
|
@endif
|
||||||
</x-modal>
|
</x-modal>
|
||||||
</section>
|
</x-slot:after>
|
||||||
|
</x-pages::settings.layout>
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<div class="mb-6 w-full">
|
|
||||||
<h1 class="type-headline-md">{{ __('Settings') }}</h1>
|
|
||||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('Manage your profile and account settings') }}</p>
|
|
||||||
</div>
|
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
<x-button icon="admin_panel_settings" :tooltip="__('Admin settings')" :link="route('admin.settings')" :variant="$onAdminSettings ? 'filled' : 'text'" :aria-current="$onAdminSettings ? 'page' : null" />
|
<x-button icon="admin_panel_settings" :tooltip="__('Admin settings')" :link="route('admin.settings')" :variant="$onAdminSettings ? 'filled' : 'text'" :aria-current="$onAdminSettings ? 'page' : null" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<span class="ms-1 inline-flex">
|
|
||||||
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email" position="top-end">
|
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email" position="top-end">
|
||||||
<x-menu-item :label="__('Settings')" icon="settings" :link="route('profile.edit')" />
|
<x-menu-item :label="__('Settings')" icon="settings" :link="route('profile.edit')" />
|
||||||
|
|
||||||
@@ -31,7 +30,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</x-slot:footer>
|
</x-slot:footer>
|
||||||
</x-account-menu>
|
</x-account-menu>
|
||||||
</span>
|
|
||||||
@else
|
@else
|
||||||
<x-button icon="upload" :aria-label="__('Upload')" :link="route('upload')" :variant="$onUpload ? 'filled' : 'text'" :aria-current="$onUpload ? 'page' : null" />
|
<x-button icon="upload" :aria-label="__('Upload')" :link="route('upload')" :variant="$onUpload ? 'filled' : 'text'" :aria-current="$onUpload ? 'page' : null" />
|
||||||
<x-theme-toggle />
|
<x-theme-toggle />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\DownloadController;
|
use App\Http\Controllers\DownloadController;
|
||||||
|
use App\Http\Controllers\UploadChunkController;
|
||||||
use App\Livewire\Admin\AdminDashboard;
|
use App\Livewire\Admin\AdminDashboard;
|
||||||
use App\Livewire\Admin\AdminSettings;
|
use App\Livewire\Admin\AdminSettings;
|
||||||
use App\Livewire\FileUploader;
|
use App\Livewire\FileUploader;
|
||||||
@@ -20,6 +21,7 @@ Route::livewire('system-password', SystemPasswordPrompt::class)->name('system-pa
|
|||||||
|
|
||||||
Route::middleware(['system.password'])->group(function () {
|
Route::middleware(['system.password'])->group(function () {
|
||||||
Route::livewire('upload', FileUploader::class)->name('upload');
|
Route::livewire('upload', FileUploader::class)->name('upload');
|
||||||
|
Route::put('upload/files/{shareFile}/chunks/{index}', [UploadChunkController::class, 'store'])->whereNumber('index')->name('upload.chunk');
|
||||||
Route::livewire('share/{share:token}/created', ShareCreated::class)->name('share.created');
|
Route::livewire('share/{share:token}/created', ShareCreated::class)->name('share.created');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Share;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ShareService;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The walk: every page's chrome and content across M3's breakpoint edges — 599, 600, 839, 840,
|
||||||
|
* 1199, 1200, 1600px, height 900, light theme — one visit per page, reused across widths by
|
||||||
|
* resizing the same page rather than revisiting it. FrameTest.php and SettingsAndAdminTest.php
|
||||||
|
* already assert the app-main margin, the page column's width and the stat grid's column count
|
||||||
|
* at their own boundary for the pages they cover; this file re-asserts those three plus the
|
||||||
|
* section navigation's picker/tab-bar switch (untested until now), and walks every page group A/B/C
|
||||||
|
* left unchecked at a breakpoint: the rest of the auth flow, the setup wizard, the system password
|
||||||
|
* prompt, every settings page and a 404.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
config(['session.driver' => 'file']);
|
||||||
|
Storage::fake('shares');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize $page to each of M3's seven edge widths and assert what must hold at all of them: no
|
||||||
|
* horizontal overflow, exactly one <main>, no skipped heading level, and — scrolled to the bottom —
|
||||||
|
* the floating toolbar covers no interactive element and no visible text. $buttonSelector, when
|
||||||
|
* given, is also asserted narrower than the form it sits in (M3: a button's width is "dynamic to
|
||||||
|
* fit label", never stretched). $atEachWidth, when given, runs after those checks with the page and
|
||||||
|
* the current width, for a caller's own edge-specific assertions without a resize of their own.
|
||||||
|
*/
|
||||||
|
function walkBreakpoints($page, ?string $buttonSelector = null, ?callable $atEachWidth = null): void
|
||||||
|
{
|
||||||
|
foreach ([599, 600, 839, 840, 1199, 1200, 1600] as $width) {
|
||||||
|
$page->resize($width, 900);
|
||||||
|
|
||||||
|
$metrics = $page->script(<<<'JS'
|
||||||
|
(() => {
|
||||||
|
const overflowOk = document.documentElement.scrollWidth <= window.innerWidth + 1;
|
||||||
|
const mainCount = document.querySelectorAll('main').length;
|
||||||
|
|
||||||
|
const levels = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6')]
|
||||||
|
.filter((h) => h.offsetParent !== null)
|
||||||
|
.map((h) => parseInt(h.tagName.slice(1), 10));
|
||||||
|
let seen = 0;
|
||||||
|
let headingSkipped = false;
|
||||||
|
for (const level of levels) {
|
||||||
|
if (level > seen + 1) {
|
||||||
|
headingSkipped = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seen = Math.max(seen, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
|
|
||||||
|
const toolbar = document.querySelector('[data-test="app-toolbar"]');
|
||||||
|
let toolbarClear = true;
|
||||||
|
if (toolbar) {
|
||||||
|
const t = toolbar.getBoundingClientRect();
|
||||||
|
const clearOf = (r) => r.right <= t.left + 0.5 || r.left >= t.right - 0.5
|
||||||
|
|| r.bottom <= t.top + 0.5 || r.top >= t.bottom - 0.5;
|
||||||
|
|
||||||
|
const interactive = [...document.querySelectorAll('a, button, input, select, textarea, [tabindex]')];
|
||||||
|
const textLeaves = [...document.querySelectorAll('*')]
|
||||||
|
.filter((el) => el.children.length === 0 && el.textContent.trim().length > 0);
|
||||||
|
|
||||||
|
toolbarClear = [...new Set([...interactive, ...textLeaves])]
|
||||||
|
.filter((el) => !toolbar.contains(el))
|
||||||
|
.every((el) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return (r.width === 0 || r.height === 0) || clearOf(r);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { overflowOk, mainCount, headingSkipped, toolbarClear };
|
||||||
|
})()
|
||||||
|
JS);
|
||||||
|
|
||||||
|
expect($metrics['overflowOk'])->toBeTrue();
|
||||||
|
expect($metrics['mainCount'])->toBe(1);
|
||||||
|
expect($metrics['headingSkipped'])->toBeFalse();
|
||||||
|
expect($metrics['toolbarClear'])->toBeTrue();
|
||||||
|
|
||||||
|
if ($buttonSelector !== null) {
|
||||||
|
$fits = $page->script("(() => {
|
||||||
|
const button = document.querySelector('{$buttonSelector}');
|
||||||
|
if (! button) { return null; }
|
||||||
|
const form = button.closest('[data-md-form]') ?? button.parentElement;
|
||||||
|
const formRect = form.getBoundingClientRect();
|
||||||
|
const buttonRect = button.getBoundingClientRect();
|
||||||
|
return formRect.width === 0 ? null : buttonRect.width < formRect.width - 0.5;
|
||||||
|
})()");
|
||||||
|
|
||||||
|
if ($fits !== null) {
|
||||||
|
expect($fits)->toBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($atEachWidth !== null) {
|
||||||
|
$atEachWidth($page, $width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the upload page holds at every breakpoint, guest and admin', function (?string $as) {
|
||||||
|
if ($as === 'admin') {
|
||||||
|
$this->actingAs(User::factory()->admin()->create());
|
||||||
|
}
|
||||||
|
|
||||||
|
$margins = [];
|
||||||
|
|
||||||
|
walkBreakpoints(ready(visit('/upload')), null, function ($page, $width) use (&$margins) {
|
||||||
|
if (in_array($width, [599, 600], true)) {
|
||||||
|
$margins[$width] = $page->script("(() => {
|
||||||
|
const style = getComputedStyle(document.querySelector('[data-md-pane-body]'));
|
||||||
|
return { left: parseFloat(style.paddingLeft), right: parseFloat(style.paddingRight) };
|
||||||
|
})()");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// M3's margin: 16px below `medium` (600px), 24px from it (foundations.md § Layout → Breakpoints).
|
||||||
|
expect($margins[599]['left'])->toEqualWithDelta(16, 1);
|
||||||
|
expect($margins[599]['right'])->toEqualWithDelta(16, 1);
|
||||||
|
expect($margins[600]['left'])->toEqualWithDelta(24, 1);
|
||||||
|
expect($margins[600]['right'])->toEqualWithDelta(24, 1);
|
||||||
|
})->with([
|
||||||
|
'guest' => [null],
|
||||||
|
'admin' => ['admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('the share-created page holds at every breakpoint', function () {
|
||||||
|
$share = app(ShareService::class)->createShare(
|
||||||
|
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 100), 'relativePath' => null]],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
walkBreakpoints(ready(visit(route('share.created', $share, false))));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the download page holds locked and unlocked at every breakpoint', function () {
|
||||||
|
$share = app(ShareService::class)->createShare(
|
||||||
|
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 100), 'relativePath' => null]],
|
||||||
|
['password' => 'let-me-in'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$page = ready(visit(route('share.download', $share, false)));
|
||||||
|
|
||||||
|
walkBreakpoints($page, 'button[type="submit"]');
|
||||||
|
|
||||||
|
$page->resize(1280, 900);
|
||||||
|
$page->type('input[type="password"]', 'let-me-in')->press('Unlock');
|
||||||
|
$page->wait(1);
|
||||||
|
|
||||||
|
walkBreakpoints($page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the auth flow pages hold at every breakpoint', function (Closure $url, string $buttonSelector) {
|
||||||
|
walkBreakpoints(ready(visit($url())), $buttonSelector);
|
||||||
|
})->with([
|
||||||
|
'login' => [fn () => route('login', [], false), '[data-test="login-button"]'],
|
||||||
|
'forgot password' => [fn () => route('password.request', [], false), '[data-test="email-password-reset-link-button"]'],
|
||||||
|
'reset password' => [fn () => route('password.reset', ['token' => 'a-fake-token', 'email' => 'test@example.com'], false), '[data-test="reset-password-button"]'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('the two-factor challenge page holds at every breakpoint', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->withSession(['login.id' => $user->getKey()]);
|
||||||
|
|
||||||
|
walkBreakpoints(ready(visit(route('two-factor.login', [], false))), 'button[type="submit"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the email verification prompt holds at every breakpoint', function () {
|
||||||
|
$this->actingAs(User::factory()->unverified()->create());
|
||||||
|
|
||||||
|
walkBreakpoints(ready(visit(route('verification.notice', [], false))));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the password confirmation page holds at every breakpoint', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
walkBreakpoints(ready(visit(route('password.confirm', [], false))), '[data-test="confirm-password-button"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the setup wizard holds at every breakpoint', function () {
|
||||||
|
// EnsureSetupComplete only renders /setup while no admin exists; Pest.php's beforeEach creates
|
||||||
|
// one for every test, so this one removes it first.
|
||||||
|
User::query()->where('is_admin', true)->delete();
|
||||||
|
|
||||||
|
walkBreakpoints(ready(visit(route('setup', [], false))), 'button[type="submit"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the system password prompt holds at every breakpoint', function () {
|
||||||
|
Setting::set('system_password', bcrypt('secret'));
|
||||||
|
|
||||||
|
walkBreakpoints(ready(visit(route('system-password', [], false))), 'button[type="submit"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the settings pages hold at every breakpoint', function (string $url, ?string $buttonSelector) {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()]);
|
||||||
|
|
||||||
|
$navSwitch = [];
|
||||||
|
|
||||||
|
walkBreakpoints(ready(visit($url)), $buttonSelector, function ($page, $width) use (&$navSwitch) {
|
||||||
|
if (in_array($width, [599, 600], true)) {
|
||||||
|
$navSwitch[$width] = $page->script("(() => {
|
||||||
|
const picker = document.querySelector('[data-md-section-nav-picker]');
|
||||||
|
const nav = document.querySelector('[data-md-section-nav] > nav');
|
||||||
|
return {
|
||||||
|
pickerVisible: !!picker && getComputedStyle(picker).display !== 'none',
|
||||||
|
navVisible: !!nav && getComputedStyle(nav).display !== 'none',
|
||||||
|
};
|
||||||
|
})()");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// <x-section-nav>: a picker below `medium` (600px), M3's secondary tabs from it (section-nav.css).
|
||||||
|
expect($navSwitch[599]['pickerVisible'])->toBeTrue();
|
||||||
|
expect($navSwitch[599]['navVisible'])->toBeFalse();
|
||||||
|
expect($navSwitch[600]['pickerVisible'])->toBeFalse();
|
||||||
|
expect($navSwitch[600]['navVisible'])->toBeTrue();
|
||||||
|
})->with([
|
||||||
|
'profile' => ['/settings/profile', '[data-test="update-profile-button"]'],
|
||||||
|
'password' => ['/settings/password', '[data-test="update-password-button"]'],
|
||||||
|
'appearance' => ['/settings/appearance', null],
|
||||||
|
'two-factor' => ['/settings/two-factor', null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('the admin dashboard holds at every breakpoint', function () {
|
||||||
|
$this->actingAs(User::factory()->admin()->create());
|
||||||
|
Share::factory()->count(3)->create();
|
||||||
|
|
||||||
|
$columns = [];
|
||||||
|
|
||||||
|
walkBreakpoints(ready(visit('/admin/dashboard')), null, function ($page, $width) use (&$columns) {
|
||||||
|
if (in_array($width, [839, 840], true)) {
|
||||||
|
$columns[$width] = $page->script("(() => {
|
||||||
|
const rects = [...document.querySelectorAll('[data-md-stat]')].map((el) => el.getBoundingClientRect());
|
||||||
|
return new Set(rects.map((r) => Math.round(r.left))).size;
|
||||||
|
})()");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// <x-grid :columns="2">: two columns at every width, since the page's column is 40rem at all of them.
|
||||||
|
expect($columns[839])->toBe(2);
|
||||||
|
expect($columns[840])->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the admin settings page holds at every breakpoint', function () {
|
||||||
|
$this->actingAs(User::factory()->admin()->create());
|
||||||
|
|
||||||
|
walkBreakpoints(ready(visit('/admin/settings')), '[data-test="save-settings"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 404 holds at every breakpoint', function () {
|
||||||
|
// No resources/views/errors/404.blade.php exists in SealShare, but that is not Laravel's own
|
||||||
|
// minimal fallback either: NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider appends
|
||||||
|
// its own error-view root to `view.paths`, so `errors::404` resolves to the package's own
|
||||||
|
// resources/views/error-pages/errors/404.blade.php first (verified by rendering the route
|
||||||
|
// in-process: the response carries `data-md-error-page`) — a whole document with its own
|
||||||
|
// <main data-md-error-page>, one <h1 data-md-error-headline> and no toolbar (it does not include
|
||||||
|
// partials.toolbar), never Alpine or Livewire, so ready() does not apply; a network-idle wait
|
||||||
|
// stands in for it.
|
||||||
|
config(['app.debug' => false]);
|
||||||
|
|
||||||
|
$page = visit('/this-page-does-not-exist-at-all')->waitForEvent('networkidle');
|
||||||
|
|
||||||
|
walkBreakpoints($page);
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frame every page shares: the app layout's main region (resources/views/layouts/app.blade.php,
|
||||||
|
* partials/toolbar.blade.php) and the page template's centred column inside it
|
||||||
|
* (resources/views/components/page.blade.php), whichever page renders there.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
config(['session.driver' => 'file']);
|
||||||
|
Storage::fake('shares');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the sign-in page has exactly one main landmark and never scrolls sideways', function () {
|
||||||
|
foreach ([[393, 852], [1280, 800]] as [$width, $height]) {
|
||||||
|
ready(visit('/login')->resize($width, $height))
|
||||||
|
->assertScript("document.querySelectorAll('main').length === 1")
|
||||||
|
->assertScript('document.documentElement.scrollWidth <= window.innerWidth');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every page column is 40rem and centred on a wide window', function (string $url, bool $asAdmin) {
|
||||||
|
if ($asAdmin) {
|
||||||
|
$this->actingAs(User::factory()->admin()->create());
|
||||||
|
}
|
||||||
|
|
||||||
|
$metrics = ready(visit($url)->resize(1600, 900))->script("(() => {
|
||||||
|
const remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
|
const rect = document.querySelector('[data-test=\"page\"]').getBoundingClientRect();
|
||||||
|
return { width: rect.width, remPx, left: rect.left, right: window.innerWidth - rect.right };
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($metrics['width'])->toEqualWithDelta(40 * $metrics['remPx'], 0.5);
|
||||||
|
expect($metrics['left'])->toEqualWithDelta($metrics['right'], 1);
|
||||||
|
})->with([
|
||||||
|
'sign-in' => ['/login', false],
|
||||||
|
'upload' => ['/upload', false],
|
||||||
|
'admin dashboard' => ['/admin/dashboard', true],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('the page column sits 16px from each edge on a phone', function () {
|
||||||
|
$metrics = ready(visit('/login')->resize(393, 852))->script("(() => {
|
||||||
|
const rect = document.querySelector('[data-test=\"page\"]').getBoundingClientRect();
|
||||||
|
return { left: rect.left, right: window.innerWidth - rect.right };
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($metrics['left'])->toEqualWithDelta(16, 1);
|
||||||
|
expect($metrics['right'])->toEqualWithDelta(16, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the sign-in submit button is end-aligned at its own width, not stretched', function () {
|
||||||
|
$page = ready(visit('/login')->resize(393, 852));
|
||||||
|
|
||||||
|
$metrics = $page->script("(() => {
|
||||||
|
const form = document.querySelector('[data-md-form]').getBoundingClientRect();
|
||||||
|
const button = document.querySelector('[data-test=\"login-button\"]').getBoundingClientRect();
|
||||||
|
return { formWidth: form.width, formRight: form.right, buttonWidth: button.width, buttonRight: button.right };
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($metrics['buttonWidth'])->toBeLessThan($metrics['formWidth']);
|
||||||
|
expect($metrics['buttonRight'])->toEqualWithDelta($metrics['formRight'], 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the floating toolbar never covers the sign-in card once scrolled to the bottom', function () {
|
||||||
|
$page = ready(visit('/login')->resize(393, 667));
|
||||||
|
|
||||||
|
$page->script('window.scrollTo(0, document.body.scrollHeight)');
|
||||||
|
|
||||||
|
$overlap = $page->script("(() => {
|
||||||
|
const card = document.querySelector('[data-test=\"page\"] [data-md-card]').getBoundingClientRect();
|
||||||
|
const toolbar = document.querySelector('[data-test=\"app-toolbar\"]').getBoundingClientRect();
|
||||||
|
return card.bottom - toolbar.top;
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($overlap)->toBeLessThanOrEqual(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a snackbar clears SealShare's floating toolbar", function () {
|
||||||
|
$page = ready(visit('/login')->resize(393, 852));
|
||||||
|
|
||||||
|
$page->script("window.materialToast('Link copied', { timeout: 10000 })");
|
||||||
|
$page->wait(0.5);
|
||||||
|
|
||||||
|
$gap = $page->script("(() => {
|
||||||
|
const snackbar = document.querySelector('[data-md-toast-snackbar]').getBoundingClientRect();
|
||||||
|
const toolbar = document.querySelector('[data-test=\"app-toolbar\"]').getBoundingClientRect();
|
||||||
|
return toolbar.top - snackbar.bottom;
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($gap)->toBeGreaterThanOrEqual(16 - 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the app layout's content keeps M3's margin at 599px and 600px", function () {
|
||||||
|
// The main region spans the full window at every width (the page inside sets its own width),
|
||||||
|
// so the padding it declares on its body is exactly the gap between its content and the edge.
|
||||||
|
$margins = fn (int $width) => ready(visit('/upload')->resize($width, 900))->script("(() => {
|
||||||
|
const main = document.querySelector('.app-main').getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(document.querySelector('[data-md-pane-body]'));
|
||||||
|
return {
|
||||||
|
spansWindow: main.left === 0 && Math.abs(main.right - window.innerWidth) < 0.5,
|
||||||
|
left: parseFloat(style.paddingLeft),
|
||||||
|
right: parseFloat(style.paddingRight),
|
||||||
|
};
|
||||||
|
})()");
|
||||||
|
|
||||||
|
$narrow = $margins(599);
|
||||||
|
expect($narrow['spansWindow'])->toBeTrue();
|
||||||
|
expect($narrow['left'])->toEqualWithDelta(16, 1);
|
||||||
|
expect($narrow['right'])->toEqualWithDelta(16, 1);
|
||||||
|
|
||||||
|
$wide = $margins(600);
|
||||||
|
expect($wide['spansWindow'])->toBeTrue();
|
||||||
|
expect($wide['left'])->toEqualWithDelta(24, 1);
|
||||||
|
expect($wide['right'])->toEqualWithDelta(24, 1);
|
||||||
|
});
|
||||||
@@ -3,20 +3,13 @@
|
|||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Share;
|
use App\Models\Share;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\FileEncryptionService;
|
||||||
use App\Services\ShareService;
|
use App\Services\ShareService;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||||
|
|
||||||
/**
|
|
||||||
* A page of SealShare, once it can be used: loaded, with Alpine and Livewire started.
|
|
||||||
*/
|
|
||||||
function ready(mixed $page): mixed
|
|
||||||
{
|
|
||||||
return $page->waitForEvent('networkidle')
|
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
// Sessions have to outlive a request here: a sign-in, a verified share password.
|
// Sessions have to outlive a request here: a sign-in, a verified share password.
|
||||||
config(['session.driver' => 'file']);
|
config(['session.driver' => 'file']);
|
||||||
@@ -26,7 +19,7 @@ beforeEach(function () {
|
|||||||
test('files dragged over the drop zone turn its shape into a burst', function () {
|
test('files dragged over the drop zone turn its shape into a burst', function () {
|
||||||
$page = ready(visit('/upload'));
|
$page = ready(visit('/upload'));
|
||||||
|
|
||||||
$burst = "getComputedStyle(document.querySelectorAll('[data-test=drop-zone] span.absolute')[1]).opacity";
|
$burst = "getComputedStyle(document.querySelector('[data-test=drop-zone-burst]')).opacity";
|
||||||
|
|
||||||
$page->assertScript("{$burst} === '0'");
|
$page->assertScript("{$burst} === '0'");
|
||||||
|
|
||||||
@@ -36,8 +29,28 @@ test('files dragged over the drop zone turn its shape into a burst', function ()
|
|||||||
->assertNoJavaScriptErrors();
|
->assertNoJavaScriptErrors();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pest's in-process server does not store a multipart upload, so the upload itself is covered by
|
test('a chosen file is encrypted in the browser, sent in chunks and shared with its exact content', function () {
|
||||||
// FileUploadTest; this picks up where it ends, on the page the upload leads to.
|
// Pest's in-process server takes request bodies up to 128 KB: 64 KB chunks send this file in three.
|
||||||
|
config(['uploads.chunk_size' => 64 * 1024]);
|
||||||
|
$content = random_bytes(150 * 1024);
|
||||||
|
$path = sys_get_temp_dir().'/sealshare-browser-upload-'.uniqid().'.bin';
|
||||||
|
file_put_contents($path, $content);
|
||||||
|
|
||||||
|
$page = ready(visit('/upload'));
|
||||||
|
$page->attach('[data-test="file-input"]', $path)
|
||||||
|
->waitForText('Uploaded')
|
||||||
|
->click('[data-test="create-share"]')
|
||||||
|
->waitForText('Share Created!')
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
|
||||||
|
$file = Share::query()->sole()->files->sole();
|
||||||
|
expect($file->uploaded_chunks)->toBe(3);
|
||||||
|
$stored = app(FileEncryptionService::class)->decryptedChunks(app(ShareService::class)->storedFilePath($file), $file->share->encryption_key);
|
||||||
|
expect(implode('', iterator_to_array($stored, false)))->toBe($content);
|
||||||
|
|
||||||
|
unlink($path);
|
||||||
|
});
|
||||||
|
|
||||||
test('a new share\'s link can be copied from the page the upload leads to', function () {
|
test('a new share\'s link can be copied from the page the upload leads to', function () {
|
||||||
$share = app(ShareService::class)->createShare(
|
$share = app(ShareService::class)->createShare(
|
||||||
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
|
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
|
||||||
@@ -51,11 +64,48 @@ test('a new share\'s link can be copied from the page the upload leads to', func
|
|||||||
|
|
||||||
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
||||||
|
|
||||||
$page->click('[data-field-copy]')
|
$page->click('[data-md-field-copy]')
|
||||||
->assertScript("typeof window.copied === 'string' && window.copied.includes('/s/')")
|
->assertScript("typeof window.copied === 'string' && window.copied.includes('/s/')")
|
||||||
->assertSee('Copied to the clipboard');
|
->assertSee('Copied to the clipboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an uploader generates a share password and copies it from the upload page', function () {
|
||||||
|
$page = ready(visit('/upload'));
|
||||||
|
|
||||||
|
$page->click('label:has-text("Password protect")')
|
||||||
|
->click('[data-test="generate-password"]')
|
||||||
|
->assertScript("/^[A-Za-z0-9]{20}$/.test(document.querySelector('input[wire\\\\:model=\"password\"]').value)");
|
||||||
|
|
||||||
|
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
||||||
|
|
||||||
|
$page->click('[data-test="copy-password"]')
|
||||||
|
->assertScript("window.copied === document.querySelector('input[wire\\\\:model=\"password\"]').value")
|
||||||
|
->assertSee('Copied to the clipboard')
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the new share\'s password stays masked on the page and is copied without being shown', function () {
|
||||||
|
$share = app(ShareService::class)->createShare(
|
||||||
|
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
|
||||||
|
['password' => 'violet-orbit-canyon'],
|
||||||
|
);
|
||||||
|
$this->withSession(['share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')]]);
|
||||||
|
|
||||||
|
$page = ready(visit(route('share.created', $share, false)));
|
||||||
|
|
||||||
|
$field = "document.querySelector('[data-test=share-password]')";
|
||||||
|
$page->assertScript("{$field}.type === 'password'")
|
||||||
|
->assertScript("{$field}.value === 'violet-orbit-canyon'");
|
||||||
|
|
||||||
|
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
||||||
|
|
||||||
|
$page->click('[data-md-field]:has([data-test="share-password"]) [data-md-field-copy]')
|
||||||
|
->assertScript("window.copied === 'violet-orbit-canyon'")
|
||||||
|
->assertScript("{$field}.type === 'password'")
|
||||||
|
->assertSee('Copied to the clipboard')
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
test('a new share\'s QR code opens in a dialog and saves as a PNG', function () {
|
test('a new share\'s QR code opens in a dialog and saves as a PNG', function () {
|
||||||
$share = Share::factory()->withPassword()->create();
|
$share = Share::factory()->withPassword()->create();
|
||||||
|
|
||||||
@@ -63,7 +113,9 @@ test('a new share\'s QR code opens in a dialog and saves as a PNG', function ()
|
|||||||
|
|
||||||
$page->click('[data-test="show-qr-code"]')
|
$page->click('[data-test="show-qr-code"]')
|
||||||
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open")
|
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open")
|
||||||
->assertScript("getComputedStyle(document.querySelector('[data-qr-code]')).backgroundColor === 'rgb(255, 255, 255)'")
|
// The white field and quiet zone are baked into the SVG itself (App\Services\QrCodeService),
|
||||||
|
// not a background colour on its container, so a scanner keeps its contrast in dark mode too.
|
||||||
|
->assertScript("document.querySelector('[data-qr-code] svg rect').getAttribute('fill') === '#ffffff'")
|
||||||
->assertScript("document.querySelector('[data-qr-code] svg').getBoundingClientRect().width > 200")
|
->assertScript("document.querySelector('[data-qr-code] svg').getBoundingClientRect().width > 200")
|
||||||
->assertSee('Recipients also need the password.');
|
->assertSee('Recipients also need the password.');
|
||||||
|
|
||||||
@@ -120,17 +172,19 @@ test('a recipient on a phone unlocks a password-protected share and sees its fil
|
|||||||
->assertScript('document.documentElement.scrollWidth <= window.innerWidth');
|
->assertScript('document.documentElement.scrollWidth <= window.innerWidth');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an admin sorts the shares table and deletes a share through its dialog', function () {
|
test('an admin sorts the shares list and deletes a share through its dialog', function () {
|
||||||
$admin = User::factory()->admin()->create();
|
$admin = User::factory()->admin()->create();
|
||||||
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 9]);
|
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]);
|
||||||
$doomed = Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 1]);
|
$doomed = Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 9, 'created_at' => now()->subDays(2)]);
|
||||||
|
|
||||||
$this->actingAs($admin);
|
$this->actingAs($admin);
|
||||||
|
|
||||||
$page = ready(visit('/admin/dashboard'));
|
$page = ready(visit('/admin/dashboard'));
|
||||||
|
|
||||||
$page->click('th button:has-text("Downloads")')
|
$page->assertScript("document.querySelector('[data-test=\"share-row\"] code').textContent.trim() === 'aaaaaaaaaaaaaaaa'")
|
||||||
->assertScript("document.querySelector('tbody tr td').textContent.trim() === 'zzzzzzzzzzzzzzzz'");
|
->select('[data-test="shares-sort"]', 'most-downloaded')
|
||||||
|
->wait(0.5)
|
||||||
|
->assertScript("document.querySelector('[data-test=\"share-row\"] code').textContent.trim() === 'zzzzzzzzzzzzzzzz'");
|
||||||
|
|
||||||
$page->click("[data-test=\"delete-share-{$doomed->id}\"]")
|
$page->click("[data-test=\"delete-share-{$doomed->id}\"]")
|
||||||
->assertScript("[...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
->assertScript("[...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
||||||
@@ -150,7 +204,7 @@ test('a first visit follows the system theme, and Appearance switches it', funct
|
|||||||
|
|
||||||
$page = ready(visit('/settings/appearance')->inDarkMode());
|
$page = ready(visit('/settings/appearance')->inDarkMode());
|
||||||
|
|
||||||
$page->click('[data-theme-option="light"]')
|
$page->click('label:has(input[name="material-theme"][value="light"])')
|
||||||
->assertScript("document.documentElement.dataset.theme === 'light'")
|
->assertScript("document.documentElement.dataset.theme === 'light'")
|
||||||
->assertScript("localStorage.getItem('sealshare-theme') === 'light'");
|
->assertScript("localStorage.getItem('sealshare-theme') === 'light'");
|
||||||
});
|
});
|
||||||
@@ -161,7 +215,7 @@ test('an admin previews a colour profile, saves it, and every page wears it', fu
|
|||||||
$page = ready(visit('/admin/settings'));
|
$page = ready(visit('/admin/settings'));
|
||||||
|
|
||||||
$page->assertScript("document.documentElement.getAttribute('data-scheme') === 'indigo'")
|
$page->assertScript("document.documentElement.getAttribute('data-scheme') === 'indigo'")
|
||||||
->click('[data-test="color-profile"] [data-scheme-option="teal"]')
|
->click('[data-test="color-profile"] [data-md-scheme-picker-option="teal"]')
|
||||||
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
|
||||||
|
|
||||||
expect(Setting::get('color_profile'))->toBeNull();
|
expect(Setting::get('color_profile'))->toBeNull();
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Share;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group B's settings and admin pages: the geometry and behaviour the review changed without a
|
||||||
|
* browser (resources/views/pages/settings/*, two-factor/recovery-codes.blade.php,
|
||||||
|
* resources/views/livewire/admin/*). Group A's frame is tests/Browser/FrameTest.php; the share
|
||||||
|
* flow is not yet rewritten and stays out of scope here.
|
||||||
|
*
|
||||||
|
* Out of scope by a later decision (M3's guidance over 1.x's look, reworked in this batch): form
|
||||||
|
* actions' placement/width, the shares table's relation to its card, and the admin settings
|
||||||
|
* cards' grouping — now end-aligned actions, the shares table in its card and each settings
|
||||||
|
* section in a card of its own respectively (tests/Browser/ShareFlowTest.php asserts the last two).
|
||||||
|
* Nothing here asserts any of those.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
config(['session.driver' => 'file']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the profile form keeps the Email field clear of the Name label above it', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$page = ready(visit('/settings/profile')->resize(1280, 800));
|
||||||
|
// Livewire fills the fields' values a beat after first paint, and the label's float is a CSS
|
||||||
|
// transition off that: wait it out, or the label is still measured at its unfloated rest position.
|
||||||
|
$page->wait(1);
|
||||||
|
|
||||||
|
$gap = $page->script("(() => {
|
||||||
|
const fields = document.querySelectorAll('[data-md-input]');
|
||||||
|
const nameBox = fields[0].querySelector('[data-md-field-box]').getBoundingClientRect();
|
||||||
|
const emailLabel = fields[1].querySelector('[data-md-field-label]').getBoundingClientRect();
|
||||||
|
return emailLabel.top - nameBox.bottom;
|
||||||
|
})()");
|
||||||
|
|
||||||
|
// The Email field's floated label sits above its own box; it must clear the Name field's box
|
||||||
|
// above it rather than overlap it (M3's 16px form gap is exactly what makes room for this).
|
||||||
|
expect($gap)->toBeGreaterThan(-0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two-factor setup draws a scannable QR code and a visible manual key in dark theme', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()]);
|
||||||
|
|
||||||
|
$page = ready(visit('/settings/two-factor')->inDarkMode()->resize(1280, 800));
|
||||||
|
|
||||||
|
$page->click('button:has-text("Enable 2FA")');
|
||||||
|
$page->wait(1);
|
||||||
|
|
||||||
|
$metrics = $page->script("(() => {
|
||||||
|
const remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
|
const box = document.querySelector('.settings-two-factor-qr').getBoundingClientRect();
|
||||||
|
const rect = document.querySelector('.settings-two-factor-qr svg rect');
|
||||||
|
const keyInput = document.querySelector('dialog[open] input[readonly]');
|
||||||
|
const keyBox = keyInput ? keyInput.getBoundingClientRect() : null;
|
||||||
|
return {
|
||||||
|
width: box.width,
|
||||||
|
height: box.height,
|
||||||
|
rem: remPx,
|
||||||
|
fill: rect ? rect.getAttribute('fill') : null,
|
||||||
|
keyVisible: !!keyInput && !!keyBox && keyBox.width > 0 && getComputedStyle(keyInput).visibility !== 'hidden',
|
||||||
|
keyFilled: !!keyInput && keyInput.value.length > 0,
|
||||||
|
};
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($metrics['width'])->toEqualWithDelta(16 * $metrics['rem'], 1);
|
||||||
|
expect($metrics['height'])->toEqualWithDelta(16 * $metrics['rem'], 1);
|
||||||
|
expect(strtolower((string) $metrics['fill']))->toBe('#ffffff');
|
||||||
|
expect($metrics['keyVisible'])->toBeTrue();
|
||||||
|
expect($metrics['keyFilled'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the admin dashboard keeps its stats two by two and never scrolls sideways on a phone', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
Share::factory()->count(3)->create();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$columns = fn (int $width) => ready(visit('/admin/dashboard')->resize($width, 900))->script("(() => {
|
||||||
|
const rects = [...document.querySelectorAll('[data-md-stat]')].map((el) => el.getBoundingClientRect());
|
||||||
|
return {
|
||||||
|
rows: new Set(rects.map((r) => Math.round(r.top))).size,
|
||||||
|
cols: new Set(rects.map((r) => Math.round(r.left))).size,
|
||||||
|
};
|
||||||
|
})()");
|
||||||
|
|
||||||
|
$narrow = $columns(839);
|
||||||
|
expect($narrow['rows'])->toBe(2);
|
||||||
|
expect($narrow['cols'])->toBe(2);
|
||||||
|
|
||||||
|
$wide = $columns(1600);
|
||||||
|
expect($wide['rows'])->toBe(2);
|
||||||
|
expect($wide['cols'])->toBe(2);
|
||||||
|
|
||||||
|
// The shares are a list that wraps within the page's column, so nothing scrolls sideways.
|
||||||
|
$phone = ready(visit('/admin/dashboard')->resize(393, 852));
|
||||||
|
|
||||||
|
$overflow = $phone->script("(() => ({
|
||||||
|
rows: document.querySelectorAll('[data-test=\"share-row\"]').length,
|
||||||
|
pageScrollWidth: document.documentElement.scrollWidth,
|
||||||
|
windowWidth: window.innerWidth,
|
||||||
|
}))()");
|
||||||
|
|
||||||
|
expect($overflow['rows'])->toBe(3);
|
||||||
|
expect($overflow['pageScrollWidth'])->toBeLessThanOrEqual($overflow['windowWidth']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting the account opens its dialog onto a reachable password field, and Escape closes it', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$page = ready(visit('/settings/profile')->resize(1280, 800));
|
||||||
|
|
||||||
|
$page->click('[data-test=delete-user-button]');
|
||||||
|
$page->wait(0.5);
|
||||||
|
|
||||||
|
$state = $page->script("(() => {
|
||||||
|
const dialog = document.querySelector('dialog[open]');
|
||||||
|
const input = dialog ? dialog.querySelector('input[type=password]') : null;
|
||||||
|
return {
|
||||||
|
open: !!dialog,
|
||||||
|
reachable: !!input && (document.activeElement === input || (input.tabIndex !== -1 && !input.disabled)),
|
||||||
|
};
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($state['open'])->toBeTrue();
|
||||||
|
expect($state['reachable'])->toBeTrue();
|
||||||
|
|
||||||
|
$page->keys('dialog[open] input[type=password]', 'Escape');
|
||||||
|
$page->wait(0.5);
|
||||||
|
|
||||||
|
$page->assertScript("document.querySelector('dialog[open]') === null");
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Share;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ShareService;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The share flow (upload, share created, share download) and today's rework of it, plus the
|
||||||
|
* settings/admin buttons and sections reworked alongside it — all changed since the last browser
|
||||||
|
* run (148ff38) without one. tests/Browser/SealShareTest.php, FrameTest.php and
|
||||||
|
* SettingsAndAdminTest.php already cover the flow's existing behaviour; this file adds what that
|
||||||
|
* rework introduced and does not re-assert what those already do (the copy-to-clipboard toast, the
|
||||||
|
* dragover burst, the QR code's fill and PNG export).
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
config(['session.driver' => 'file']);
|
||||||
|
Storage::fake('shares');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tab reaches Browse Files with its focus ring, and Enter or Space opens the file picker', function () {
|
||||||
|
$page = ready(visit('/upload'));
|
||||||
|
|
||||||
|
// A spy, not a real dialog: Playwright would otherwise have to field a native file chooser.
|
||||||
|
$page->script("window.eval(\"window.__fileInputClicks = 0; HTMLInputElement.prototype.click = function () { if (this.type === 'file') { window.__fileInputClicks++ } }\")");
|
||||||
|
|
||||||
|
// "body" alone is not a CSS-explicit selector to this plugin's guesser (no special chars) and
|
||||||
|
// falls back to a text search, which never matches and times out; "html > body" is explicit and
|
||||||
|
// focuses nothing new (the body takes no tabindex), so the Tab lands where a fresh page load
|
||||||
|
// would send it: the first tabbable element.
|
||||||
|
$page->keys('html > body', 'Tab');
|
||||||
|
|
||||||
|
$page->assertScript("document.activeElement.matches('[data-md-button]') && document.activeElement.textContent.trim() === 'Browse Files'");
|
||||||
|
|
||||||
|
$ring = $page->script('getComputedStyle(document.activeElement).outlineStyle');
|
||||||
|
expect($ring)->toBe('solid');
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Enter');
|
||||||
|
$page->assertScript('window.__fileInputClicks === 1');
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Space');
|
||||||
|
$page->assertScript('window.__fileInputClicks === 2')
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the selected files list avoids horizontal overflow once files are chosen', function () {
|
||||||
|
$path = sys_get_temp_dir().'/a-genuinely-quite-long-holiday-photos-archive-from-portugal-'.uniqid().'.zip';
|
||||||
|
file_put_contents($path, 'archive');
|
||||||
|
|
||||||
|
$page = ready(visit('/upload')->resize(393, 852));
|
||||||
|
$page->attach('[data-test="file-input"]', $path)
|
||||||
|
->waitForText('Uploaded');
|
||||||
|
|
||||||
|
$rows = $page->script("(() => {
|
||||||
|
const rows = [...document.querySelectorAll('[data-test=selected-file]')];
|
||||||
|
return { count: rows.length, allFit: rows.every((row) => row.getBoundingClientRect().right <= window.innerWidth + 0.5) };
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($rows['count'])->toBe(1);
|
||||||
|
expect($rows['allFit'])->toBeTrue();
|
||||||
|
$page->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
|
||||||
|
unlink($path);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without a secure context the upload page says HTTPS is needed and takes no files', function () {
|
||||||
|
$page = ready(visit('/upload'));
|
||||||
|
|
||||||
|
$page->script("window.eval(\"Alpine.\$data(document.querySelector('[data-test=drop-zone]')).secure = false\")");
|
||||||
|
|
||||||
|
$page->assertScript("getComputedStyle(document.querySelector('[data-test=insecure-context]')).display !== 'none'")
|
||||||
|
->assertSee('Uploads need a secure connection (HTTPS).')
|
||||||
|
->assertScript("document.querySelector('[data-test=file-input]').disabled === true")
|
||||||
|
->assertScript("document.querySelector('[data-test=drop-zone]').getAttribute('aria-disabled') === 'true'")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the drop zone hides its burst again once the drag leaves', function () {
|
||||||
|
$page = ready(visit('/upload'));
|
||||||
|
|
||||||
|
$burst = "getComputedStyle(document.querySelector('[data-test=drop-zone-burst]')).opacity";
|
||||||
|
|
||||||
|
$page->assertScript("{$burst} === '0'");
|
||||||
|
|
||||||
|
$page->script("window.eval(\"document.querySelector('[data-test=drop-zone]').dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true }))\")");
|
||||||
|
$page->assertScript("{$burst} === '1'");
|
||||||
|
|
||||||
|
$page->script("window.eval(\"document.querySelector('[data-test=drop-zone]').dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true }))\")");
|
||||||
|
$page->assertScript("{$burst} === '0'")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the QR dialog holds its code inside the box, and Escape returns focus to the button that opened it', function () {
|
||||||
|
$share = Share::factory()->create();
|
||||||
|
|
||||||
|
$page = ready(visit(route('share.created', $share, false)));
|
||||||
|
|
||||||
|
$page->click('[data-test="show-qr-code"]');
|
||||||
|
$page->wait(1);
|
||||||
|
|
||||||
|
$page->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
|
||||||
|
|
||||||
|
$inside = $page->script("(() => {
|
||||||
|
const dialog = document.querySelector('[data-test=\"qr-code-dialog\"]').getBoundingClientRect();
|
||||||
|
const qr = document.querySelector('[data-qr-code] svg').getBoundingClientRect();
|
||||||
|
return qr.left >= dialog.left - 1 && qr.right <= dialog.right + 1
|
||||||
|
&& qr.top >= dialog.top - 1 && qr.bottom <= dialog.bottom + 1;
|
||||||
|
})()");
|
||||||
|
expect($inside)->toBeTrue();
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Escape');
|
||||||
|
$page->wait(1);
|
||||||
|
|
||||||
|
$page->assertScript("! document.querySelector('[data-test=\"qr-code-dialog\"]').open")
|
||||||
|
->assertScript("document.activeElement === document.querySelector('[data-test=\"show-qr-code\"]')")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the download page fits a phone before and after unlocking a password-protected share', function () {
|
||||||
|
$share = app(ShareService::class)->createShare(
|
||||||
|
[['file' => UploadedFile::fake()->create('a-genuinely-quite-long-holiday-photos-archive-from-portugal.zip', 120), 'relativePath' => null]],
|
||||||
|
['password' => 'let-me-in'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$page = ready(visit(route('share.download', $share, false))->resize(393, 852));
|
||||||
|
|
||||||
|
$metrics = $page->script("(() => {
|
||||||
|
const button = document.querySelector('button[type=submit]');
|
||||||
|
const form = button.closest('[data-md-form]').getBoundingClientRect();
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
return { formWidth: form.width, formRight: form.right, buttonWidth: rect.width, buttonRight: rect.right };
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($metrics['buttonWidth'])->toBeLessThan($metrics['formWidth']);
|
||||||
|
expect($metrics['buttonRight'])->toEqualWithDelta($metrics['formRight'], 1);
|
||||||
|
$page->assertScript('document.documentElement.scrollWidth <= window.innerWidth');
|
||||||
|
|
||||||
|
$page->type('input[type="password"]', 'let-me-in')->press('Unlock');
|
||||||
|
$page->wait(1);
|
||||||
|
|
||||||
|
$page->assertScript("document.querySelector('h2').tagName === 'H2' && document.querySelector('h2').textContent.trim() === 'Shared Files'");
|
||||||
|
|
||||||
|
$rows = $page->script("(() => {
|
||||||
|
const rows = [...document.querySelectorAll('[data-md-list-item]')];
|
||||||
|
return {
|
||||||
|
count: rows.length,
|
||||||
|
allFit: rows.every((row) => row.getBoundingClientRect().right <= window.innerWidth + 0.5),
|
||||||
|
};
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($rows['count'])->toBeGreaterThan(0);
|
||||||
|
expect($rows['allFit'])->toBeTrue();
|
||||||
|
$page->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the settings Save button is end-aligned at less than the form\'s width', function (string $url, string $button) {
|
||||||
|
$this->actingAs($url === '/admin/settings' ? User::factory()->admin()->create() : User::factory()->create());
|
||||||
|
|
||||||
|
$page = ready(visit($url)->resize(1280, 800));
|
||||||
|
|
||||||
|
$metrics = $page->script("(() => {
|
||||||
|
const button = document.querySelector('{$button}');
|
||||||
|
const form = button.closest('[data-md-form]').getBoundingClientRect();
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
return { formWidth: form.width, formRight: form.right, buttonWidth: rect.width, buttonRight: rect.right };
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($metrics['buttonWidth'])->toBeLessThan($metrics['formWidth']);
|
||||||
|
expect($metrics['buttonRight'])->toEqualWithDelta($metrics['formRight'], 1);
|
||||||
|
})->with([
|
||||||
|
['/settings/profile', '[data-test="update-profile-button"]'],
|
||||||
|
['/admin/settings', '[data-test="save-settings"]'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('the admin dashboard heads its shares list with an h2, both in one card', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
Share::factory()->count(2)->create();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$page = ready(visit('/admin/dashboard'));
|
||||||
|
|
||||||
|
$result = $page->script("(() => {
|
||||||
|
const heading = [...document.querySelectorAll('h2')].find((h) => h.textContent.trim() === 'All Shares');
|
||||||
|
const list = document.querySelector('[data-test=\"share-row\"]');
|
||||||
|
const card = heading?.closest('[data-md-card]');
|
||||||
|
return {
|
||||||
|
headingIsCardTitle: heading?.matches('[data-md-card-title]') ?? false,
|
||||||
|
listInSameCard: !!card && card.contains(list),
|
||||||
|
headingBeforeList: !!heading && !!list
|
||||||
|
&& !!(heading.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||||
|
};
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($result['headingIsCardTitle'])->toBeTrue();
|
||||||
|
expect($result['listInSameCard'])->toBeTrue();
|
||||||
|
expect($result['headingBeforeList'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the admin settings page has six sections, each a card headed by an h2', function () {
|
||||||
|
$this->actingAs(User::factory()->admin()->create());
|
||||||
|
|
||||||
|
$page = ready(visit('/admin/settings'));
|
||||||
|
|
||||||
|
$result = $page->script("(() => {
|
||||||
|
// Every dialog's own title is an h2 too (components/modal.blade.php), whether open or
|
||||||
|
// not: exclude those to count only the page's own section headings.
|
||||||
|
const headings = [...document.querySelectorAll('h2:not([data-md-modal-title])')];
|
||||||
|
const cards = [...document.querySelectorAll('[data-md-card]')];
|
||||||
|
return {
|
||||||
|
headings: headings.map((h) => h.textContent.trim()),
|
||||||
|
cardCount: cards.length,
|
||||||
|
everyCardHeaded: cards.every((card) => card.querySelector('h2[data-md-card-title]') !== null),
|
||||||
|
};
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($result['headings'])->toBe(['Colour profile', 'Branding', 'Upload Protection', 'Share Passwords', 'Upload Limits', 'Storage']);
|
||||||
|
expect($result['cardCount'])->toBe(6);
|
||||||
|
expect($result['everyCardHeaded'])->toBeTrue();
|
||||||
|
});
|
||||||
@@ -61,7 +61,7 @@ test('admin can delete share', function () {
|
|||||||
expect(Share::query()->find($shareId))->toBeNull();
|
expect(Share::query()->find($shareId))->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin dashboard shows shares table', function () {
|
test('admin dashboard lists the shares', function () {
|
||||||
$admin = User::query()->where('is_admin', true)->first();
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
$share = Share::factory()->create(['token' => 'testtoken12345678']);
|
$share = Share::factory()->create(['token' => 'testtoken12345678']);
|
||||||
@@ -70,29 +70,62 @@ test('admin dashboard shows shares table', function () {
|
|||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
$response->assertSee('testtoken12345678');
|
$response->assertSee('testtoken12345678');
|
||||||
|
$response->assertSee('href="'.route('share.download', $share).'"', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the shares table sorts only by its own columns', function () {
|
test('the shares list sorts only by its own orders', function () {
|
||||||
$admin = User::query()->where('is_admin', true)->first();
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 9]);
|
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]);
|
||||||
Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 1]);
|
Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 9, 'created_at' => now()->subDays(2)]);
|
||||||
|
|
||||||
Livewire::actingAs($admin)
|
Livewire::actingAs($admin)
|
||||||
->test(AdminDashboard::class)
|
->test(AdminDashboard::class)
|
||||||
->set('sortBy', ['column' => 'download_count', 'direction' => 'asc'])
|
->assertSeeInOrder(['aaaaaaaaaaaaaaaa', 'zzzzzzzzzzzzzzzz'])
|
||||||
|
->set('sort', 'most-downloaded')
|
||||||
->assertSeeInOrder(['zzzzzzzzzzzzzzzz', 'aaaaaaaaaaaaaaaa'])
|
->assertSeeInOrder(['zzzzzzzzzzzzzzzz', 'aaaaaaaaaaaaaaaa'])
|
||||||
->set('sortBy', ['column' => 'token; drop table shares', 'direction' => 'sideways'])
|
->set('sort', 'token; drop table shares')
|
||||||
->assertOk();
|
->assertOk()
|
||||||
|
->assertSeeInOrder(['aaaaaaaaaaaaaaaa', 'zzzzzzzzzzzzzzzz']);
|
||||||
|
|
||||||
expect(Share::query()->count())->toBe(2);
|
expect(Share::query()->count())->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('without shares the dashboard shows an empty state instead of the table', function () {
|
test('sorting by expiry puts shares that never expire last', function () {
|
||||||
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
|
Share::factory()->create(['token' => 'neverexpires0000', 'expires_at' => null]);
|
||||||
|
Share::factory()->create(['token' => 'expireslater0000', 'expires_at' => now()->addWeek()]);
|
||||||
|
Share::factory()->create(['token' => 'expiressoon00000', 'expires_at' => now()->addHour()]);
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(AdminDashboard::class)
|
||||||
|
->set('sort', 'expiring')
|
||||||
|
->assertSeeInOrder(['expiressoon00000', 'expireslater0000', 'neverexpires0000']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without shares the dashboard shows an empty state instead of the list', function () {
|
||||||
$admin = User::query()->where('is_admin', true)->first();
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
$this->actingAs($admin)->get(route('admin.dashboard'))
|
$this->actingAs($admin)->get(route('admin.dashboard'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('No shares yet')
|
->assertSee('No shares yet')
|
||||||
->assertDontSee('<table', false);
|
->assertDontSee('data-test="share-row"', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shares whose files are still uploading are neither listed nor counted, but their bytes count as used space', function () {
|
||||||
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]);
|
||||||
|
ShareFile::factory()->for($completed)->create();
|
||||||
|
$pending = Share::factory()->pending()->create(['token' => 'pendingshare0001', 'total_size' => 500]);
|
||||||
|
ShareFile::factory()->for($pending)->uploading()->create();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(AdminDashboard::class)
|
||||||
|
->assertSee('completedshare01')
|
||||||
|
->assertDontSee('pendingshare0001')
|
||||||
|
->assertViewHas('totalShares', 1)
|
||||||
|
->assertViewHas('activeShares', 1)
|
||||||
|
->assertViewHas('totalFiles', 1)
|
||||||
|
->assertViewHas('usedSpace', 1500);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,11 +33,9 @@ test('admin can access settings page', function () {
|
|||||||
test('admin can save settings', function () {
|
test('admin can save settings', function () {
|
||||||
$admin = User::query()->where('is_admin', true)->first();
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
|
||||||
|
|
||||||
Livewire::actingAs($admin)
|
Livewire::actingAs($admin)
|
||||||
->test(AdminSettings::class)
|
->test(AdminSettings::class)
|
||||||
->set('maxFileSize', min(200, $phpMaxMb))
|
->set('maxFileSize', 200)
|
||||||
->set('maxStorageQuota', 50)
|
->set('maxStorageQuota', 50)
|
||||||
->set('maxFilesPerShare', 100)
|
->set('maxFilesPerShare', 100)
|
||||||
->set('maxSizePerShare', 5)
|
->set('maxSizePerShare', 5)
|
||||||
@@ -46,7 +44,7 @@ test('admin can save settings', function () {
|
|||||||
->assertHasNoErrors()
|
->assertHasNoErrors()
|
||||||
->assertDispatched('toast', type: 'success', title: 'Settings saved successfully.');
|
->assertDispatched('toast', type: 'success', title: 'Settings saved successfully.');
|
||||||
|
|
||||||
expect(Setting::get('max_file_size'))->toBe((string) (min(200, $phpMaxMb) * 1024 * 1024));
|
expect(Setting::get('max_file_size'))->toBe((string) (200 * 1024 * 1024));
|
||||||
expect(Setting::get('max_storage_quota'))->toBe((string) (50 * 1024 * 1024 * 1024));
|
expect(Setting::get('max_storage_quota'))->toBe((string) (50 * 1024 * 1024 * 1024));
|
||||||
expect(Setting::get('max_files_per_share'))->toBe('100');
|
expect(Setting::get('max_files_per_share'))->toBe('100');
|
||||||
expect(Setting::get('max_size_per_share'))->toBe((string) (5 * 1024 * 1024 * 1024));
|
expect(Setting::get('max_size_per_share'))->toBe((string) (5 * 1024 * 1024 * 1024));
|
||||||
@@ -55,11 +53,9 @@ test('admin can save settings', function () {
|
|||||||
|
|
||||||
test('admin can set system password', function () {
|
test('admin can set system password', function () {
|
||||||
$admin = User::query()->where('is_admin', true)->first();
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
|
||||||
|
|
||||||
Livewire::actingAs($admin)
|
Livewire::actingAs($admin)
|
||||||
->test(AdminSettings::class)
|
->test(AdminSettings::class)
|
||||||
->set('maxFileSize', $phpMaxMb)
|
->set('maxFileSize', 100)
|
||||||
->set('systemPassword', 'new-system-password')
|
->set('systemPassword', 'new-system-password')
|
||||||
->call('saveSettings')
|
->call('saveSettings')
|
||||||
->assertHasNoErrors();
|
->assertHasNoErrors();
|
||||||
@@ -87,8 +83,7 @@ test('admin can clear system password', function () {
|
|||||||
|
|
||||||
test('settings page loads existing values', function () {
|
test('settings page loads existing values', function () {
|
||||||
$admin = User::query()->where('is_admin', true)->first();
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
$testSize = 40;
|
||||||
$testSize = min(40, $phpMaxMb);
|
|
||||||
|
|
||||||
Setting::set('max_file_size', $testSize * 1024 * 1024);
|
Setting::set('max_file_size', $testSize * 1024 * 1024);
|
||||||
Setting::set('max_files_per_share', 75);
|
Setting::set('max_files_per_share', 75);
|
||||||
@@ -157,3 +152,120 @@ test('a colour profile that was not generated is refused', function () {
|
|||||||
|
|
||||||
expect(Setting::get('color_profile'))->toBeNull();
|
expect(Setting::get('color_profile'))->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('admin saves a character password generator, starting from the saved settings', function () {
|
||||||
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(AdminSettings::class)
|
||||||
|
->assertSet('passwordGeneratorMode', 'button')
|
||||||
|
->set('passwordGeneratorMode', 'prefill')
|
||||||
|
->set('passwordGeneratorType', 'characters')
|
||||||
|
->set('passwordLength', 24)
|
||||||
|
->set('passwordCharacterSets', ['numbers', 'symbols'])
|
||||||
|
->set('passwordAvoidAmbiguous', false)
|
||||||
|
->call('saveSettings')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect(Setting::get('password_generator_mode'))->toBe('prefill');
|
||||||
|
expect(Setting::get('password_generator_type'))->toBe('characters');
|
||||||
|
expect(Setting::get('password_generator_length'))->toBe('24');
|
||||||
|
expect(Setting::get('password_generator_character_sets'))->toBe('numbers,symbols');
|
||||||
|
expect(Setting::get('password_generator_avoid_ambiguous'))->toBe('0');
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(AdminSettings::class)
|
||||||
|
->assertSet('passwordGeneratorMode', 'prefill')
|
||||||
|
->assertSet('passwordLength', 24)
|
||||||
|
->assertSet('passwordCharacterSets', ['numbers', 'symbols'])
|
||||||
|
->assertSet('passwordAvoidAmbiguous', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a passphrase generator saves its words and separator and ignores the hidden character fields', function () {
|
||||||
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(AdminSettings::class)
|
||||||
|
->set('passwordGeneratorType', 'passphrase')
|
||||||
|
->set('passphraseWords', 8)
|
||||||
|
->set('passphraseSeparator', 'space')
|
||||||
|
->set('passwordLength', 3)
|
||||||
|
->set('passwordCharacterSets', [])
|
||||||
|
->call('saveSettings')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect(Setting::get('password_generator_type'))->toBe('passphrase');
|
||||||
|
expect(Setting::get('password_generator_words'))->toBe('8');
|
||||||
|
expect(Setting::get('password_generator_separator'))->toBe('space');
|
||||||
|
expect(Setting::get('password_generator_length'))->toBeNull();
|
||||||
|
expect(Setting::get('password_generator_character_sets'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a password generator without any kind of character is refused', function () {
|
||||||
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(AdminSettings::class)
|
||||||
|
->set('passwordCharacterSets', [])
|
||||||
|
->call('saveSettings')
|
||||||
|
->assertHasErrors(['passwordCharacterSets' => 'Choose at least one kind of character.']);
|
||||||
|
|
||||||
|
expect(Setting::get('password_generator_character_sets'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password generator settings out of range are refused', function (string $property, mixed $value, string $rule) {
|
||||||
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(AdminSettings::class)
|
||||||
|
->set('passwordGeneratorType', $property === 'passphraseWords' ? 'passphrase' : 'characters')
|
||||||
|
->set($property, $value)
|
||||||
|
->call('saveSettings')
|
||||||
|
->assertHasErrors([$property => $rule]);
|
||||||
|
|
||||||
|
expect(Setting::get('password_generator_mode'))->toBeNull();
|
||||||
|
})->with([
|
||||||
|
'an unknown mode' => ['passwordGeneratorMode', 'sometimes', 'in'],
|
||||||
|
'a length below 12' => ['passwordLength', 8, 'min'],
|
||||||
|
'a length above 64' => ['passwordLength', 65, 'max'],
|
||||||
|
'fewer than 4 words' => ['passphraseWords', 3, 'min'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('the password example follows the unsaved form and disappears while the form is invalid', function () {
|
||||||
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(AdminSettings::class)
|
||||||
|
->set('passwordGeneratorType', 'passphrase')
|
||||||
|
->set('passphraseWords', 5)
|
||||||
|
->set('passphraseSeparator', 'dot')
|
||||||
|
->assertViewHas('passwordExample', fn (string $example): bool => count(explode('.', $example)) === 5)
|
||||||
|
->assertViewHas('passwordEntropy', 64)
|
||||||
|
->set('passphraseWords', 2)
|
||||||
|
->assertViewHas('passwordExample', null)
|
||||||
|
->assertDontSeeHtml('data-test="password-example"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching the password generator off hides its options', function () {
|
||||||
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(AdminSettings::class)
|
||||||
|
->assertSeeHtml('wire:model.live="passwordGeneratorType"')
|
||||||
|
->set('passwordGeneratorMode', 'off')
|
||||||
|
->assertDontSeeHtml('wire:model.live="passwordGeneratorType"')
|
||||||
|
->assertDontSeeHtml('data-test="password-example"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a max file size far above PHP\'s upload limit loads and saves unchanged', function () {
|
||||||
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
Setting::set('max_file_size', 15000 * 1024 * 1024);
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(AdminSettings::class)
|
||||||
|
->assertSet('maxFileSize', 15000)
|
||||||
|
->call('saveSettings')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect(Setting::get('max_file_size'))->toBe((string) (15000 * 1024 * 1024));
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ function toolbarLink(string $html, string $url): string
|
|||||||
test('pages have a floating toolbar at the bottom instead of a top app bar', function () {
|
test('pages have a floating toolbar at the bottom instead of a top app bar', function () {
|
||||||
$html = $this->get(route('upload'))->assertOk()->getContent();
|
$html = $this->get(route('upload'))->assertOk()->getContent();
|
||||||
|
|
||||||
expect($html)->not->toContain('data-app-bar')
|
expect($html)->not->toContain('data-md-app-bar')
|
||||||
->and(toolbar($html))->toContain('role="toolbar"')->toContain('data-toolbar-place="bottom"');
|
->and(toolbar($html))->toContain('role="toolbar"')->toContain('data-md-toolbar-place="bottom"');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a guest gets the upload page, the theme toggle and a way to log in, without tooltips', function () {
|
test('a guest gets the upload page, the theme toggle and a way to log in, without tooltips', function () {
|
||||||
@@ -34,7 +34,7 @@ test('a guest gets the upload page, the theme toggle and a way to log in, withou
|
|||||||
|
|
||||||
expect(toolbarLink($html, route('upload')))->toContain('aria-current="page"')->toContain('aria-label="Upload"')
|
expect(toolbarLink($html, route('upload')))->toContain('aria-current="page"')->toContain('aria-label="Upload"')
|
||||||
->and(toolbarLink($html, route('login')))->not->toBe('')
|
->and(toolbarLink($html, route('login')))->not->toBe('')
|
||||||
->and(toolbar($html))->toContain('Log in')->toContain('data-theme-toggle')->not->toContain('popover')->not->toContain('data-account-menu');
|
->and(toolbar($html))->toContain('Log in')->toContain('data-md-theme-toggle')->not->toContain('popover')->not->toContain('data-md-account-menu');
|
||||||
|
|
||||||
expect(toolbar($this->get(route('login'))->getContent()))->not->toContain(route('login').'"');
|
expect(toolbar($this->get(route('login'))->getContent()))->not->toContain(route('login').'"');
|
||||||
});
|
});
|
||||||
@@ -47,7 +47,7 @@ test('an admin gets the admin pages and the account menu, the current page marke
|
|||||||
expect(toolbarLink($html, route('admin.dashboard')))->toContain('aria-current="page"')
|
expect(toolbarLink($html, route('admin.dashboard')))->toContain('aria-current="page"')
|
||||||
->and(toolbarLink($html, route('upload')))->not->toContain('aria-current')
|
->and(toolbarLink($html, route('upload')))->not->toContain('aria-current')
|
||||||
->and(toolbarLink($html, route('admin.settings')))->not->toContain('aria-current')
|
->and(toolbarLink($html, route('admin.settings')))->not->toContain('aria-current')
|
||||||
->and(toolbar($html))->toContain('data-account-menu')->toContain('data-test="logout-button"')->not->toContain('Log in');
|
->and(toolbar($html))->toContain('data-md-account-menu')->toContain('data-test="logout-button"')->not->toContain('Log in');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a user who is not an admin gets no admin pages', function () {
|
test('a user who is not an admin gets no admin pages', function () {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Models\Share;
|
use App\Models\Share;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||||
|
|
||||||
test('cleanup removes expired shares', function () {
|
test('cleanup removes expired shares', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
@@ -58,3 +59,35 @@ test('cleanup removes both expired and download-limited shares', function () {
|
|||||||
expect(Share::query()->find($limitReached->id))->toBeNull();
|
expect(Share::query()->find($limitReached->id))->toBeNull();
|
||||||
expect(Share::query()->find($active->id))->not->toBeNull();
|
expect(Share::query()->find($active->id))->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cleanup removes uploads no chunk reached for 4 hours, and keeps recent ones and completed shares', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$this->freezeTime();
|
||||||
|
$abandoned = Share::factory()->pending()->create(['updated_at' => now()->subHours(4)->subMinute()]);
|
||||||
|
Storage::disk('shares')->put($abandoned->token.'/file.enc', 'encrypted');
|
||||||
|
$recent = Share::factory()->pending()->create(['updated_at' => now()->subHours(3)]);
|
||||||
|
$completed = Share::factory()->create(['updated_at' => now()->subDays(3)]);
|
||||||
|
|
||||||
|
$this->artisan('shares:cleanup')
|
||||||
|
->expectsOutputToContain('Cleaned up 1 abandoned upload(s)')
|
||||||
|
->assertExitCode(0);
|
||||||
|
|
||||||
|
$this->assertModelMissing($abandoned);
|
||||||
|
$this->assertModelExists($recent);
|
||||||
|
$this->assertModelExists($completed);
|
||||||
|
expect(Storage::disk('shares')->directories())->not->toContain($abandoned->token);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleanup removes temporary upload files older than 4 hours and keeps newer ones', function () {
|
||||||
|
$storage = FileUploadConfiguration::storage();
|
||||||
|
$storage->put(FileUploadConfiguration::path('old.pdf'), 'unencrypted leftover');
|
||||||
|
$storage->put(FileUploadConfiguration::path('new.png'), 'a logo being chosen');
|
||||||
|
touch($storage->path(FileUploadConfiguration::path('old.pdf')), now()->subHours(5)->getTimestamp());
|
||||||
|
|
||||||
|
$this->artisan('shares:cleanup')
|
||||||
|
->expectsOutputToContain('Cleaned up 1 temporary upload file(s)')
|
||||||
|
->assertExitCode(0);
|
||||||
|
|
||||||
|
expect($storage->exists(FileUploadConfiguration::path('old.pdf')))->toBeFalse();
|
||||||
|
expect($storage->exists(FileUploadConfiguration::path('new.png')))->toBeTrue();
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||||
|
|
||||||
test('views and code use only what the design system compiles', function () {
|
test('views, stylesheets and code use only what the design system provides', function () {
|
||||||
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])->violations())->toBe([]);
|
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
|
||||||
|
->missingStylesheets(resource_path('css/app.css'))
|
||||||
|
->unusedStylesheets(resource_path('css/app.css'))
|
||||||
|
->violations())->toBe([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('nothing of maryUI or daisyUI is left behind', function () {
|
test('nothing of maryUI or daisyUI is left behind', function () {
|
||||||
|
|||||||
@@ -4,11 +4,41 @@ use App\Livewire\FileUploader;
|
|||||||
use App\Livewire\SystemPasswordPrompt;
|
use App\Livewire\SystemPasswordPrompt;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Share;
|
use App\Models\Share;
|
||||||
use Illuminate\Http\UploadedFile;
|
use App\Models\ShareFile;
|
||||||
use Illuminate\Support\Facades\Log;
|
use App\Services\ShareService;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload files through the page as the browser does: register them in one batch, then store each
|
||||||
|
* one's encrypted content (the chunk endpoint itself is covered by UploadChunkTest).
|
||||||
|
*
|
||||||
|
* @param array<string, string> $files name => content
|
||||||
|
* @return array<int, array<string, mixed>|null> what the page handed the browser
|
||||||
|
*/
|
||||||
|
function uploadThroughPage(Testable $component, array $files): array
|
||||||
|
{
|
||||||
|
$targets = [];
|
||||||
|
|
||||||
|
$component->call('registerFiles', collect($files)->map(fn (string $content, string $name): array => ['name' => $name, 'size' => strlen($content), 'path' => null])->values()->all())
|
||||||
|
->assertReturned(function (array $returned) use (&$targets): bool {
|
||||||
|
$targets = $returned;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (array_values($files) as $position => $content) {
|
||||||
|
if ($targets[$position] !== null) {
|
||||||
|
$file = ShareFile::query()->findOrFail($targets[$position]['id']);
|
||||||
|
app(ShareService::class)->storeChunk($file, 0, encryptedChunk($file, $content, 0, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $targets;
|
||||||
|
}
|
||||||
|
|
||||||
test('upload page can be rendered', function () {
|
test('upload page can be rendered', function () {
|
||||||
$response = $this->get(route('upload'));
|
$response = $this->get(route('upload'));
|
||||||
|
|
||||||
@@ -34,28 +64,100 @@ test('upload page accessible after system password verified', function () {
|
|||||||
|
|
||||||
test('file upload creates share', function () {
|
test('file upload creates share', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
$component = Livewire::test(FileUploader::class);
|
||||||
|
uploadThroughPage($component, ['document.pdf' => 'the document']);
|
||||||
|
|
||||||
$file = UploadedFile::fake()->create('document.pdf', 1024);
|
$component->call('createShare')
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
|
||||||
->set('files', [$file])
|
|
||||||
->call('createShare')
|
|
||||||
->assertRedirectContains('/share/');
|
->assertRedirectContains('/share/');
|
||||||
|
|
||||||
expect(Share::query()->count())->toBe(1);
|
$share = Share::query()->sole();
|
||||||
|
expect($share->isCompleted())->toBeTrue();
|
||||||
|
expect($share->files->pluck('original_name')->all())->toBe(['document.pdf']);
|
||||||
|
expect(session('pending_shares'))->not->toContain($share->token);
|
||||||
|
});
|
||||||
|
|
||||||
$share = Share::query()->first();
|
test('registering files hands the browser each file\'s chunk URL, the share key and the file\'s nonce prefix', function () {
|
||||||
expect($share->files)->toHaveCount(1);
|
Storage::fake('shares');
|
||||||
expect($share->files->first()->original_name)->toBe('document.pdf');
|
config(['uploads.chunk_size' => 4]);
|
||||||
|
$component = Livewire::test(FileUploader::class);
|
||||||
|
|
||||||
|
$targets = uploadThroughPage($component, ['a.txt' => 'abc']);
|
||||||
|
|
||||||
|
$file = ShareFile::query()->sole();
|
||||||
|
expect($targets[0])->toMatchArray([
|
||||||
|
'id' => $file->id,
|
||||||
|
'url' => url('upload/files/'.$file->id.'/chunks'),
|
||||||
|
'key' => $file->share->encryption_key,
|
||||||
|
'chunkSize' => 4,
|
||||||
|
'chunkCount' => 1,
|
||||||
|
]);
|
||||||
|
expect($targets[0]['noncePrefix'])->toBe(bin2hex(substr(file_get_contents(app(ShareService::class)->storedFilePath($file)), 12, 7)));
|
||||||
|
expect(session('pending_shares'))->toBe([$file->share->token]);
|
||||||
|
$component->assertSet('pendingToken', $file->share->token)
|
||||||
|
->assertSeeHtml('data-test="selected-file"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each upload page gets its own pending share', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$first = Livewire::test(FileUploader::class);
|
||||||
|
$second = Livewire::test(FileUploader::class);
|
||||||
|
|
||||||
|
uploadThroughPage($first, ['one.txt' => 'one']);
|
||||||
|
uploadThroughPage($second, ['two.txt' => 'two']);
|
||||||
|
|
||||||
|
expect($first->get('pendingToken'))->not->toBe($second->get('pendingToken'));
|
||||||
|
expect(Share::query()->pluck('total_size')->all())->toBe([3, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file an admin limit refuses gets no target and shows why, while the rest of the batch is registered', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
Setting::set('max_file_size', 1024 * 1024);
|
||||||
|
$component = Livewire::test(FileUploader::class);
|
||||||
|
|
||||||
|
$component->call('registerFiles', [
|
||||||
|
['name' => 'small.txt', 'size' => 3, 'path' => null],
|
||||||
|
['name' => 'large.txt', 'size' => 2 * 1024 * 1024, 'path' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component->assertReturned(fn (array $targets): bool => $targets[0] !== null && $targets[1] === null);
|
||||||
|
expect($component->errors()->first('files'))->toBe('"large.txt" is too large (2 MB). Maximum file size is 1 MB.');
|
||||||
|
expect(ShareFile::query()->pluck('original_name')->all())->toBe(['small.txt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing files takes them out of the pending share', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$component = Livewire::test(FileUploader::class);
|
||||||
|
$targets = uploadThroughPage($component, ['keep.txt' => 'keep', 'remove.txt' => 'remove']);
|
||||||
|
|
||||||
|
$component->call('removeFiles', [$targets[1]['id']]);
|
||||||
|
|
||||||
|
expect(ShareFile::query()->pluck('original_name')->all())->toBe(['keep.txt']);
|
||||||
|
expect(Share::query()->sole()->total_size)->toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a share cannot be created while a file is still uploading', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$component = Livewire::test(FileUploader::class)
|
||||||
|
->call('registerFiles', [['name' => 'unfinished.txt', 'size' => 10, 'path' => null]]);
|
||||||
|
|
||||||
|
$component->call('createShare');
|
||||||
|
|
||||||
|
expect($component->errors()->first('files'))->toBe('Wait until every file has finished uploading, or remove the ones that failed.');
|
||||||
|
expect(Share::query()->sole()->isCompleted())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the page offers a warning for browsers without a secure context', function () {
|
||||||
|
Livewire::test(FileUploader::class)
|
||||||
|
->assertSeeHtml('data-test="insecure-context"')
|
||||||
|
->assertSee('Uploads need a secure connection (HTTPS).');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('file upload with password creates password-protected share', function () {
|
test('file upload with password creates password-protected share', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
$component = Livewire::test(FileUploader::class);
|
||||||
|
uploadThroughPage($component, ['secret.txt' => 'secret']);
|
||||||
|
|
||||||
$file = UploadedFile::fake()->create('secret.txt', 512);
|
$component
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
|
||||||
->set('files', [$file])
|
|
||||||
->set('usePassword', true)
|
->set('usePassword', true)
|
||||||
->set('password', 'my-password')
|
->set('password', 'my-password')
|
||||||
->call('createShare')
|
->call('createShare')
|
||||||
@@ -65,13 +167,80 @@ test('file upload with password creates password-protected share', function () {
|
|||||||
expect($share->isPasswordProtected())->toBeTrue();
|
expect($share->isPasswordProtected())->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('switching password protection on leaves the field empty and offers a Generate button by default', function () {
|
||||||
|
$component = Livewire::test(FileUploader::class)
|
||||||
|
->set('usePassword', true);
|
||||||
|
|
||||||
|
$component->assertSet('password', '')
|
||||||
|
->assertSeeHtml('data-test="generate-password"')
|
||||||
|
->assertSeeHtml('data-test="copy-password"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a generated password protects the share and is flashed, encrypted, for the page the upload leads to', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
|
||||||
|
$component = Livewire::test(FileUploader::class);
|
||||||
|
uploadThroughPage($component, ['secret.txt' => 'secret']);
|
||||||
|
$component->set('usePassword', true)
|
||||||
|
->call('generatePassword');
|
||||||
|
$password = $component->get('password');
|
||||||
|
$component->call('createShare');
|
||||||
|
|
||||||
|
$share = Share::query()->first();
|
||||||
|
expect($password)->toMatch('/^[A-Za-z0-9]{20}$/');
|
||||||
|
expect(app(ShareService::class)->verifyPassword($share, $password))->toBeTrue();
|
||||||
|
expect(session('share_password.token'))->toBe($share->token);
|
||||||
|
expect(Crypt::decryptString(session('share_password.password')))->toBe($password);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a share without a password flashes no password', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
|
||||||
|
$component = Livewire::test(FileUploader::class);
|
||||||
|
uploadThroughPage($component, ['document.pdf' => 'the document']);
|
||||||
|
|
||||||
|
$component->call('createShare')
|
||||||
|
->assertRedirectContains('/share/');
|
||||||
|
|
||||||
|
expect(session()->has('share_password'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a prefilling generator fills in a password as protection is switched on', function () {
|
||||||
|
Setting::set('password_generator_mode', 'prefill');
|
||||||
|
|
||||||
|
$component = Livewire::test(FileUploader::class)
|
||||||
|
->set('usePassword', true);
|
||||||
|
|
||||||
|
expect($component->get('password'))->toMatch('/^[A-Za-z0-9]{20}$/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a prefilling generator keeps a password the uploader already typed', function () {
|
||||||
|
Setting::set('password_generator_mode', 'prefill');
|
||||||
|
|
||||||
|
Livewire::test(FileUploader::class)
|
||||||
|
->set('password', 'my-own-password')
|
||||||
|
->set('usePassword', true)
|
||||||
|
->assertSet('password', 'my-own-password');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a generator switched off offers no Generate button and generates nothing', function () {
|
||||||
|
Setting::set('password_generator_mode', 'off');
|
||||||
|
|
||||||
|
Livewire::test(FileUploader::class)
|
||||||
|
->set('usePassword', true)
|
||||||
|
->assertDontSeeHtml('data-test="generate-password"')
|
||||||
|
->assertSeeHtml('data-test="copy-password"')
|
||||||
|
->call('generatePassword')
|
||||||
|
->assertSet('password', '');
|
||||||
|
});
|
||||||
|
|
||||||
test('file upload with expiration sets expires_at', function () {
|
test('file upload with expiration sets expires_at', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
|
||||||
$file = UploadedFile::fake()->create('file.txt', 256);
|
$component = Livewire::test(FileUploader::class);
|
||||||
|
uploadThroughPage($component, ['file.txt' => 'content']);
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
$component
|
||||||
->set('files', [$file])
|
|
||||||
->set('expiration', '24h')
|
->set('expiration', '24h')
|
||||||
->call('createShare')
|
->call('createShare')
|
||||||
->assertRedirectContains('/share/');
|
->assertRedirectContains('/share/');
|
||||||
@@ -83,10 +252,10 @@ test('file upload with expiration sets expires_at', function () {
|
|||||||
test('file upload with max downloads sets limit', function () {
|
test('file upload with max downloads sets limit', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
|
||||||
$file = UploadedFile::fake()->create('file.txt', 256);
|
$component = Livewire::test(FileUploader::class);
|
||||||
|
uploadThroughPage($component, ['file.txt' => 'content']);
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
$component
|
||||||
->set('files', [$file])
|
|
||||||
->set('maxDownloads', 5)
|
->set('maxDownloads', 5)
|
||||||
->call('createShare')
|
->call('createShare')
|
||||||
->assertRedirectContains('/share/');
|
->assertRedirectContains('/share/');
|
||||||
@@ -95,69 +264,26 @@ test('file upload with max downloads sets limit', function () {
|
|||||||
expect($share->max_downloads)->toBe(5);
|
expect($share->max_downloads)->toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every upload batch dispatches files-processed to clear the uploading state', function () {
|
test('a file of 6 GB is accepted when the admin limits allow it', function () {
|
||||||
Storage::fake('shares');
|
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
|
||||||
->set('files', [UploadedFile::fake()->create('first.txt', 64)])
|
|
||||||
->assertDispatched('files-processed')
|
|
||||||
->set('files', [UploadedFile::fake()->create('second.txt', 64)])
|
|
||||||
->assertDispatched('files-processed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('files added in multiple batches end up in the same share', function () {
|
|
||||||
Storage::fake('shares');
|
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
|
||||||
->set('files', [UploadedFile::fake()->create('first.txt', 64)])
|
|
||||||
->set('files', [UploadedFile::fake()->create('second.txt', 64)])
|
|
||||||
->call('createShare')
|
|
||||||
->assertHasNoErrors()
|
|
||||||
->assertRedirectContains('/share/');
|
|
||||||
|
|
||||||
$share = Share::query()->first();
|
|
||||||
|
|
||||||
expect($share->files->pluck('original_name')->all())->toBe(['first.txt', 'second.txt']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('files larger than 4 GB can be shared when within the admin file size limit', function () {
|
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
Setting::set('max_file_size', 15000 * 1024 * 1024);
|
Setting::set('max_file_size', 15000 * 1024 * 1024);
|
||||||
Setting::set('max_size_per_share', 20 * 1024 * 1024 * 1024);
|
Setting::set('max_size_per_share', 20 * 1024 * 1024 * 1024);
|
||||||
|
Setting::set('max_storage_quota', 50 * 1024 * 1024 * 1024);
|
||||||
Livewire::test(FileUploader::class)
|
|
||||||
->set('files', [UploadedFile::fake()->create('backup.dump', 6 * 1024 * 1024)])
|
|
||||||
->assertHasNoErrors('files')
|
|
||||||
->call('createShare')
|
|
||||||
->assertHasNoErrors()
|
|
||||||
->assertRedirectContains('/share/');
|
|
||||||
|
|
||||||
expect(Share::query()->first()->total_size)->toBe(6 * 1024 * 1024 * 1024);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a rejected upload logs the real reason instead of blaming the file size limit', function () {
|
|
||||||
Log::spy();
|
|
||||||
Setting::set('max_file_size', 15000 * 1024 * 1024);
|
|
||||||
|
|
||||||
$errors = ['files.0' => ['The files.0 failed to upload.']];
|
|
||||||
|
|
||||||
$component = Livewire::test(FileUploader::class)
|
$component = Livewire::test(FileUploader::class)
|
||||||
->call('_uploadErrored', 'files', json_encode(['errors' => $errors]), true)
|
->call('registerFiles', [['name' => 'backup.dump', 'size' => 6 * 1024 * 1024 * 1024, 'path' => null]]);
|
||||||
->assertDispatched('upload:errored');
|
|
||||||
|
|
||||||
expect($component->errors()->first('files'))
|
$component->assertHasNoErrors('files');
|
||||||
->toBe('Upload failed: the server could not accept the file. Please try again or contact the administrator.');
|
expect(Share::query()->sole()->total_size)->toBe(6 * 1024 * 1024 * 1024);
|
||||||
|
expect(ShareFile::query()->sole()->file_size)->toBe(6 * 1024 * 1024 * 1024);
|
||||||
Log::shouldHaveReceived('warning')
|
|
||||||
->withArgs(fn (string $message, array $context): bool => $context['errors'] === $errors)
|
|
||||||
->once();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('file upload requires at least one file', function () {
|
test('file upload requires at least one file', function () {
|
||||||
Livewire::test(FileUploader::class)
|
$component = Livewire::test(FileUploader::class)
|
||||||
->set('files', [])
|
->call('createShare');
|
||||||
->call('createShare')
|
|
||||||
->assertHasErrors(['files']);
|
expect($component->errors()->first('files'))->toBe('Please select at least one file to upload.');
|
||||||
|
expect(Share::query()->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('file upload blocks when storage is full', function () {
|
test('file upload blocks when storage is full', function () {
|
||||||
@@ -165,12 +291,11 @@ test('file upload blocks when storage is full', function () {
|
|||||||
Setting::set('max_storage_quota', 100);
|
Setting::set('max_storage_quota', 100);
|
||||||
Share::factory()->create(['total_size' => 100]);
|
Share::factory()->create(['total_size' => 100]);
|
||||||
|
|
||||||
$file = UploadedFile::fake()->create('file.txt', 1);
|
$component = Livewire::test(FileUploader::class)
|
||||||
|
->call('registerFiles', [['name' => 'file.txt', 'size' => 1, 'path' => null]]);
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
expect($component->errors()->first('files'))->toBe('Storage is full. Please contact the administrator.');
|
||||||
->set('files', [$file])
|
expect(ShareFile::query()->count())->toBe(0);
|
||||||
->call('createShare')
|
|
||||||
->assertHasErrors(['files']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('system password prompt verifies correct password', function () {
|
test('system password prompt verifies correct password', function () {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Share;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Testing\TestResponse;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opening tag of the page template's pane (resources/views/components/page.blade.php).
|
||||||
|
*/
|
||||||
|
function pageTag(string $html): string
|
||||||
|
{
|
||||||
|
preg_match('/<div\s[^>]*data-test="page"[^>]*>/', $html, $matches);
|
||||||
|
|
||||||
|
return $matches[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every page is one page template, one h1 and the same width', function (Closure $visit) {
|
||||||
|
$html = $visit($this)->assertOk()->getContent();
|
||||||
|
|
||||||
|
expect(substr_count($html, 'data-test="page"'))->toBe(1)
|
||||||
|
->and(preg_match_all('/<h1[\s>]/', $html))->toBe(1)
|
||||||
|
->and(pageTag($html))->toContain('data-md-width="narrow"');
|
||||||
|
})->with([
|
||||||
|
'upload' => [fn (TestCase $test): TestResponse => $test->get(route('upload'))],
|
||||||
|
'download' => [fn (TestCase $test): TestResponse => $test->get(route('share.download', Share::factory()->withPassword()->create()))],
|
||||||
|
'share created' => [fn (TestCase $test): TestResponse => $test->get(route('share.created', Share::factory()->create()))],
|
||||||
|
'login' => [fn (TestCase $test): TestResponse => $test->get(route('login'))],
|
||||||
|
'forgot password' => [fn (TestCase $test): TestResponse => $test->get(route('password.request'))],
|
||||||
|
'reset password' => [fn (TestCase $test): TestResponse => $test->get(route('password.reset', 'token'))],
|
||||||
|
'two-factor challenge' => [fn (TestCase $test): TestResponse => $test->withSession(['login.id' => User::factory()->withTwoFactor()->create()->id])->get(route('two-factor.login'))],
|
||||||
|
'setup' => [function (TestCase $test): TestResponse {
|
||||||
|
User::query()->where('is_admin', true)->delete();
|
||||||
|
|
||||||
|
return $test->get(route('setup'));
|
||||||
|
}],
|
||||||
|
'system password' => [function (TestCase $test): TestResponse {
|
||||||
|
Setting::set('system_password', bcrypt('system-secret'));
|
||||||
|
|
||||||
|
return $test->get(route('system-password'));
|
||||||
|
}],
|
||||||
|
'verify email' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->unverified()->create())->get(route('verification.notice'))],
|
||||||
|
'confirm password' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('password.confirm'))],
|
||||||
|
'profile' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('profile.edit'))],
|
||||||
|
'password' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('user-password.edit'))],
|
||||||
|
'two-factor settings' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->withSession(['auth.password_confirmed_at' => time()])->get(route('two-factor.show'))],
|
||||||
|
'appearance' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('appearance.edit'))],
|
||||||
|
'admin settings' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->admin()->create())->get(route('admin.settings'))],
|
||||||
|
'admin dashboard' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->admin()->create())->get(route('admin.dashboard'))],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test("a brand page is headed by the site's own logo, title and description", function () {
|
||||||
|
Setting::set('site_title', 'Acme Files');
|
||||||
|
Setting::set('site_description', 'Send files to Acme Engineering.');
|
||||||
|
Setting::set('site_logo', 'branding/acme.png');
|
||||||
|
|
||||||
|
$html = $this->get(route('login'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSeeInOrder(['data-test="page-logo"', '<h1', 'Send files to Acme Engineering.'], false)
|
||||||
|
->assertSee(Storage::disk('public')->url('branding/acme.png'), false)
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
expect($html)->toMatch('/<h1[^>]*>\s*Acme Files\s*<\/h1>/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a brand page falls back to the app name and SealShare\'s own line without branding', function () {
|
||||||
|
$html = $this->get(route('upload'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSeeInOrder(['<h1', 'Share your files safely and securely'], false)
|
||||||
|
->assertDontSee('data-test="page-logo"', false)
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
expect($html)->toMatch('/<h1[^>]*>\s*'.preg_quote(config('app.name'), '/').'\s*<\/h1>/');
|
||||||
|
});
|
||||||
@@ -4,8 +4,8 @@ use App\Livewire\Admin\AdminSettings;
|
|||||||
use App\Livewire\FileUploader;
|
use App\Livewire\FileUploader;
|
||||||
use App\Livewire\ShareDownload;
|
use App\Livewire\ShareDownload;
|
||||||
use App\Models\Share;
|
use App\Models\Share;
|
||||||
|
use App\Models\ShareFile;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\FileEncryptionService;
|
|
||||||
use App\Services\ShareService;
|
use App\Services\ShareService;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
@@ -94,23 +94,17 @@ test('rate limiter clears after successful password verification', function () {
|
|||||||
test('share password must be at least 8 characters', function () {
|
test('share password must be at least 8 characters', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
|
||||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
Livewire::test(FileUploader::class)
|
||||||
->set('files', [$file])
|
|
||||||
->set('usePassword', true)
|
->set('usePassword', true)
|
||||||
->set('password', 'short')
|
->set('password', 'short')
|
||||||
->call('createShare')
|
->call('createShare')
|
||||||
->assertHasErrors(['password']);
|
->assertHasErrors(['password' => 'min']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('share password of 8 characters is accepted', function () {
|
test('share password of 8 characters is accepted', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
|
||||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
Livewire::test(FileUploader::class)
|
||||||
->set('files', [$file])
|
|
||||||
->set('usePassword', true)
|
->set('usePassword', true)
|
||||||
->set('password', 'longenough')
|
->set('password', 'longenough')
|
||||||
->call('createShare')
|
->call('createShare')
|
||||||
@@ -151,42 +145,17 @@ test('setup wizard createAdmin is blocked when admin already exists', function (
|
|||||||
test('content disposition handles special characters in filename', function () {
|
test('content disposition handles special characters in filename', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
|
||||||
$service = app(ShareService::class);
|
$share = app(ShareService::class)->createShare([
|
||||||
$encryptionService = app(FileEncryptionService::class);
|
['file' => UploadedFile::fake()->createWithContent('normal.txt', 'test content'), 'relativePath' => null],
|
||||||
|
|
||||||
$file = UploadedFile::fake()->create('normal.txt', 100);
|
|
||||||
|
|
||||||
$share = $service->createShare([
|
|
||||||
['file' => $file, 'relativePath' => null],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$share->load('files');
|
|
||||||
$shareFile = $share->files->first();
|
$shareFile = $share->files->first();
|
||||||
|
$shareFile->update(['original_name' => 'file"with"quotes.txt']);
|
||||||
|
|
||||||
$shareFile->original_name = 'file"with"quotes.txt';
|
$response = $this->get(route('share.download.file', [$share, $shareFile]));
|
||||||
$shareFile->save();
|
|
||||||
|
|
||||||
$encryptedDir = Storage::disk('shares')->path($share->token);
|
expect($response->headers->get('Content-Disposition'))
|
||||||
if (! is_dir($encryptedDir)) {
|
->toContain('attachment')
|
||||||
mkdir($encryptedDir, 0755, true);
|
->not->toContain('file"with"quotes.txt');
|
||||||
}
|
|
||||||
$encryptedPath = $encryptedDir.'/'.basename($shareFile->stored_path);
|
|
||||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
|
||||||
file_put_contents($tempSource, 'test content');
|
|
||||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $share->encryption_key);
|
|
||||||
unlink($tempSource);
|
|
||||||
|
|
||||||
$response = $encryptionService->decryptFileStream(
|
|
||||||
$encryptedPath,
|
|
||||||
$share->encryption_key,
|
|
||||||
'file"with"quotes.txt',
|
|
||||||
'text/plain',
|
|
||||||
12,
|
|
||||||
);
|
|
||||||
|
|
||||||
$contentDisposition = $response->headers->get('Content-Disposition');
|
|
||||||
expect($contentDisposition)->not->toContain('file"with"quotes.txt');
|
|
||||||
expect($contentDisposition)->toContain('attachment');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- SVG upload rejected ---
|
// --- SVG upload rejected ---
|
||||||
@@ -194,11 +163,9 @@ test('content disposition handles special characters in filename', function () {
|
|||||||
test('svg upload is rejected for site logo', function () {
|
test('svg upload is rejected for site logo', function () {
|
||||||
$admin = User::query()->where('is_admin', true)->first();
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
|
||||||
|
|
||||||
Livewire::actingAs($admin)
|
Livewire::actingAs($admin)
|
||||||
->test(AdminSettings::class)
|
->test(AdminSettings::class)
|
||||||
->set('maxFileSize', $phpMaxMb)
|
->set('maxFileSize', 100)
|
||||||
->set('siteLogo', UploadedFile::fake()->create('logo.svg', 100, 'image/svg+xml'))
|
->set('siteLogo', UploadedFile::fake()->create('logo.svg', 100, 'image/svg+xml'))
|
||||||
->call('saveSettings')
|
->call('saveSettings')
|
||||||
->assertHasErrors(['siteLogo']);
|
->assertHasErrors(['siteLogo']);
|
||||||
@@ -206,52 +173,26 @@ test('svg upload is rejected for site logo', function () {
|
|||||||
|
|
||||||
// --- Relative path validation (Zip Slip prevention) ---
|
// --- Relative path validation (Zip Slip prevention) ---
|
||||||
|
|
||||||
test('relative paths with directory traversal are sanitized', function () {
|
test('relative paths from a dropped folder that could reach outside the share are dropped', function (string $relativePath) {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
|
||||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
Livewire::test(FileUploader::class)
|
||||||
->set('files', [$file])
|
->call('registerFiles', [['name' => 'file.txt', 'size' => 100, 'path' => $relativePath]]);
|
||||||
->set('relativePaths', ['../../etc/passwd'])
|
|
||||||
->call('createShare')
|
|
||||||
->assertRedirectContains('/share/');
|
|
||||||
|
|
||||||
$share = Share::query()->first();
|
expect(ShareFile::query()->sole()->relative_path)->toBeNull();
|
||||||
$shareFile = $share->files->first();
|
})->with([
|
||||||
expect($shareFile->relative_path)->toBeNull();
|
'directory traversal' => '../../etc/passwd',
|
||||||
});
|
'absolute path' => '/etc/passwd',
|
||||||
|
'windows traversal' => '..\\..\\windows\\system.ini',
|
||||||
test('relative paths with absolute paths are sanitized', function () {
|
]);
|
||||||
Storage::fake('shares');
|
|
||||||
|
|
||||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
|
||||||
->set('files', [$file])
|
|
||||||
->set('relativePaths', ['/etc/passwd'])
|
|
||||||
->call('createShare')
|
|
||||||
->assertRedirectContains('/share/');
|
|
||||||
|
|
||||||
$share = Share::query()->first();
|
|
||||||
$shareFile = $share->files->first();
|
|
||||||
expect($shareFile->relative_path)->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('valid relative paths are preserved', function () {
|
test('valid relative paths are preserved', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
|
||||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
|
||||||
|
|
||||||
Livewire::test(FileUploader::class)
|
Livewire::test(FileUploader::class)
|
||||||
->set('files', [$file])
|
->call('registerFiles', [['name' => 'file.txt', 'size' => 100, 'path' => 'folder/subfolder/file.txt']]);
|
||||||
->set('relativePaths', ['folder/subfolder/file.txt'])
|
|
||||||
->call('createShare')
|
|
||||||
->assertRedirectContains('/share/');
|
|
||||||
|
|
||||||
$share = Share::query()->first();
|
expect(ShareFile::query()->sole()->relative_path)->toBe('folder/subfolder/file.txt');
|
||||||
$shareFile = $share->files->first();
|
|
||||||
expect($shareFile->relative_path)->toBe('folder/subfolder/file.txt');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Security headers ---
|
// --- Security headers ---
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
use App\Models\Share;
|
use App\Models\Share;
|
||||||
use App\Services\QrCodeService;
|
use App\Services\QrCodeService;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
|
||||||
test('the share created page offers the link as a QR code and through the share sheet', function () {
|
test('the share created page offers the link as a QR code and through the share sheet', function () {
|
||||||
$share = Share::factory()->create();
|
$share = Share::factory()->create();
|
||||||
$url = route('share.download', $share);
|
$url = route('share.download', $share);
|
||||||
|
$svg = app(QrCodeService::class)->svg($url);
|
||||||
|
|
||||||
$response = $this->get(route('share.created', $share));
|
$response = $this->get(route('share.created', $share));
|
||||||
|
|
||||||
@@ -14,8 +16,12 @@ test('the share created page offers the link as a QR code and through the share
|
|||||||
->assertSee('data-test="show-qr-code"', false)
|
->assertSee('data-test="show-qr-code"', false)
|
||||||
->assertSee('data-test="share-sheet"', false)
|
->assertSee('data-test="share-sheet"', false)
|
||||||
->assertSee('share-'.$share->token.'.png')
|
->assertSee('share-'.$share->token.'.png')
|
||||||
->assertSee('<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">'.app(QrCodeService::class)->svg($url).'</div>', false)
|
|
||||||
->assertDontSee('Recipients also need the password.');
|
->assertDontSee('Recipients also need the password.');
|
||||||
|
|
||||||
|
// Structure, not the 1.x class string: the `data-qr-code` hook directly wraps the service's own
|
||||||
|
// SVG, which draws its own white field and quiet zone — no colour class or literal colour here.
|
||||||
|
expect($response->getContent())
|
||||||
|
->toMatch('#<div[^>]*\bdata-qr-code\b[^>]*>'.preg_quote($svg, '#').'</div>#');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the QR code dialog reminds that a protected share also needs its password', function () {
|
test('the QR code dialog reminds that a protected share also needs its password', function () {
|
||||||
@@ -25,3 +31,32 @@ test('the QR code dialog reminds that a protected share also needs its password'
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Recipients also need the password.');
|
->assertSee('Recipients also need the password.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the uploader who just set the password can copy it beside the link, without it being on screen', function () {
|
||||||
|
$share = Share::factory()->withPassword()->create();
|
||||||
|
|
||||||
|
$response = $this->withSession(['share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')]])
|
||||||
|
->get(route('share.created', $share));
|
||||||
|
|
||||||
|
$response->assertSee('data-test="share-password"', false)
|
||||||
|
->assertSee('Available only this once. Send it separately from the link.');
|
||||||
|
|
||||||
|
// A masked field holding the password, with the field's copy button at its end.
|
||||||
|
expect($response->getContent())
|
||||||
|
->toMatch('#<input(?=[^>]*value="violet-orbit-canyon")(?=[^>]*type="password")(?=[^>]*data-test="share-password")[^>]*>#')
|
||||||
|
->toMatch('#data-test="share-password".*?data-md-field-copy#s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the password is not shown without a flash for this share', function (?string $flashedFor) {
|
||||||
|
$share = Share::factory()->withPassword()->create();
|
||||||
|
$session = $flashedFor === null ? [] : ['share_password' => ['token' => $flashedFor, 'password' => Crypt::encryptString('violet-orbit-canyon')]];
|
||||||
|
|
||||||
|
$response = $this->withSession($session)->get(route('share.created', $share));
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertDontSee('data-test="share-password"', false)
|
||||||
|
->assertDontSee('violet-orbit-canyon');
|
||||||
|
})->with([
|
||||||
|
'no flash (a reload or another visitor)' => [null],
|
||||||
|
'a flash for another share' => ['another-share-token'],
|
||||||
|
]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Livewire\ShareDownload;
|
use App\Livewire\ShareDownload;
|
||||||
use App\Models\Share;
|
use App\Models\Share;
|
||||||
|
use App\Models\ShareFile;
|
||||||
use App\Services\FileEncryptionService;
|
use App\Services\FileEncryptionService;
|
||||||
use App\Services\ShareService;
|
use App\Services\ShareService;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
@@ -81,102 +82,79 @@ test('non-password share shows files directly', function () {
|
|||||||
|
|
||||||
test('download counter increments on zip download', function () {
|
test('download counter increments on zip download', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
|
||||||
$share = createShareWithFile();
|
$share = createShareWithFile();
|
||||||
$share->load('files');
|
|
||||||
|
|
||||||
$encryptionService = app(FileEncryptionService::class);
|
$response = $this->get(route('share.download.all', $share));
|
||||||
$key = $share->encryption_key;
|
$response->streamedContent();
|
||||||
$content = 'test content';
|
|
||||||
|
|
||||||
foreach ($share->files as $file) {
|
|
||||||
$dir = Storage::disk('shares')->path($share->token);
|
|
||||||
if (! is_dir($dir)) {
|
|
||||||
mkdir($dir, 0755, true);
|
|
||||||
}
|
|
||||||
$encryptedPath = $dir.'/'.basename($file->stored_path);
|
|
||||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
|
||||||
file_put_contents($tempSource, $content);
|
|
||||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
|
|
||||||
unlink($tempSource);
|
|
||||||
$file->update(['file_size' => strlen($content)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $this->withSession(['share_password_'.$share->token => null])
|
|
||||||
->get(route('share.download.all', $share));
|
|
||||||
|
|
||||||
$response->assertDownload();
|
|
||||||
|
|
||||||
|
$response->assertDownload('share-'.$share->token.'.zip');
|
||||||
expect($share->fresh()->download_count)->toBe(1);
|
expect($share->fresh()->download_count)->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('zip download produces a valid archive', function () {
|
test('zip download streams a valid archive with every file\'s original content', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
config(['uploads.chunk_size' => 1000]);
|
||||||
|
$binary = random_bytes(2500);
|
||||||
|
$share = app(ShareService::class)->createShare([
|
||||||
|
['file' => UploadedFile::fake()->createWithContent('notes.txt', 'hello zip content'), 'relativePath' => null],
|
||||||
|
['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => 'holiday/photo.bin'],
|
||||||
|
]);
|
||||||
|
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
|
||||||
|
|
||||||
$share = createShareWithFile();
|
file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent());
|
||||||
$share->load('files');
|
|
||||||
|
|
||||||
$encryptionService = app(FileEncryptionService::class);
|
|
||||||
$key = $share->encryption_key;
|
|
||||||
$content = 'hello zip content';
|
|
||||||
|
|
||||||
foreach ($share->files as $file) {
|
|
||||||
$dir = Storage::disk('shares')->path($share->token);
|
|
||||||
if (! is_dir($dir)) {
|
|
||||||
mkdir($dir, 0755, true);
|
|
||||||
}
|
|
||||||
$encryptedPath = $dir.'/'.basename($file->stored_path);
|
|
||||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
|
||||||
file_put_contents($tempSource, $content);
|
|
||||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
|
|
||||||
unlink($tempSource);
|
|
||||||
$file->update(['file_size' => strlen($content)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $this->get(route('share.download.all', $share));
|
|
||||||
$response->assertDownload();
|
|
||||||
|
|
||||||
$zipPath = $response->getFile()->getPathname();
|
|
||||||
|
|
||||||
$zip = new ZipArchive;
|
$zip = new ZipArchive;
|
||||||
$result = $zip->open($zipPath);
|
expect($zip->open($zipPath))->toBeTrue();
|
||||||
|
expect($zip->numFiles)->toBe(2);
|
||||||
expect($result)->toBe(true);
|
expect($zip->getFromName('notes.txt'))->toBe('hello zip content');
|
||||||
expect($zip->numFiles)->toBe(1);
|
expect($zip->getFromName('holiday/photo.bin'))->toBe($binary);
|
||||||
expect($zip->statIndex(0)['size'])->toBe(strlen($content));
|
|
||||||
|
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
unlink($zipPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('last download streams successfully before auto-delete', function () {
|
test('last download streams successfully before auto-delete', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
|
|
||||||
$share = createShareWithFile();
|
$share = createShareWithFile();
|
||||||
$share->update(['max_downloads' => 1]);
|
$share->update(['max_downloads' => 1]);
|
||||||
$share->load('files');
|
|
||||||
|
|
||||||
$encryptionService = app(FileEncryptionService::class);
|
$content = $this->get(route('share.download.all', $share))->streamedContent();
|
||||||
$key = $share->encryption_key;
|
|
||||||
$content = 'last download content';
|
|
||||||
|
|
||||||
foreach ($share->files as $file) {
|
expect($content)->toStartWith("PK\x03\x04");
|
||||||
$dir = Storage::disk('shares')->path($share->token);
|
$this->assertModelMissing($share);
|
||||||
if (! is_dir($dir)) {
|
});
|
||||||
mkdir($dir, 0755, true);
|
|
||||||
}
|
|
||||||
$encryptedPath = $dir.'/'.basename($file->stored_path);
|
|
||||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
|
||||||
file_put_contents($tempSource, $content);
|
|
||||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
|
|
||||||
unlink($tempSource);
|
|
||||||
$file->update(['file_size' => strlen($content)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $this->get(route('share.download.all', $share));
|
test('a share whose files are still uploading is not found anywhere a recipient or uploader could open it', function (string $route) {
|
||||||
$response->assertDownload();
|
Storage::fake('shares');
|
||||||
|
$share = Share::factory()->pending()->create();
|
||||||
|
$file = ShareFile::factory()->for($share)->uploading()->create();
|
||||||
|
|
||||||
// Share was deleted after download
|
$response = $this->get(route($route, ['share' => $share, 'shareFile' => $file]));
|
||||||
expect(Share::query()->find($share->id))->toBeNull();
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
})->with([
|
||||||
|
'download page' => 'share.download',
|
||||||
|
'download all' => 'share.download.all',
|
||||||
|
'download one file' => 'share.download.file',
|
||||||
|
'share created page' => 'share.created',
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('a password share created before key wrapping still unlocks and downloads', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$salt = str_repeat('cd', 32);
|
||||||
|
$share = Share::factory()->withPassword('old-password')->create(['encryption_salt' => $salt]);
|
||||||
|
$file = ShareFile::factory()->for($share)->create(['stored_path' => 'shares/'.$share->token.'/old.enc', 'file_size' => 11]);
|
||||||
|
$source = tempnam(sys_get_temp_dir(), 'old');
|
||||||
|
file_put_contents($source, 'old content');
|
||||||
|
Storage::disk('shares')->makeDirectory($share->token);
|
||||||
|
app(FileEncryptionService::class)->encryptFile($source, Storage::disk('shares')->path($share->token.'/old.enc'), bin2hex(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 32, true)), 1024);
|
||||||
|
unlink($source);
|
||||||
|
|
||||||
|
Livewire::test(ShareDownload::class, ['share' => $share])
|
||||||
|
->set('password', 'old-password')
|
||||||
|
->call('verifyPassword')
|
||||||
|
->assertSet('authenticated', true);
|
||||||
|
|
||||||
|
expect($this->get(route('share.download.file', [$share, $file]))->streamedContent())->toBe('old content');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('share auto-deletes after reaching download limit', function () {
|
test('share auto-deletes after reaching download limit', function () {
|
||||||
@@ -199,13 +177,10 @@ test('share auto-deletes after reaching download limit', function () {
|
|||||||
/**
|
/**
|
||||||
* Helper to create a share with an actual encrypted file.
|
* Helper to create a share with an actual encrypted file.
|
||||||
*/
|
*/
|
||||||
function createShareWithFile(?string $password = null): Share
|
function createShareWithFile(?string $password = null, string $content = 'test content'): Share
|
||||||
{
|
{
|
||||||
$service = app(ShareService::class);
|
return app(ShareService::class)->createShare([
|
||||||
$file = UploadedFile::fake()->create('testfile.txt', 100);
|
['file' => UploadedFile::fake()->createWithContent('testfile.txt', $content), 'relativePath' => null],
|
||||||
|
|
||||||
return $service->createShare([
|
|
||||||
['file' => $file, 'relativePath' => null],
|
|
||||||
], [
|
], [
|
||||||
'password' => $password,
|
'password' => $password,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\ShareFile;
|
||||||
|
use App\Services\ShareService;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT a chunk's bytes as the uploader's page does, with the given pending shares in the session.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $pendingShares
|
||||||
|
*/
|
||||||
|
function putChunk(mixed $test, ShareFile $file, int $index, string $chunk, array $pendingShares): mixed
|
||||||
|
{
|
||||||
|
return $test->withSession(['pending_shares' => $pendingShares])->call(
|
||||||
|
'PUT',
|
||||||
|
route('upload.chunk', ['shareFile' => $file, 'index' => $index]),
|
||||||
|
server: ['CONTENT_TYPE' => 'application/octet-stream', 'HTTP_ACCEPT' => 'application/json'],
|
||||||
|
content: $chunk,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a chunk for a pending share this session started is stored', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
config(['uploads.chunk_size' => 4]);
|
||||||
|
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
|
||||||
|
|
||||||
|
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, false), [$file->share->token]);
|
||||||
|
|
||||||
|
$response->assertOk()->assertExactJson(['uploaded_chunks' => 1]);
|
||||||
|
expect($file->refresh()->uploaded_chunks)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk for a pending share another session started returns 404', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||||
|
|
||||||
|
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), ['someOtherToken12']);
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk for a share that is already complete returns 404', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||||
|
$file->share->update(['completed_at' => now()]);
|
||||||
|
|
||||||
|
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), [$file->share->token]);
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk for a removed file returns 404', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$service = app(ShareService::class);
|
||||||
|
$file = $service->registerFile(null, 'notes.txt', 4, null);
|
||||||
|
$chunk = encryptedChunk($file, 'abcd', 0, true);
|
||||||
|
$token = $file->share->token;
|
||||||
|
$service->removeFile($file);
|
||||||
|
|
||||||
|
$response = putChunk($this, $file, 0, $chunk, [$token]);
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk that skips ahead returns 409 with the number of chunks stored', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
config(['uploads.chunk_size' => 4]);
|
||||||
|
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
|
||||||
|
|
||||||
|
$response = putChunk($this, $file, 1, encryptedChunk($file, 'ef', 1, true), [$file->share->token]);
|
||||||
|
|
||||||
|
$response->assertConflict()->assertExactJson(['uploaded_chunks' => 0]);
|
||||||
|
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk sent again after it was stored is acknowledged without storing it twice', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
config(['uploads.chunk_size' => 4]);
|
||||||
|
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
|
||||||
|
$chunk = encryptedChunk($file, 'abcd', 0, false);
|
||||||
|
putChunk($this, $file, 0, $chunk, [$file->share->token]);
|
||||||
|
|
||||||
|
$response = putChunk($this, $file->refresh(), 0, $chunk, [$file->share->token]);
|
||||||
|
|
||||||
|
$response->assertOk()->assertExactJson(['uploaded_chunks' => 1]);
|
||||||
|
expect($file->refresh()->uploaded_chunks)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an invalid chunk returns 422 and is not stored', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||||
|
|
||||||
|
$response = putChunk($this, $file, 0, str_repeat("\0", 20), [$file->share->token]);
|
||||||
|
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk is refused until the system password was entered', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
Setting::set('system_password', bcrypt('system-secret'));
|
||||||
|
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||||
|
|
||||||
|
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), [$file->share->token]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('system-password'));
|
||||||
|
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\ShareFile;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\FileEncryptionService;
|
||||||
|
use App\Services\ShareService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -53,3 +56,24 @@ function something()
|
|||||||
{
|
{
|
||||||
// ..
|
// ..
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page of SealShare, once it can be used: loaded, with Alpine and Livewire started. Shared by
|
||||||
|
* every file under tests/Browser, so a browser test needs no visit() of its own to define it.
|
||||||
|
*/
|
||||||
|
function ready(mixed $page): mixed
|
||||||
|
{
|
||||||
|
return $page->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chunk of a registered file, encrypted with its share's key and its header's nonce prefix.
|
||||||
|
*/
|
||||||
|
function encryptedChunk(ShareFile $file, string $plaintext, int $index, bool $isLast): string
|
||||||
|
{
|
||||||
|
$encryption = app(FileEncryptionService::class);
|
||||||
|
$header = $encryption->parseHeader(file_get_contents(app(ShareService::class)->storedFilePath($file), false, null, 0, FileEncryptionService::HEADER_LENGTH));
|
||||||
|
|
||||||
|
return $encryption->encryptChunk($plaintext, $file->share->encryption_key, $header['noncePrefix'], $index, $isLast);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Share;
|
||||||
use App\Services\QrCodeService;
|
use App\Services\QrCodeService;
|
||||||
use Illuminate\Http\UploadedFile;
|
use App\Services\ShareService;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
|
||||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
|
||||||
use Tests\Screenshots\DemoData;
|
use Tests\Screenshots\DemoData;
|
||||||
use Tests\Screenshots\Publisher;
|
use Tests\Screenshots\Publisher;
|
||||||
|
|
||||||
@@ -19,14 +18,9 @@ beforeEach(function () {
|
|||||||
// Sessions have to outlive a request: a sign-in, an unlocked share.
|
// Sessions have to outlive a request: a sign-in, an unlocked share.
|
||||||
config(['session.driver' => 'file']);
|
config(['session.driver' => 'file']);
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
Storage::fake('tmp-for-tests');
|
|
||||||
|
|
||||||
$this->travelTo(Carbon::parse('2026-10-01 09:30'));
|
$this->travelTo(Carbon::parse('2026-10-01 09:30'));
|
||||||
|
|
||||||
// Livewire deletes temporary uploads a day older than now, by the files' real modification
|
|
||||||
// times: under the frozen clock that is every file this run stores.
|
|
||||||
config(['livewire.temporary_file_upload.cleanup' => false]);
|
|
||||||
|
|
||||||
DemoData::shares();
|
DemoData::shares();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,27 +65,36 @@ function shoot(mixed $page, string $device, string $theme, string $name): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Put files into the uploader the way a finished upload does: stored as Livewire temporary uploads
|
* Put files into the uploader the way a finished upload does: registered through the page's own
|
||||||
* and handed to `_finishUpload` by their signed names. A browser test cannot select files through
|
* `registerFiles`, their encrypted content stored here as the browser would have sent it (zeros of
|
||||||
* the file input, because the in-process server does not store a multipart upload.
|
* the demo size), and the list refreshed to show them uploaded.
|
||||||
*
|
*
|
||||||
* @param array<string, int> $files relative path => size in kilobytes
|
* @param array<string, int> $files relative path => size in kilobytes
|
||||||
*/
|
*/
|
||||||
function selectFiles(mixed $page, array $files): void
|
function selectFiles(mixed $page, array $files): void
|
||||||
{
|
{
|
||||||
$signed = [];
|
$selection = collect($files)->map(fn (int $kilobytes, string $path): array => [
|
||||||
|
'name' => basename($path),
|
||||||
|
'size' => $kilobytes * 1024,
|
||||||
|
'path' => str_contains($path, '/') ? $path : null,
|
||||||
|
])->values();
|
||||||
|
$wire = 'Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id"))';
|
||||||
|
|
||||||
foreach ($files as $path => $kilobytes) {
|
$page->script('(async () => { await '.$wire.'.registerFiles('.json_encode($selection).') })()');
|
||||||
// Livewire's own storage for an arriving upload: the file and its name, type and size beside it.
|
$page->waitForText('Selected Files ('.count($files).')');
|
||||||
$stored = FileUploadConfiguration::storeTemporaryFile(UploadedFile::fake()->create(basename($path), $kilobytes), 'tmp-for-tests');
|
|
||||||
|
|
||||||
$signed[] = TemporaryUploadedFile::signPath(basename($stored));
|
$shareService = app(ShareService::class);
|
||||||
|
|
||||||
|
foreach (Share::query()->whereNull('completed_at')->latest('id')->firstOrFail()->files as $file) {
|
||||||
|
$header = $shareService->readHeader($file);
|
||||||
|
|
||||||
|
for ($index = 0; $index < $header['chunkCount']; $index++) {
|
||||||
|
$length = min($header['chunkSize'], $file->file_size - $index * $header['chunkSize']);
|
||||||
|
$shareService->storeChunk($file->refresh(), $index, encryptedChunk($file, str_repeat("\0", $length), $index, $index === $header['chunkCount'] - 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$relativePaths = array_map(fn (string $path): ?string => str_contains($path, '/') ? $path : null, array_keys($files));
|
$page->script('(async () => { await '.$wire.'.$refresh() })()');
|
||||||
|
|
||||||
$page->script('(() => { const wire = Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id")); wire.$set("relativePaths", '.json_encode($relativePaths).', false); wire._finishUpload("files", '.json_encode($signed).', true) })()');
|
|
||||||
|
|
||||||
$page->assertSee('Selected Files ('.count($files).')');
|
$page->assertSee('Selected Files ('.count($files).')');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Services\FileEncryptionService;
|
use App\Services\FileEncryptionService;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
$this->service = new FileEncryptionService;
|
$this->service = new FileEncryptionService;
|
||||||
@@ -16,6 +15,27 @@ afterEach(function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole decrypted content of an encrypted file.
|
||||||
|
*/
|
||||||
|
function decryptToString(FileEncryptionService $service, string $path, string $key): string
|
||||||
|
{
|
||||||
|
return implode('', iterator_to_array($service->decryptedChunks($path, $key), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chunk encrypted the way the uploader's browser does, built here without the service: the
|
||||||
|
* nonce is prefix, index and last-chunk flag; the tag follows the ciphertext.
|
||||||
|
*/
|
||||||
|
function browserChunk(string $plaintext, string $keyHex, string $noncePrefix, int $index, bool $isLast): string
|
||||||
|
{
|
||||||
|
$tag = '';
|
||||||
|
$nonce = $noncePrefix.pack('N', $index).($isLast ? "\x01" : "\x00");
|
||||||
|
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', hex2bin($keyHex), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||||
|
|
||||||
|
return $ciphertext.$tag;
|
||||||
|
}
|
||||||
|
|
||||||
test('encrypt and decrypt round-trip works', function () {
|
test('encrypt and decrypt round-trip works', function () {
|
||||||
$sourcePath = $this->tempDir.'/source.txt';
|
$sourcePath = $this->tempDir.'/source.txt';
|
||||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||||
@@ -25,14 +45,10 @@ test('encrypt and decrypt round-trip works', function () {
|
|||||||
|
|
||||||
$key = $this->service->generateRandomKey();
|
$key = $this->service->generateRandomKey();
|
||||||
|
|
||||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024);
|
||||||
|
|
||||||
expect(file_exists($encryptedPath))->toBeTrue();
|
expect(file_get_contents($encryptedPath))->not->toContain($content);
|
||||||
expect(file_get_contents($encryptedPath))->not->toBe($content);
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||||
|
|
||||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
|
||||||
|
|
||||||
expect($decrypted)->toBe($content);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decrypt with wrong key fails', function () {
|
test('decrypt with wrong key fails', function () {
|
||||||
@@ -41,12 +57,9 @@ test('decrypt with wrong key fails', function () {
|
|||||||
|
|
||||||
file_put_contents($sourcePath, 'Secret data');
|
file_put_contents($sourcePath, 'Secret data');
|
||||||
|
|
||||||
$correctKey = $this->service->generateRandomKey();
|
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
||||||
$wrongKey = $this->service->generateRandomKey();
|
|
||||||
|
|
||||||
$this->service->encryptFile($sourcePath, $encryptedPath, $correctKey);
|
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
|
||||||
|
|
||||||
$this->service->decryptFile($encryptedPath, $wrongKey);
|
|
||||||
})->throws(RuntimeException::class, 'Decryption failed');
|
})->throws(RuntimeException::class, 'Decryption failed');
|
||||||
|
|
||||||
test('derive key produces consistent results', function () {
|
test('derive key produces consistent results', function () {
|
||||||
@@ -98,78 +111,50 @@ test('password-derived key encrypt/decrypt round-trip works', function () {
|
|||||||
|
|
||||||
file_put_contents($sourcePath, $content);
|
file_put_contents($sourcePath, $content);
|
||||||
|
|
||||||
$password = 'user-password';
|
$key = bin2hex($this->service->deriveKey('user-password', $this->service->generateSalt()));
|
||||||
$salt = $this->service->generateSalt();
|
|
||||||
$key = bin2hex($this->service->deriveKey($password, $salt));
|
|
||||||
|
|
||||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024);
|
||||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
|
||||||
|
|
||||||
expect($decrypted)->toBe($content);
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decrypt file stream returns streamed response', function () {
|
test('an encrypted file starts with the SEALCHK2 header and its chunk size', function () {
|
||||||
$sourcePath = $this->tempDir.'/source.txt';
|
|
||||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
|
||||||
$content = 'Streamed content';
|
|
||||||
|
|
||||||
file_put_contents($sourcePath, $content);
|
|
||||||
|
|
||||||
$key = $this->service->generateRandomKey();
|
|
||||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
|
||||||
|
|
||||||
$response = $this->service->decryptFileStream($encryptedPath, $key, 'test.txt', 'text/plain');
|
|
||||||
|
|
||||||
expect($response)->toBeInstanceOf(StreamedResponse::class);
|
|
||||||
expect($response->headers->get('Content-Type'))->toBe('text/plain');
|
|
||||||
expect($response->headers->get('Content-Disposition'))->toContain('test.txt');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('chunked file has SEALCHK1 magic header', function () {
|
|
||||||
$sourcePath = $this->tempDir.'/source.txt';
|
$sourcePath = $this->tempDir.'/source.txt';
|
||||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||||
|
|
||||||
file_put_contents($sourcePath, 'test content');
|
file_put_contents($sourcePath, 'test content');
|
||||||
|
|
||||||
$key = $this->service->generateRandomKey();
|
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
||||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
|
||||||
|
|
||||||
$header = file_get_contents($encryptedPath, false, null, 0, 8);
|
expect(file_get_contents($encryptedPath, false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 1024));
|
||||||
|
|
||||||
expect($header)->toBe('SEALCHK1');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('multi-chunk round-trip works', function () {
|
test('multi-chunk round-trip works', function () {
|
||||||
$sourcePath = $this->tempDir.'/large.bin';
|
$sourcePath = $this->tempDir.'/large.bin';
|
||||||
$encryptedPath = $this->tempDir.'/large.enc';
|
$encryptedPath = $this->tempDir.'/large.enc';
|
||||||
|
$content = random_bytes(2500);
|
||||||
// Create a file larger than one 4 MB chunk (5 MB)
|
|
||||||
$chunkSize = 4 * 1024 * 1024;
|
|
||||||
$content = random_bytes($chunkSize + (1024 * 1024));
|
|
||||||
|
|
||||||
file_put_contents($sourcePath, $content);
|
file_put_contents($sourcePath, $content);
|
||||||
|
|
||||||
$key = $this->service->generateRandomKey();
|
$key = $this->service->generateRandomKey();
|
||||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
|
||||||
|
|
||||||
expect($decrypted)->toBe($content);
|
expect(filesize($encryptedPath))->toBe(19 + 3 * 16 + 2500);
|
||||||
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('exact chunk boundary round-trip works', function () {
|
test('exact chunk boundary round-trip works', function () {
|
||||||
$sourcePath = $this->tempDir.'/exact.bin';
|
$sourcePath = $this->tempDir.'/exact.bin';
|
||||||
$encryptedPath = $this->tempDir.'/exact.enc';
|
$encryptedPath = $this->tempDir.'/exact.enc';
|
||||||
|
$content = random_bytes(2000);
|
||||||
// Create a file exactly equal to one chunk (4 MB)
|
|
||||||
$content = random_bytes(4 * 1024 * 1024);
|
|
||||||
|
|
||||||
file_put_contents($sourcePath, $content);
|
file_put_contents($sourcePath, $content);
|
||||||
|
|
||||||
$key = $this->service->generateRandomKey();
|
$key = $this->service->generateRandomKey();
|
||||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
|
||||||
|
|
||||||
expect($decrypted)->toBe($content);
|
expect(filesize($encryptedPath))->toBe(19 + 2 * 16 + 2000);
|
||||||
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('empty file round-trip works', function () {
|
test('empty file round-trip works', function () {
|
||||||
@@ -179,58 +164,123 @@ test('empty file round-trip works', function () {
|
|||||||
file_put_contents($sourcePath, '');
|
file_put_contents($sourcePath, '');
|
||||||
|
|
||||||
$key = $this->service->generateRandomKey();
|
$key = $this->service->generateRandomKey();
|
||||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
|
||||||
|
|
||||||
expect($decrypted)->toBe('');
|
expect(filesize($encryptedPath))->toBe(19 + 16);
|
||||||
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chunks encrypted the way the browser does decrypt to the original file', function () {
|
||||||
|
$key = $this->service->generateRandomKey();
|
||||||
|
$header = $this->service->createHeader(4);
|
||||||
|
$noncePrefix = substr($header, 12, 7);
|
||||||
|
$encryptedPath = $this->tempDir.'/browser.enc';
|
||||||
|
|
||||||
|
file_put_contents($encryptedPath, $header
|
||||||
|
.browserChunk('abcd', $key, $noncePrefix, 0, false)
|
||||||
|
.browserChunk('ef', $key, $noncePrefix, 1, true));
|
||||||
|
|
||||||
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('abcdef');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk with the wrong last-chunk flag is rejected', function () {
|
||||||
|
$key = $this->service->generateRandomKey();
|
||||||
|
$noncePrefix = random_bytes(7);
|
||||||
|
|
||||||
|
$this->service->decryptChunk(browserChunk('abcd', $key, $noncePrefix, 0, false), $key, $noncePrefix, 0, true);
|
||||||
|
})->throws(RuntimeException::class, 'Decryption failed');
|
||||||
|
|
||||||
|
test('a file cut short at a chunk boundary fails to decrypt', function () {
|
||||||
|
$sourcePath = $this->tempDir.'/source.bin';
|
||||||
|
$encryptedPath = $this->tempDir.'/truncated.enc';
|
||||||
|
|
||||||
|
file_put_contents($sourcePath, random_bytes(3000));
|
||||||
|
|
||||||
|
$key = $this->service->generateRandomKey();
|
||||||
|
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||||
|
|
||||||
|
$handle = fopen($encryptedPath, 'r+b');
|
||||||
|
ftruncate($handle, 19 + 2 * (1000 + 16));
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
decryptToString($this->service, $encryptedPath, $key);
|
||||||
|
})->throws(RuntimeException::class, 'Decryption failed');
|
||||||
|
|
||||||
|
test('a file with two chunks swapped fails to decrypt', function () {
|
||||||
|
$key = $this->service->generateRandomKey();
|
||||||
|
$header = $this->service->createHeader(4);
|
||||||
|
$noncePrefix = substr($header, 12, 7);
|
||||||
|
$encryptedPath = $this->tempDir.'/swapped.enc';
|
||||||
|
|
||||||
|
file_put_contents($encryptedPath, $header
|
||||||
|
.browserChunk('efgh', $key, $noncePrefix, 1, false)
|
||||||
|
.browserChunk('abcd', $key, $noncePrefix, 0, false)
|
||||||
|
.browserChunk('ij', $key, $noncePrefix, 2, true));
|
||||||
|
|
||||||
|
decryptToString($this->service, $encryptedPath, $key);
|
||||||
|
})->throws(RuntimeException::class, 'Decryption failed');
|
||||||
|
|
||||||
|
test('SEALCHK1 files from before still decrypt', function () {
|
||||||
|
$key = $this->service->generateRandomKey();
|
||||||
|
$encryptedPath = $this->tempDir.'/sealchk1.enc';
|
||||||
|
$baseNonce = random_bytes(12);
|
||||||
|
$file = 'SEALCHK1'.pack('N', 4).$baseNonce;
|
||||||
|
|
||||||
|
foreach (['abcd', 'ef'] as $index => $plaintext) {
|
||||||
|
$nonce = $baseNonce;
|
||||||
|
$indexBytes = pack('N', $index);
|
||||||
|
|
||||||
|
for ($i = 0; $i < 4; $i++) {
|
||||||
|
$nonce[8 + $i] = $nonce[8 + $i] ^ $indexBytes[$i];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = '';
|
||||||
|
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||||
|
$file .= $tag.$ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($encryptedPath, $file);
|
||||||
|
|
||||||
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('abcdef');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('legacy format backward compatibility', function () {
|
test('legacy format backward compatibility', function () {
|
||||||
$sourcePath = $this->tempDir.'/source.txt';
|
|
||||||
$encryptedPath = $this->tempDir.'/legacy.enc';
|
$encryptedPath = $this->tempDir.'/legacy.enc';
|
||||||
$content = 'Legacy encrypted content';
|
$content = 'Legacy encrypted content';
|
||||||
|
|
||||||
file_put_contents($sourcePath, $content);
|
|
||||||
|
|
||||||
$key = $this->service->generateRandomKey();
|
$key = $this->service->generateRandomKey();
|
||||||
$binaryKey = hex2bin($key);
|
|
||||||
|
|
||||||
// Manually create a legacy format file: [nonce][tag][ciphertext]
|
// Manually create a legacy format file: [nonce][tag][ciphertext]
|
||||||
$nonce = random_bytes(12);
|
$nonce = random_bytes(12);
|
||||||
$tag = '';
|
$tag = '';
|
||||||
$ciphertext = openssl_encrypt($content, 'aes-256-gcm', $binaryKey, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
$ciphertext = openssl_encrypt($content, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||||
file_put_contents($encryptedPath, $nonce.$tag.$ciphertext);
|
file_put_contents($encryptedPath, $nonce.$tag.$ciphertext);
|
||||||
|
|
||||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||||
|
|
||||||
expect($decrypted)->toBe($content);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wrong key on chunked file throws exception', function () {
|
test('wrong key on chunked file throws exception', function () {
|
||||||
$sourcePath = $this->tempDir.'/source.txt';
|
$sourcePath = $this->tempDir.'/source.txt';
|
||||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||||
|
|
||||||
file_put_contents($sourcePath, 'Chunked secret data');
|
file_put_contents($sourcePath, random_bytes(2500));
|
||||||
|
|
||||||
$correctKey = $this->service->generateRandomKey();
|
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1000);
|
||||||
$wrongKey = $this->service->generateRandomKey();
|
|
||||||
|
|
||||||
$this->service->encryptFile($sourcePath, $encryptedPath, $correctKey);
|
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
|
||||||
|
|
||||||
$this->service->decryptFile($encryptedPath, $wrongKey);
|
|
||||||
})->throws(RuntimeException::class, 'Decryption failed');
|
})->throws(RuntimeException::class, 'Decryption failed');
|
||||||
|
|
||||||
test('decrypt file stream with file size sets content-length header', function () {
|
test('a wrapped key unwraps with its password to the same data key', function () {
|
||||||
$sourcePath = $this->tempDir.'/source.txt';
|
$dataKey = $this->service->generateRandomKey();
|
||||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
|
||||||
$content = 'Content with known size';
|
|
||||||
|
|
||||||
file_put_contents($sourcePath, $content);
|
$wrapped = $this->service->wrapKey($dataKey, 'correct horse battery');
|
||||||
|
|
||||||
$key = $this->service->generateRandomKey();
|
expect($wrapped)->toStartWith('argon2id$')->not->toContain($dataKey);
|
||||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
expect($this->service->unwrapKey($wrapped, 'correct horse battery'))->toBe($dataKey);
|
||||||
|
|
||||||
$response = $this->service->decryptFileStream($encryptedPath, $key, 'test.txt', 'text/plain', strlen($content));
|
|
||||||
|
|
||||||
expect($response->headers->get('Content-Length'))->toBe((string) strlen($content));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a wrapped key does not unwrap with a wrong password', function () {
|
||||||
|
$wrapped = $this->service->wrapKey($this->service->generateRandomKey(), 'correct horse battery');
|
||||||
|
|
||||||
|
$this->service->unwrapKey($wrapped, 'wrong horse battery');
|
||||||
|
})->throws(RuntimeException::class, 'Unwrapping failed');
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\PasswordGeneratorService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
pest()->extend(TestCase::class)
|
||||||
|
->use(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->service = app(PasswordGeneratorService::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('options fall back to the defaults when nothing is saved', function () {
|
||||||
|
expect($this->service->options())->toBe([
|
||||||
|
'mode' => 'button',
|
||||||
|
'type' => 'characters',
|
||||||
|
'length' => 20,
|
||||||
|
'characterSets' => ['uppercase', 'lowercase', 'numbers'],
|
||||||
|
'avoidAmbiguous' => true,
|
||||||
|
'words' => 6,
|
||||||
|
'separator' => 'hyphen',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('options read the saved generator settings', function () {
|
||||||
|
Setting::set('password_generator_mode', 'prefill');
|
||||||
|
Setting::set('password_generator_type', 'passphrase');
|
||||||
|
Setting::set('password_generator_length', 32);
|
||||||
|
Setting::set('password_generator_character_sets', 'numbers,symbols');
|
||||||
|
Setting::set('password_generator_avoid_ambiguous', '0');
|
||||||
|
Setting::set('password_generator_words', 8);
|
||||||
|
Setting::set('password_generator_separator', 'space');
|
||||||
|
|
||||||
|
expect($this->service->options())->toBe([
|
||||||
|
'mode' => 'prefill',
|
||||||
|
'type' => 'passphrase',
|
||||||
|
'length' => 32,
|
||||||
|
'characterSets' => ['numbers', 'symbols'],
|
||||||
|
'avoidAmbiguous' => false,
|
||||||
|
'words' => 8,
|
||||||
|
'separator' => 'space',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('options replace saved values that are not allowed with the defaults', function () {
|
||||||
|
Setting::set('password_generator_mode', 'sometimes');
|
||||||
|
Setting::set('password_generator_type', 'emoji');
|
||||||
|
Setting::set('password_generator_length', 8);
|
||||||
|
Setting::set('password_generator_character_sets', 'runes');
|
||||||
|
Setting::set('password_generator_words', 40);
|
||||||
|
Setting::set('password_generator_separator', 'comma');
|
||||||
|
|
||||||
|
expect($this->service->options())->toMatchArray([
|
||||||
|
'mode' => 'button',
|
||||||
|
'type' => 'characters',
|
||||||
|
'length' => 20,
|
||||||
|
'characterSets' => ['uppercase', 'lowercase', 'numbers'],
|
||||||
|
'words' => 6,
|
||||||
|
'separator' => 'hyphen',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a character password has the chosen length and only characters from the chosen sets', function () {
|
||||||
|
$password = $this->service->generate([
|
||||||
|
'type' => 'characters',
|
||||||
|
'length' => 40,
|
||||||
|
'characterSets' => ['numbers'],
|
||||||
|
'avoidAmbiguous' => true,
|
||||||
|
'words' => 6,
|
||||||
|
'separator' => 'hyphen',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($password)->toMatch('/^[2-9]{40}$/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a character password holds at least one character of every chosen set', function () {
|
||||||
|
foreach (range(1, 25) as $draw) {
|
||||||
|
expect($this->service->characters(4, ['uppercase', 'lowercase', 'numbers', 'symbols'], false))
|
||||||
|
->toMatch('/[A-Z]/')
|
||||||
|
->toMatch('/[a-z]/')
|
||||||
|
->toMatch('/[0-9]/')
|
||||||
|
->toMatch('/[^A-Za-z0-9]/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a character password leaves out look-alike characters when asked', function () {
|
||||||
|
foreach (range(1, 25) as $draw) {
|
||||||
|
expect($this->service->characters(64, ['uppercase', 'lowercase', 'numbers'], true))->not->toMatch('/[0O1lI]/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a character password needs room for every chosen set', function () {
|
||||||
|
$this->service->characters(2, ['uppercase', 'lowercase', 'numbers'], false);
|
||||||
|
})->throws(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
test('a passphrase has the chosen number of words from the word list, joined by the separator', function () {
|
||||||
|
$wordList = file(resource_path('wordlists/eff-large-wordlist.txt'), FILE_IGNORE_NEW_LINES);
|
||||||
|
|
||||||
|
$passphrase = $this->service->generate([
|
||||||
|
'type' => 'passphrase',
|
||||||
|
'length' => 20,
|
||||||
|
'characterSets' => ['uppercase'],
|
||||||
|
'avoidAmbiguous' => true,
|
||||||
|
'words' => 7,
|
||||||
|
'separator' => 'dot',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$words = explode('.', $passphrase);
|
||||||
|
expect($words)->toHaveCount(7);
|
||||||
|
expect(array_diff($words, $wordList))->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the word list is EFF\'s large list without its hyphenated words', function () {
|
||||||
|
expect($this->service->wordList())
|
||||||
|
->toHaveCount(7772)
|
||||||
|
->each->toMatch('/^[a-z]+$/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two generated passwords differ', function () {
|
||||||
|
expect($this->service->generate())->not->toBe($this->service->generate());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the entropy estimate follows the alphabet or the word list', function () {
|
||||||
|
expect($this->service->entropyBits([
|
||||||
|
'type' => 'characters',
|
||||||
|
'length' => 20,
|
||||||
|
'characterSets' => ['uppercase', 'lowercase', 'numbers'],
|
||||||
|
'avoidAmbiguous' => true,
|
||||||
|
]))->toBe(116);
|
||||||
|
|
||||||
|
expect($this->service->entropyBits([
|
||||||
|
'type' => 'passphrase',
|
||||||
|
'length' => 20,
|
||||||
|
'characterSets' => [],
|
||||||
|
'avoidAmbiguous' => false,
|
||||||
|
'words' => 6,
|
||||||
|
]))->toBe(77);
|
||||||
|
});
|
||||||
@@ -3,11 +3,14 @@
|
|||||||
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 App\Services\FileEncryptionService;
|
||||||
use App\Services\ShareService;
|
use App\Services\ShareService;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
pest()->extend(TestCase::class)
|
pest()->extend(TestCase::class)
|
||||||
@@ -29,7 +32,7 @@ test('create share without password stores encryption key', function () {
|
|||||||
expect($share->token)->toHaveLength(16);
|
expect($share->token)->toHaveLength(16);
|
||||||
expect($share->password)->toBeNull();
|
expect($share->password)->toBeNull();
|
||||||
expect($share->encryption_key)->not->toBeNull();
|
expect($share->encryption_key)->not->toBeNull();
|
||||||
expect($share->encryption_salt)->not->toBeNull();
|
expect($share->wrapped_key)->toBeNull();
|
||||||
expect($share->files)->toHaveCount(1);
|
expect($share->files)->toHaveCount(1);
|
||||||
expect($share->files->first()->original_name)->toBe('document.pdf');
|
expect($share->files->first()->original_name)->toBe('document.pdf');
|
||||||
});
|
});
|
||||||
@@ -161,8 +164,8 @@ test('get decryption key returns stored key for non-password share', function ()
|
|||||||
expect(strlen($key))->toBe(64);
|
expect(strlen($key))->toBe(64);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('get decryption key derives key for password share', function () {
|
test('get decryption key unwraps the data key of a password share, which decrypts its files', function () {
|
||||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
$file = UploadedFile::fake()->createWithContent('file.txt', 'the secret contents');
|
||||||
|
|
||||||
$share = $this->service->createShare([
|
$share = $this->service->createShare([
|
||||||
['file' => $file, 'relativePath' => null],
|
['file' => $file, 'relativePath' => null],
|
||||||
@@ -172,8 +175,17 @@ test('get decryption key derives key for password share', function () {
|
|||||||
|
|
||||||
$key = $this->service->getDecryptionKey($share, 'test-password');
|
$key = $this->service->getDecryptionKey($share, 'test-password');
|
||||||
|
|
||||||
expect($key)->not->toBeNull();
|
$decrypted = implode('', iterator_to_array(app(FileEncryptionService::class)->decryptedChunks($this->service->storedFilePath($share->files->first()), $key), false));
|
||||||
expect(strlen($key))->toBe(64);
|
expect($decrypted)->toBe('the secret contents');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get decryption key derives the key of a password share created before key wrapping', function () {
|
||||||
|
$salt = str_repeat('ab', 32);
|
||||||
|
$share = Share::factory()->withPassword('old-password')->create(['encryption_salt' => $salt]);
|
||||||
|
|
||||||
|
$key = $this->service->getDecryptionKey($share, 'old-password');
|
||||||
|
|
||||||
|
expect($key)->toBe(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 64));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('get decryption key throws for password share without password', function () {
|
test('get decryption key throws for password share without password', function () {
|
||||||
@@ -187,3 +199,175 @@ test('get decryption key throws for password share without password', function (
|
|||||||
|
|
||||||
$this->service->getDecryptionKey($share);
|
$this->service->getDecryptionKey($share);
|
||||||
})->throws(RuntimeException::class, 'Password required');
|
})->throws(RuntimeException::class, 'Password required');
|
||||||
|
|
||||||
|
test('registering a file starts a pending share with the file\'s encrypted header on disk', function () {
|
||||||
|
config(['uploads.chunk_size' => 4]);
|
||||||
|
|
||||||
|
$file = $this->service->registerFile(null, 'report.pdf', 10, 'reports/report.pdf');
|
||||||
|
|
||||||
|
expect($file->share->isCompleted())->toBeFalse();
|
||||||
|
expect($file->share->total_size)->toBe(10);
|
||||||
|
expect($file->relative_path)->toBe('reports/report.pdf');
|
||||||
|
expect($file->completed_at)->toBeNull();
|
||||||
|
expect(file_get_contents($this->service->storedFilePath($file), false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 4));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second registered file joins the same pending share and adds its size', function () {
|
||||||
|
$first = $this->service->registerFile(null, 'one.txt', 10, null);
|
||||||
|
|
||||||
|
$second = $this->service->registerFile($first->share, 'two.txt', 20, null);
|
||||||
|
|
||||||
|
expect($second->share_id)->toBe($first->share_id);
|
||||||
|
expect($second->share->total_size)->toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file larger than the admin file size limit is rejected', function () {
|
||||||
|
Setting::set('max_file_size', 5 * 1024 * 1024);
|
||||||
|
|
||||||
|
expect(fn () => $this->service->registerFile(null, 'big.iso', 6 * 1024 * 1024, null))
|
||||||
|
->toThrow(ValidationException::class, '"big.iso" is too large (6 MB). Maximum file size is 5 MB.');
|
||||||
|
expect(Share::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file beyond the admin limit of files per share is rejected', function () {
|
||||||
|
Setting::set('max_files_per_share', 1);
|
||||||
|
$first = $this->service->registerFile(null, 'one.txt', 10, null);
|
||||||
|
|
||||||
|
expect(fn () => $this->service->registerFile($first->share, 'two.txt', 10, null))
|
||||||
|
->toThrow(ValidationException::class, 'Too many files. Maximum 1 files allowed per share.');
|
||||||
|
expect(ShareFile::query()->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file that takes the share beyond the admin size per share is rejected', function () {
|
||||||
|
Setting::set('max_size_per_share', 25);
|
||||||
|
$first = $this->service->registerFile(null, 'one.txt', 20, null);
|
||||||
|
|
||||||
|
expect(fn () => $this->service->registerFile($first->share, 'two.txt', 10, null))
|
||||||
|
->toThrow(ValidationException::class, 'Total file size exceeds the maximum allowed per share.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file that does not fit the storage quota beside files still uploading is rejected', function () {
|
||||||
|
Setting::set('max_storage_quota', 100);
|
||||||
|
Share::factory()->pending()->create(['total_size' => 60]);
|
||||||
|
|
||||||
|
expect(fn () => $this->service->registerFile(null, 'file.txt', 50, null))
|
||||||
|
->toThrow(ValidationException::class, 'Storage is full. Please contact the administrator.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chunks stored in order complete the file', function () {
|
||||||
|
config(['uploads.chunk_size' => 4]);
|
||||||
|
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||||
|
|
||||||
|
$afterFirst = $this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, false));
|
||||||
|
$afterLast = $this->service->storeChunk($file->refresh(), 1, encryptedChunk($file, 'ef', 1, true));
|
||||||
|
|
||||||
|
expect([$afterFirst, $afterLast])->toBe([1, 2]);
|
||||||
|
expect($file->refresh()->completed_at)->not->toBeNull();
|
||||||
|
$decrypted = implode('', iterator_to_array(app(FileEncryptionService::class)->decryptedChunks($this->service->storedFilePath($file), $file->share->encryption_key), false));
|
||||||
|
expect($decrypted)->toBe('abcdef');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk stored a second time is counted once', function () {
|
||||||
|
config(['uploads.chunk_size' => 4]);
|
||||||
|
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||||
|
$chunk = encryptedChunk($file, 'abcd', 0, false);
|
||||||
|
$this->service->storeChunk($file, 0, $chunk);
|
||||||
|
|
||||||
|
$uploadedChunks = $this->service->storeChunk($file, 0, $chunk);
|
||||||
|
|
||||||
|
expect($uploadedChunks)->toBe(1);
|
||||||
|
expect($file->refresh()->uploaded_chunks)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk with the wrong length is rejected', function () {
|
||||||
|
config(['uploads.chunk_size' => 4]);
|
||||||
|
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||||
|
|
||||||
|
expect(fn () => $this->service->storeChunk($file, 0, encryptedChunk($file, 'abc', 0, false)))
|
||||||
|
->toThrow(InvalidArgumentException::class, 'wrong length');
|
||||||
|
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk that fails authentication is rejected', function () {
|
||||||
|
config(['uploads.chunk_size' => 4]);
|
||||||
|
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||||
|
$chunk = encryptedChunk($file, 'abcd', 0, false);
|
||||||
|
$chunk[0] = $chunk[0] ^ "\x01";
|
||||||
|
|
||||||
|
expect(fn () => $this->service->storeChunk($file, 0, $chunk))
|
||||||
|
->toThrow(InvalidArgumentException::class, 'failed authentication');
|
||||||
|
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk beyond the end of the file is rejected', function () {
|
||||||
|
config(['uploads.chunk_size' => 4]);
|
||||||
|
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||||
|
|
||||||
|
expect(fn () => $this->service->storeChunk($file, 1, encryptedChunk($file, 'abcd', 1, true)))
|
||||||
|
->toThrow(InvalidArgumentException::class, 'beyond the end');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the MIME type is detected from the first chunk\'s content', function () {
|
||||||
|
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==');
|
||||||
|
$file = $this->service->registerFile(null, 'photo.bin', strlen($png), null);
|
||||||
|
|
||||||
|
$this->service->storeChunk($file, 0, encryptedChunk($file, $png, 0, true));
|
||||||
|
|
||||||
|
expect($file->refresh()->mime_type)->toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a chunk for a removed file is rejected', function () {
|
||||||
|
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||||
|
$chunk = encryptedChunk($file, 'abcd', 0, true);
|
||||||
|
$this->service->removeFile($file);
|
||||||
|
|
||||||
|
$this->service->storeChunk($file, 0, $chunk);
|
||||||
|
})->throws(ModelNotFoundException::class);
|
||||||
|
|
||||||
|
test('removing a file deletes it and gives its size back', function () {
|
||||||
|
$keep = $this->service->registerFile(null, 'keep.txt', 10, null);
|
||||||
|
$remove = $this->service->registerFile($keep->share, 'remove.txt', 20, null);
|
||||||
|
$path = $this->service->storedFilePath($remove);
|
||||||
|
|
||||||
|
$this->service->removeFile($remove);
|
||||||
|
|
||||||
|
expect($keep->share->refresh()->total_size)->toBe(10);
|
||||||
|
expect(file_exists($path))->toBeFalse();
|
||||||
|
$this->assertModelMissing($remove);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completing a share with a file still uploading is rejected', function () {
|
||||||
|
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||||
|
|
||||||
|
expect(fn () => $this->service->completeShare($file->share))
|
||||||
|
->toThrow(ValidationException::class, 'Wait until every file has finished uploading, or remove the ones that failed.');
|
||||||
|
expect($file->share->refresh()->isCompleted())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completing a share without files is rejected', function () {
|
||||||
|
$share = Share::factory()->pending()->create();
|
||||||
|
|
||||||
|
expect(fn () => $this->service->completeShare($share))
|
||||||
|
->toThrow(ValidationException::class, 'Please select at least one file to upload.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completing a share with a password wraps its data key instead of storing it', function () {
|
||||||
|
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||||
|
$this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, true));
|
||||||
|
$dataKey = $file->share->encryption_key;
|
||||||
|
|
||||||
|
$share = $this->service->completeShare($file->share, ['password' => 'a-long-password']);
|
||||||
|
|
||||||
|
expect($share->refresh()->isCompleted())->toBeTrue();
|
||||||
|
expect($share->encryption_key)->toBeNull();
|
||||||
|
expect(app(FileEncryptionService::class)->unwrapKey($share->wrapped_key, 'a-long-password'))->toBe($dataKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create share stores an empty file', function () {
|
||||||
|
$share = $this->service->createShare([
|
||||||
|
['file' => UploadedFile::fake()->createWithContent('empty.txt', ''), 'relativePath' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($share->files->first()->completed_at)->not->toBeNull();
|
||||||
|
expect(filesize($this->service->storedFilePath($share->files->first())))->toBe(FileEncryptionService::HEADER_LENGTH + FileEncryptionService::TAG_LENGTH);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
defineConfig
|
defineConfig
|
||||||
} from 'vite';
|
} from 'vite';
|
||||||
import laravel from 'laravel-vite-plugin';
|
import laravel from 'laravel-vite-plugin';
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -10,7 +9,6 @@ export default defineConfig({
|
|||||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||||
refresh: true,
|
refresh: true,
|
||||||
}),
|
}),
|
||||||
tailwindcss(),
|
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user