Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f597181f91 | ||
|
|
30a485f5d9 | ||
|
|
6578335568 | ||
|
|
6c3202a0cf | ||
|
|
63bae2d1a6 | ||
|
|
b594fb2300 | ||
|
|
f9a7839ad3 | ||
|
|
202813a1e6 | ||
|
|
2c1ed8b720 |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
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. 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."
|
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, API responses), 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
|
disable-model-invocation: true
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ Each item gives the fork, then a hint (a grep or dir to spot which side the app
|
|||||||
## A. Validation & HTTP input
|
## A. Validation & HTTP input
|
||||||
|
|
||||||
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
|
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
|
||||||
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
|
- Hint: `ls app/Http/Requests`; grep -- `->validate(` / `Validator::make(` in `app/Http/Controllers`.
|
||||||
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
|
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
|
||||||
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
|
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
|
||||||
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
|
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
|
||||||
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
|
- Hint: grep -- `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
|
||||||
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
|
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
|
||||||
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
|
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ Each item gives the fork, then a hint (a grep or dir to spot which side the app
|
|||||||
7. Route handler style: closures in `routes/*.php` vs controller classes.
|
7. Route handler style: closures in `routes/*.php` vs controller classes.
|
||||||
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
|
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
|
||||||
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
|
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
|
||||||
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
|
- Hint: grep -- `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
|
||||||
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
|
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
|
||||||
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
|
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
|
||||||
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
|
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
|
||||||
@@ -37,7 +37,7 @@ Each item gives the fork, then a hint (a grep or dir to spot which side the app
|
|||||||
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
|
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
|
||||||
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
|
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
|
||||||
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
|
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
|
||||||
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
|
- Hint: grep -- `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
|
||||||
|
|
||||||
## D. Eloquent & models
|
## D. Eloquent & models
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ Each item gives the fork, then a hint (a grep or dir to spot which side the app
|
|||||||
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
|
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
|
||||||
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
|
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
|
||||||
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
|
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
|
||||||
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
|
- Hint: grep -- `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
|
||||||
|
|
||||||
## E. Architecture & organization
|
## E. Architecture & organization
|
||||||
|
|
||||||
@@ -91,11 +91,11 @@ This app ships a frontend stack, so the items below apply.
|
|||||||
## G. Database & migrations
|
## G. Database & migrations
|
||||||
|
|
||||||
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
|
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
|
||||||
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
|
- Hint: grep -- `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
|
||||||
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
|
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
|
||||||
- Hint: grep `function down` vs the migration count.
|
- Hint: grep `function down` vs the migration count.
|
||||||
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
|
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
|
||||||
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
|
- Hint: grep -- `->enum(` in migrations vs string columns cast to enums.
|
||||||
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
|
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
|
||||||
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
|
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
|
||||||
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
|
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
|
||||||
@@ -110,14 +110,14 @@ This app ships a frontend stack, so the items below apply.
|
|||||||
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
|
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
|
||||||
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
|
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
|
||||||
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
|
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
|
||||||
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
|
- Hint: grep -- `->mock(`, `->spy(`, `Mockery::` in `tests/`.
|
||||||
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
|
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
|
||||||
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
|
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
|
||||||
|
|
||||||
## I. Responses & API resources
|
## I. Responses & API resources
|
||||||
|
|
||||||
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
|
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
|
||||||
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
|
- Hint: `ls app/Http/Resources`; grep -- `JsonResource`, `->json(` in controllers.
|
||||||
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
|
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
|
||||||
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
|
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
|
||||||
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
|
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
|
||||||
@@ -128,7 +128,7 @@ This app ships a frontend stack, so the items below apply.
|
|||||||
## J. Strings, collections & dates
|
## J. Strings, collections & dates
|
||||||
|
|
||||||
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
|
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
|
||||||
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
|
- Hint: grep -- `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
|
||||||
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
|
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
|
||||||
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
|
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
|
||||||
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
|
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ $posts = Post::query()
|
|||||||
|
|
||||||
## Use Atomic Locks for Race Conditions
|
## Use Atomic Locks for Race Conditions
|
||||||
|
|
||||||
Use a lock when concurrent execution must be serialized. `Cache::lock()` provides an atomic lock when the configured cache store supports locks. `lockForUpdate()` locks selected database rows and must run inside a database transaction. These mechanisms solve different coordination problems.
|
Use a lock when concurrent execution must be serialized. `Cache::lock()` provides an atomic lock when the configured cache store supports locks. `lockForUpdate()` locks selected database rows and must run inside a database transaction. These mechanisms solve different coordination problems. Set the lease (`10` below) longer than the callback can ever run: once it expires, another process can acquire the lock while the first is still working.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
|
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Use the assertion in this table for each subject.
|
|||||||
|
|
||||||
Use a PHPUnit assertion only if no Pest expectation and no Laravel assertion exists for the subject.
|
Use a PHPUnit assertion only if no Pest expectation and no Laravel assertion exists for the subject.
|
||||||
|
|
||||||
Assert each fact once. Do not assert a 200 status before `assertSee`, because `assertSee` already shows that the page rendered.
|
Assert each fact once, but status and content are separate facts. `assertSee` passes on an error page that contains the text, so keep `assertOk()` alongside it when the status is part of the behavior.
|
||||||
|
|
||||||
## Named Response Assertions
|
## Named Response Assertions
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
coverage: xdebug
|
coverage: xdebug
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
|
|
||||||
@@ -65,25 +65,25 @@ jobs:
|
|||||||
needs: test
|
needs: test
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
# The runner is an arm64 server, so the amd64 image's final stage is emulated. QEMU 8.x
|
# The runner is an arm64 server, so the amd64 image's final stage is emulated. QEMU 8.x
|
||||||
# crashes running x86_64 programs on an arm64 host (QEMU issue 2168, "QEMU internal
|
# crashes running x86_64 programs on an arm64 host (QEMU issue 2168, "QEMU internal
|
||||||
# SIGSEGV {code=MAPERR, addr=0x20}") and 10.2 segfaults on this runner too; 9.2.2 was
|
# SIGSEGV {code=MAPERR, addr=0x20}") and 10.2 segfaults on this runner too; 9.2.2 was
|
||||||
# checked on the runner's host: node, composer and install-php-extensions all run.
|
# checked on the runner's host: node, composer and install-php-extensions all run.
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
with:
|
with:
|
||||||
image: tonistiigi/binfmt:qemu-v9.2.2
|
image: tonistiigi/binfmt:qemu-v9.2.2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
# Gitea's job token cannot publish packages yet: REGISTRY_TOKEN is an access token with
|
# Gitea's job token cannot publish packages yet: REGISTRY_TOKEN is an access token with
|
||||||
# package write rights, owned by the account that pushes (gitea.actor).
|
# package write rights, owned by the account that pushes (gitea.actor).
|
||||||
- name: Log in to the Gitea container registry
|
- name: Log in to the Gitea container registry
|
||||||
if: gitea.event_name != 'pull_request'
|
if: gitea.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: gitea.nonameweb.ch
|
registry: gitea.nonameweb.ch
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
@@ -91,7 +91,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: gitea.nonameweb.ch/nonameweb/sealshare
|
images: gitea.nonameweb.ch/nonameweb/sealshare
|
||||||
tags: |
|
tags: |
|
||||||
@@ -102,7 +102,7 @@ jobs:
|
|||||||
type=sha
|
type=sha
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Take the release notes from the changelog
|
- name: Take the release notes from the changelog
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: Testing
|
environment: Testing
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
coverage: xdebug
|
coverage: xdebug
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,38 @@ All notable changes to this project are documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.4.0] - 2026-10-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- "Download all to a folder" on the download page, in Chrome and Edge on a computer. The recipient picks a folder, and the files are saved into it one at a time, in their folder paths, with progress. A connection that breaks off is resumed where it stopped, files already in the folder are skipped, and files that still fail can be retried. Each request is only as long as one file, so large shares get through proxies that cut long requests. Other browsers keep the ZIP and the per-file buttons.
|
||||||
|
- Single-file downloads can be resumed: they answer HTTP Range requests, so the browser continues an interrupted download instead of starting over.
|
||||||
|
- The download page shows how many files a share holds and their total size, and marks the files this browser has downloaded. During a folder download it marks the file on its way and scrolls the list to it.
|
||||||
|
- "Browse Folder" on the upload page picks a whole folder. Each file keeps its path in it, as when a folder is dropped.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The ZIP download sends its size, so the browser shows its progress and notices a download that was cut short.
|
||||||
|
- A long file list on the download page scrolls on its own, so the download buttons stay on the first screen.
|
||||||
|
- A recipient's 1-hour download window restarts with each download they start, so downloading a large share file by file does not use up another download partway through.
|
||||||
|
- Updated to Laravel 13.35, Livewire 4.4.7, Laravel Octane 2.21 and Laravel Fortify 1.41, and Vite 8.3.4.
|
||||||
|
- The Gitea workflows use the current versions of the checkout, setup-node and Docker actions, which run on Node 24 instead of the end-of-life Node 20.
|
||||||
|
- Sessions are encrypted (unless `SESSION_ENCRYPT=false`), because they hold the keys of the password shares a recipient unlocked. Every share stays readable, but everyone is signed out once by the update: admins sign in again, and recipients enter a share's password again. A recipient who was inside their download window on a share that had reached its download limit can no longer download it.
|
||||||
|
- The Docker image no longer starts without `APP_KEY`. It used to generate one, print it to the logs and generate another on the next start, which left every share without a password unreadable. `docker-compose.example.yml` now requires it for both services. **Before updating an install that never set `APP_KEY`, copy the key from the container's logs (`Generated APP_KEY: …`) and set it for the app and the scheduler**, so the shares created since its last start stay readable. A new key is created with `docker run --rm --entrypoint php <image> artisan key:generate --show`; the command in the README did not run it before.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Downloads of large shares stopped after 5 minutes (`OCTANE_MAX_EXECUTION_TIME`, 300 seconds by default) and left a broken ZIP. Downloads no longer have a time limit.
|
||||||
|
- Security: a visitor with a password-protected share's link but not its password could reveal its file list (names, folder paths, sizes, and whether it holds a text) by changing the page's state in the browser. The files and the text themselves stayed protected.
|
||||||
|
- Security: the password-reset link is built from `APP_URL`, never from the request's host, which a forged `X-Forwarded-Host` header could set.
|
||||||
|
- Security: league/commonmark 2.10.3 fixes a denial of service in the GitHub Flavored Markdown table extension and a bypass of its raw-HTML filter. source-map-js, used by the asset build, is updated past a high-severity advisory.
|
||||||
|
|
||||||
|
## [2.3.0] - 2026-09-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Private text: a share can hold a text, such as a password, a key or a short note, with its files or on its own. It is typed into the new "Private text" card on the upload page (up to 100 KB) and encrypted in the browser like a file, so it gets the share's password, expiry and download limit. The recipient sees it only after pressing "Show text", which counts as their download, so a link preview cannot use up a share limited to one download. The text is left out of the file list, the ZIP and the file counts; the admin dashboard marks shares that hold one with "Text".
|
||||||
|
|
||||||
## [2.2.0] - 2026-09-19
|
## [2.2.0] - 2026-09-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -168,6 +200,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI.
|
- Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI.
|
||||||
- Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane.
|
- Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane.
|
||||||
|
|
||||||
|
[2.4.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.3.0...v2.4.0
|
||||||
|
[2.3.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.2.0...v2.3.0
|
||||||
[2.2.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.1.0...v2.2.0
|
[2.2.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.1.0...v2.2.0
|
||||||
[2.1.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.1...v2.1.0
|
[2.1.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.1...v2.1.0
|
||||||
[2.0.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...v2.0.1
|
[2.0.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...v2.0.1
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
|
|
||||||
# Laravel Boost Guidelines
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
|
||||||
|
|
||||||
## Foundational Context
|
## Foundational Context
|
||||||
|
|
||||||
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
|
This application is a Laravel application running on PHP 8.5. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||||
|
|
||||||
Before relying on a package's API, confirm its installed version:
|
Before relying on a package's API, confirm its installed version:
|
||||||
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||||
@@ -34,16 +32,12 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
|||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
|
|
||||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
- If a frontend change doesn't show in the UI or you get a "Unable to locate file in Vite manifest" error, run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||||
|
|
||||||
## Documentation Files
|
## Documentation Files
|
||||||
|
|
||||||
- You must only create documentation files if explicitly requested by the user.
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
## Replies
|
|
||||||
|
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
|
||||||
|
|
||||||
=== boost rules ===
|
=== boost rules ===
|
||||||
|
|
||||||
# Laravel Boost
|
# Laravel Boost
|
||||||
@@ -72,8 +66,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, including path-scoped framework guidelines under `.ai/rules/boost`. 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 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
|
||||||
|
|
||||||
@@ -103,7 +96,6 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
|||||||
# Deployment
|
# Deployment
|
||||||
|
|
||||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||||
- Activate the `deploying-to-cloud` skill whenever deploying to Laravel Cloud, configuring Cloud environments or resources, using the Cloud CLI, or troubleshooting Cloud deployments.
|
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
@@ -141,10 +133,6 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
|||||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
## Vite Error
|
|
||||||
|
|
||||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
|
||||||
|
|
||||||
=== laravel-octane/core rules ===
|
=== laravel-octane/core rules ===
|
||||||
|
|
||||||
# Laravel Octane
|
# Laravel Octane
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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; large files go up in chunks, each retried on its own if the connection drops
|
- **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
|
||||||
|
- **Private Text** — Share a password, a key or a short note (up to 100 KB) with the files or on its own; it is encrypted in the browser like a file and stays hidden until the recipient presses "Show text", which counts as a download
|
||||||
- **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 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
|
- **Encryption at Rest** — Files are encrypted in the uploader's browser, chunk by chunk with AES-256-GCM, before they are sent, and are stored only in encrypted form; with a share password the share's key is wrapped with a key derived from it (Argon2id) and never stored as it is. It is not end-to-end encryption: the server issues the key, checks each chunk, and decrypts the files for downloads
|
||||||
- **Password Protection** — Optionally protect shares with a password, typed or generated (random characters or a passphrase, as the admin configures) and copied on the upload page or next to the new link
|
- **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
|
||||||
@@ -93,7 +94,7 @@ curl -O https://gitea.nonameweb.ch/noNameWEB/SealShare/raw/branch/main/docker-co
|
|||||||
cp docker-compose.example.yml docker-compose.yml
|
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 --entrypoint php gitea.nonameweb.ch/nonameweb/sealshare:latest artisan key:generate --show
|
||||||
|
|
||||||
# Edit docker-compose.yml — set APP_KEY and APP_URL, and choose how HTTPS is served (below)
|
# Edit docker-compose.yml — set APP_KEY and APP_URL, and choose how HTTPS is served (below)
|
||||||
# Then start:
|
# Then start:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
use ZipStream\CompressionMethod;
|
use ZipStream\CompressionMethod;
|
||||||
|
use ZipStream\OperationMode;
|
||||||
use ZipStream\ZipStream;
|
use ZipStream\ZipStream;
|
||||||
|
|
||||||
class DownloadController extends Controller
|
class DownloadController extends Controller
|
||||||
@@ -23,89 +24,159 @@ class DownloadController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Download all files as a ZIP archive, streamed file by file as it is decrypted: stored without
|
* Download all files as a ZIP archive, streamed file by file as it is decrypted: stored without
|
||||||
* compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk.
|
* compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk. The
|
||||||
|
* archive's size is worked out from the files' sizes first, so the browser shows its progress
|
||||||
|
* and notices a download cut short. The private text is left out: it is only ever shown on the
|
||||||
|
* page.
|
||||||
*/
|
*/
|
||||||
public function download(Request $request, Share $share): StreamedResponse
|
public function download(Request $request, Share $share): StreamedResponse
|
||||||
{
|
{
|
||||||
abort_if(! $share->isCompleted() || $share->isExpired(), 404);
|
abort_if(! $share->isCompleted() || $share->isExpired(), 404);
|
||||||
|
|
||||||
$share->load('files');
|
$share->load(['files' => fn ($query) => $query->where('is_text', false)]);
|
||||||
$key = $this->resolveDecryptionKey($share);
|
abort_if($share->files->isEmpty(), 404);
|
||||||
|
|
||||||
|
$key = $this->shareService->sessionDecryptionKey($share, $request->session());
|
||||||
|
|
||||||
// Counted before the body streams: the session is saved by then.
|
// Counted before the body streams: the session is saved by then.
|
||||||
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
|
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
|
||||||
|
|
||||||
return new StreamedResponse(function () use ($share, $key): void {
|
$zip = new ZipStream(
|
||||||
$zip = new ZipStream(
|
operationMode: OperationMode::SIMULATE_STRICT,
|
||||||
defaultCompressionMethod: CompressionMethod::STORE,
|
defaultCompressionMethod: CompressionMethod::STORE,
|
||||||
defaultEnableZeroHeader: true,
|
defaultEnableZeroHeader: true,
|
||||||
sendHttpHeaders: false,
|
sendHttpHeaders: false,
|
||||||
flushOutput: true,
|
flushOutput: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($share->files as $file) {
|
||||||
|
$zip->addFileFromCallback(
|
||||||
|
fileName: $this->archiveName($file),
|
||||||
|
exactSize: $file->file_size,
|
||||||
|
callback: fn (): PumpStream => $this->decryptedStream($share, $file, $key),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($share->files as $file) {
|
$size = $zip->finish();
|
||||||
$chunks = $this->encryptionService->decryptedChunks(
|
|
||||||
Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)),
|
|
||||||
$key,
|
|
||||||
);
|
|
||||||
|
|
||||||
$zip->addFileFromPsr7Stream(fileName: $this->archiveName($file), stream: new PumpStream(function () use ($chunks): string|false {
|
return new StreamedResponse(function () use ($zip): void {
|
||||||
while ($chunks->valid() && $chunks->current() === '') {
|
// A large share takes longer than any request limit to send.
|
||||||
$chunks->next();
|
set_time_limit(0);
|
||||||
}
|
|
||||||
|
|
||||||
if (! $chunks->valid()) {
|
$zip->executeSimulation();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$chunk = $chunks->current();
|
|
||||||
$chunks->next();
|
|
||||||
|
|
||||||
return $chunk;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
$zip->finish();
|
|
||||||
}, 200, [
|
}, 200, [
|
||||||
'Content-Type' => 'application/zip',
|
'Content-Type' => 'application/zip',
|
||||||
|
'Content-Length' => $size,
|
||||||
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'),
|
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a single file.
|
* Download a single file; never the private text, which is only ever shown on the page. A
|
||||||
|
* `Range` request gets the bytes from where an interrupted download stopped, so the browser,
|
||||||
|
* or the page's folder download, resumes it instead of starting over.
|
||||||
*/
|
*/
|
||||||
public function downloadFile(Request $request, Share $share, ShareFile $shareFile): StreamedResponse
|
public function downloadFile(Request $request, Share $share, ShareFile $shareFile): StreamedResponse
|
||||||
{
|
{
|
||||||
abort_if(! $share->isCompleted() || $share->isExpired(), 404);
|
abort_if(! $share->isCompleted() || $share->isExpired(), 404);
|
||||||
abort_if($shareFile->share_id !== $share->id, 404);
|
abort_if($shareFile->share_id !== $share->id || $shareFile->is_text, 404);
|
||||||
|
|
||||||
$key = $this->resolveDecryptionKey($share);
|
$key = $this->shareService->sessionDecryptionKey($share, $request->session());
|
||||||
|
|
||||||
|
$size = $shareFile->file_size;
|
||||||
|
$etag = '"'.hash('xxh128', $shareFile->stored_path).'"';
|
||||||
|
[$start, $end] = $this->requestedRange($request, $size, $etag) ?? [0, $size - 1];
|
||||||
|
|
||||||
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
|
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
|
||||||
|
|
||||||
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path));
|
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path));
|
||||||
$mimeType = $shareFile->mime_type ?? 'application/octet-stream';
|
$partial = $start > 0 || $end < $size - 1;
|
||||||
|
|
||||||
$headers = [
|
$headers = [
|
||||||
'Content-Type' => $mimeType,
|
'Content-Type' => $shareFile->mime_type ?? 'application/octet-stream',
|
||||||
'Content-Disposition' => HeaderUtils::makeDisposition(
|
'Content-Disposition' => HeaderUtils::makeDisposition(
|
||||||
'attachment',
|
'attachment',
|
||||||
$shareFile->original_name,
|
$shareFile->original_name,
|
||||||
'download',
|
'download',
|
||||||
),
|
),
|
||||||
|
'Content-Length' => max($end - $start + 1, 0),
|
||||||
|
'Accept-Ranges' => 'bytes',
|
||||||
|
'ETag' => $etag,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($shareFile->file_size !== null) {
|
if ($partial) {
|
||||||
$headers['Content-Length'] = $shareFile->file_size;
|
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
|
||||||
}
|
}
|
||||||
|
|
||||||
return new StreamedResponse(function () use ($encryptedPath, $key): void {
|
return new StreamedResponse(function () use ($encryptedPath, $key, $start, $end): void {
|
||||||
foreach ($this->encryptionService->decryptedChunks($encryptedPath, $key) as $chunk) {
|
set_time_limit(0);
|
||||||
|
|
||||||
|
$remaining = $end - $start + 1;
|
||||||
|
|
||||||
|
foreach ($this->encryptionService->decryptedChunks($encryptedPath, $key, $start) as $chunk) {
|
||||||
|
if (strlen($chunk) >= $remaining) {
|
||||||
|
echo substr($chunk, 0, $remaining);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
echo $chunk;
|
echo $chunk;
|
||||||
flush();
|
flush();
|
||||||
|
$remaining -= strlen($chunk);
|
||||||
}
|
}
|
||||||
}, 200, $headers);
|
}, $partial ? 206 : 200, $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first and last byte a single `Range` request asks for, or null to send the whole file:
|
||||||
|
* without a range, with one this does not read (several ranges, another unit), or with an
|
||||||
|
* `If-Range` naming another version of the file. A range wholly past the end is refused.
|
||||||
|
*
|
||||||
|
* @return array{0: int, 1: int}|null
|
||||||
|
*/
|
||||||
|
private function requestedRange(Request $request, int $size, string $etag): ?array
|
||||||
|
{
|
||||||
|
$range = (string) $request->header('Range');
|
||||||
|
|
||||||
|
if ($size === 0 || ! preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches) || $matches[1].$matches[2] === ''
|
||||||
|
|| ($request->hasHeader('If-Range') && $request->header('If-Range') !== $etag)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$start, $end] = $matches[1] === ''
|
||||||
|
? [max($size - (int) $matches[2], 0), $size - 1]
|
||||||
|
: [(int) $matches[1], $matches[2] === '' ? $size - 1 : min((int) $matches[2], $size - 1)];
|
||||||
|
|
||||||
|
abort_if($start > $end, 416, headers: ['Content-Range' => "bytes */{$size}"]);
|
||||||
|
|
||||||
|
return [$start, $end];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file's decrypted content as a stream ZipStream reads from.
|
||||||
|
*/
|
||||||
|
private function decryptedStream(Share $share, ShareFile $file, string $key): PumpStream
|
||||||
|
{
|
||||||
|
$chunks = $this->encryptionService->decryptedChunks(
|
||||||
|
Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)),
|
||||||
|
$key,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new PumpStream(function () use ($chunks): string|false {
|
||||||
|
while ($chunks->valid() && $chunks->current() === '') {
|
||||||
|
$chunks->next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $chunks->valid()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$chunk = $chunks->current();
|
||||||
|
$chunks->next();
|
||||||
|
|
||||||
|
return $chunk;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,20 +193,4 @@ class DownloadController extends Controller
|
|||||||
|
|
||||||
return $filename;
|
return $filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the decryption key from session or share.
|
|
||||||
*/
|
|
||||||
private function resolveDecryptionKey(Share $share): string
|
|
||||||
{
|
|
||||||
if ($share->isPasswordProtected()) {
|
|
||||||
$key = session('share_key_'.$share->token);
|
|
||||||
|
|
||||||
abort_if(! $key, 403, 'Password required');
|
|
||||||
|
|
||||||
return $key;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->shareService->getDecryptionKey($share);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ class AdminDashboard extends Component
|
|||||||
// Shares whose files are still being uploaded are not shares yet; their bytes do count as used space.
|
// 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')
|
->whereNotNull('completed_at')
|
||||||
->withCount('files')
|
// A share's private text is not one of its files.
|
||||||
|
->withCount(['files' => fn ($query) => $query->where('is_text', false)])
|
||||||
|
->withExists('textFile')
|
||||||
// Shares that never expire come after every share that does, whichever way expiry is sorted.
|
// 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'))
|
->when($column === 'expires_at', fn ($query) => $query->orderByRaw('expires_at is null'))
|
||||||
->orderBy($column, $direction)
|
->orderBy($column, $direction)
|
||||||
@@ -74,7 +76,7 @@ class AdminDashboard extends Component
|
|||||||
})->where(function ($q) {
|
})->where(function ($q) {
|
||||||
$q->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads');
|
$q->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads');
|
||||||
})->count(),
|
})->count(),
|
||||||
'totalFiles' => ShareFile::query()->whereHas('share', fn ($query) => $query->whereNotNull('completed_at'))->count(),
|
'totalFiles' => ShareFile::query()->where('is_text', false)->whereHas('share', fn ($query) => $query->whereNotNull('completed_at'))->count(),
|
||||||
'usedSpace' => $shareService->getTotalUsedSpace(),
|
'usedSpace' => $shareService->getTotalUsedSpace(),
|
||||||
'maxQuota' => $shareService->getMaxStorageQuota(),
|
'maxQuota' => $shareService->getMaxStorageQuota(),
|
||||||
'version' => config('app.version'),
|
'version' => config('app.version'),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Livewire;
|
|||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Share;
|
use App\Models\Share;
|
||||||
|
use App\Models\ShareFile;
|
||||||
use App\Services\PasswordGeneratorService;
|
use App\Services\PasswordGeneratorService;
|
||||||
use App\Services\ShareService;
|
use App\Services\ShareService;
|
||||||
use Carbon\CarbonInterval;
|
use Carbon\CarbonInterval;
|
||||||
@@ -73,26 +74,50 @@ class FileUploader extends Component
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->pendingToken !== $shareFile->share->token) {
|
$this->rememberPendingShare($shareFile->share);
|
||||||
$this->pendingToken = $shareFile->share->token;
|
|
||||||
session()->push('pending_shares', $this->pendingToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
$header = $shareService->readHeader($shareFile);
|
$targets[] = $this->uploadTarget($shareFile, $shareService);
|
||||||
|
|
||||||
$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;
|
return $targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the private text as the share is created, in place of any text an earlier attempt
|
||||||
|
* registered, and hand the browser what it encrypts and sends it with. Only its size in UTF-8
|
||||||
|
* bytes reaches the server here: the text itself arrives encrypted, like a file. Nothing is
|
||||||
|
* registered for an empty text; a text an admin limit refuses gets `null` and the reason under
|
||||||
|
* `text`.
|
||||||
|
*
|
||||||
|
* @return array{id: int, url: string, key: string, noncePrefix: string, chunkSize: int, chunkCount: int}|null
|
||||||
|
*/
|
||||||
|
public function registerText(int $size, ShareService $shareService): ?array
|
||||||
|
{
|
||||||
|
$this->resetErrorBag('text');
|
||||||
|
|
||||||
|
$existingText = $this->pendingShare()?->textFile()->first();
|
||||||
|
|
||||||
|
if ($existingText !== null) {
|
||||||
|
$shareService->removeFile($existingText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$shareFile = $shareService->registerFile($this->pendingShare(), 'text.txt', $size, null, isText: true);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
$this->addError('text', $e->errors()['files'][0]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->rememberPendingShare($shareFile->share);
|
||||||
|
|
||||||
|
return $this->uploadTarget($shareFile, $shareService);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Take files out of the pending share, whether or not their upload finished.
|
* Take files out of the pending share, whether or not their upload finished.
|
||||||
*
|
*
|
||||||
@@ -153,7 +178,7 @@ class FileUploader extends Component
|
|||||||
$pendingShare = $this->pendingShare();
|
$pendingShare = $this->pendingShare();
|
||||||
|
|
||||||
if ($pendingShare === null) {
|
if ($pendingShare === null) {
|
||||||
$this->addError('files', __('Please select at least one file to upload.'));
|
$this->addError('files', __('Add files or a text to share.'));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -183,17 +208,48 @@ class FileUploader extends Component
|
|||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
$shareService = app(ShareService::class);
|
$shareService = app(ShareService::class);
|
||||||
$pendingFiles = $this->pendingShare()?->files()->orderBy('id')->get() ?? collect();
|
$pendingFiles = $this->pendingShare()?->files()->where('is_text', false)->orderBy('id')->get() ?? collect();
|
||||||
|
|
||||||
return view('livewire.file-uploader', [
|
return view('livewire.file-uploader', [
|
||||||
'pendingFiles' => $pendingFiles,
|
'pendingFiles' => $pendingFiles,
|
||||||
'allFilesUploaded' => $pendingFiles->isNotEmpty() && $pendingFiles->every(fn ($file): bool => $file->completed_at !== null),
|
'allFilesUploaded' => $pendingFiles->every(fn ($file): bool => $file->completed_at !== null),
|
||||||
|
'maxTextBytes' => ShareFile::MAX_TEXT_BYTES,
|
||||||
'isStorageFull' => $shareService->isStorageFull(),
|
'isStorageFull' => $shareService->isStorageFull(),
|
||||||
'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false),
|
'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false),
|
||||||
'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(),
|
'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a share the one this page uploads into, and let this session reach it.
|
||||||
|
*/
|
||||||
|
private function rememberPendingShare(Share $share): void
|
||||||
|
{
|
||||||
|
if ($this->pendingToken !== $share->token) {
|
||||||
|
$this->pendingToken = $share->token;
|
||||||
|
session()->push('pending_shares', $this->pendingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the browser encrypts and sends a registered file with.
|
||||||
|
*
|
||||||
|
* @return array{id: int, url: string, key: string, noncePrefix: string, chunkSize: int, chunkCount: int}
|
||||||
|
*/
|
||||||
|
private function uploadTarget(ShareFile $shareFile, ShareService $shareService): array
|
||||||
|
{
|
||||||
|
$header = $shareService->readHeader($shareFile);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This page's pending share, while it is still pending and this session started it.
|
* This page's pending share, while it is still pending and this session started it.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use App\Services\ShareService;
|
|||||||
use Carbon\CarbonInterval;
|
use Carbon\CarbonInterval;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Attributes\Renderless;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ class ShareDownload extends Component
|
|||||||
{
|
{
|
||||||
public Share $share;
|
public Share $share;
|
||||||
|
|
||||||
|
/** Set only by mount() and verifyPassword(): the browser must never unlock the file list. */
|
||||||
|
#[Locked]
|
||||||
public bool $authenticated = false;
|
public bool $authenticated = false;
|
||||||
|
|
||||||
#[Validate('required|string')]
|
#[Validate('required|string')]
|
||||||
@@ -60,11 +64,41 @@ class ShareDownload extends Component
|
|||||||
$this->authenticated = true;
|
$this->authenticated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The share's private text, for the recipient who pressed "Show text": counted as their
|
||||||
|
* download, as a file would be. Returned to the browser only, never kept in a property, so it
|
||||||
|
* is not in the component's snapshot. Null when the text is no longer available; no abort(),
|
||||||
|
* which would open Livewire's error modal. Renderless: a re-render would morph the download
|
||||||
|
* note Alpine just switched back into the one it hid.
|
||||||
|
*/
|
||||||
|
#[Renderless]
|
||||||
|
public function revealText(ShareService $shareService): ?string
|
||||||
|
{
|
||||||
|
$share = $this->share;
|
||||||
|
|
||||||
|
if (! $share->isCompleted() || $share->isExpired() || ! $share->hasText()
|
||||||
|
|| ($share->isPasswordProtected() && ! session('share_key_'.$share->token))
|
||||||
|
|| ! $shareService->claimDownload($share, session()->driver())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $shareService->readText($share, $shareService->sessionDecryptionKey($share, session()->driver()));
|
||||||
|
}
|
||||||
|
|
||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
$shareService = app(ShareService::class);
|
$shareService = app(ShareService::class);
|
||||||
|
$files = $this->share->files->where('is_text', false)->values();
|
||||||
|
|
||||||
return view('livewire.share-download', [
|
return view('livewire.share-download', [
|
||||||
|
'files' => $files,
|
||||||
|
'downloads' => $files->map(fn ($file): array => [
|
||||||
|
'id' => $file->id,
|
||||||
|
'path' => $file->relative_path ?: $file->original_name,
|
||||||
|
'size' => $file->file_size,
|
||||||
|
'url' => route('share.download.file', [$this->share, $file]),
|
||||||
|
]),
|
||||||
|
'hasText' => $this->share->files->contains('is_text', true),
|
||||||
'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()),
|
'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()),
|
||||||
'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null,
|
'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null,
|
||||||
'downloadWindow' => CarbonInterval::minutes(ShareService::DOWNLOAD_WINDOW_MINUTES)->cascade()->forHumans(),
|
'downloadWindow' => CarbonInterval::minutes(ShareService::DOWNLOAD_WINDOW_MINUTES)->cascade()->forHumans(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
class Share extends Model
|
class Share extends Model
|
||||||
{
|
{
|
||||||
@@ -62,6 +63,21 @@ class Share extends Model
|
|||||||
return $this->hasMany(ShareFile::class);
|
return $this->hasMany(ShareFile::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The share's private text, stored as one of its files.
|
||||||
|
*
|
||||||
|
* @return HasOne<ShareFile, $this>
|
||||||
|
*/
|
||||||
|
public function textFile(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(ShareFile::class)->where('is_text', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasText(): bool
|
||||||
|
{
|
||||||
|
return $this->textFile()->exists();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the share was created: until then its files are still being uploaded and nobody
|
* Whether the share was created: until then its files are still being uploaded and nobody
|
||||||
* but the uploader's page may reach it.
|
* but the uploader's page may reach it.
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ class ShareFile extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most a share's private text may hold, in UTF-8 bytes: one chunk of the upload pipeline.
|
||||||
|
*/
|
||||||
|
public const MAX_TEXT_BYTES = 100 * 1024;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'share_id',
|
'share_id',
|
||||||
'original_name',
|
'original_name',
|
||||||
@@ -17,6 +22,7 @@ class ShareFile extends Model
|
|||||||
'stored_path',
|
'stored_path',
|
||||||
'file_size',
|
'file_size',
|
||||||
'mime_type',
|
'mime_type',
|
||||||
|
'is_text',
|
||||||
'uploaded_chunks',
|
'uploaded_chunks',
|
||||||
'completed_at',
|
'completed_at',
|
||||||
];
|
];
|
||||||
@@ -28,6 +34,7 @@ class ShareFile extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'file_size' => 'integer',
|
'file_size' => 'integer',
|
||||||
|
'is_text' => 'boolean',
|
||||||
'uploaded_chunks' => 'integer',
|
'uploaded_chunks' => 'integer',
|
||||||
'completed_at' => 'datetime',
|
'completed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Actions\Fortify\ResetUserPassword;
|
use App\Actions\Fortify\ResetUserPassword;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
@@ -28,6 +30,12 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
private function configureActions(): void
|
private function configureActions(): void
|
||||||
{
|
{
|
||||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||||
|
|
||||||
|
// From APP_URL, never the request's host: every proxy is trusted, so a forged X-Forwarded-Host would otherwise pick it.
|
||||||
|
ResetPassword::createUrlUsing(fn (User $user, string $token): string => rtrim((string) config('app.url'), '/').route('password.reset', [
|
||||||
|
'token' => $token,
|
||||||
|
'email' => $user->getEmailForPasswordReset(),
|
||||||
|
], false));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -200,20 +200,35 @@ class FileEncryptionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The decrypted content of a file in any of the three formats, chunk by chunk.
|
* The decrypted content of a file in any of the three formats, chunk by chunk, from byte
|
||||||
|
* `$offset` of the plaintext on. A `SEALCHK2` file starts at the chunk holding that byte; the
|
||||||
|
* older formats are decrypted from their start and the bytes before it dropped.
|
||||||
*
|
*
|
||||||
* @return Generator<int, string>
|
* @return Generator<int, string>
|
||||||
*/
|
*/
|
||||||
public function decryptedChunks(string $encryptedPath, string $key): Generator
|
public function decryptedChunks(string $encryptedPath, string $key, int $offset = 0): Generator
|
||||||
{
|
{
|
||||||
$magic = (string) file_get_contents($encryptedPath, false, null, 0, 8);
|
$magic = (string) file_get_contents($encryptedPath, false, null, 0, 8);
|
||||||
|
|
||||||
if ($magic === self::MAGIC) {
|
if ($magic === self::MAGIC) {
|
||||||
yield from $this->decryptChunks($encryptedPath, $key);
|
yield from $this->decryptChunks($encryptedPath, $key, $offset);
|
||||||
} elseif ($magic === self::LEGACY_CHUNKED_MAGIC) {
|
|
||||||
yield from $this->decryptLegacyChunks($encryptedPath, $key);
|
return;
|
||||||
} else {
|
}
|
||||||
yield $this->decryptLegacy($encryptedPath, $key);
|
|
||||||
|
$chunks = $magic === self::LEGACY_CHUNKED_MAGIC
|
||||||
|
? $this->decryptLegacyChunks($encryptedPath, $key)
|
||||||
|
: [$this->decryptLegacy($encryptedPath, $key)];
|
||||||
|
|
||||||
|
foreach ($chunks as $chunk) {
|
||||||
|
if ($offset >= strlen($chunk)) {
|
||||||
|
$offset -= strlen($chunk);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield substr($chunk, $offset);
|
||||||
|
$offset = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +266,7 @@ class FileEncryptionService
|
|||||||
*
|
*
|
||||||
* @return Generator<int, string>
|
* @return Generator<int, string>
|
||||||
*/
|
*/
|
||||||
private function decryptChunks(string $encryptedPath, string $key): Generator
|
private function decryptChunks(string $encryptedPath, string $key, int $offset = 0): Generator
|
||||||
{
|
{
|
||||||
$handle = fopen($encryptedPath, 'rb');
|
$handle = fopen($encryptedPath, 'rb');
|
||||||
|
|
||||||
@@ -270,10 +285,16 @@ class FileEncryptionService
|
|||||||
throw new RuntimeException('Invalid encrypted file: no chunks');
|
throw new RuntimeException('Invalid encrypted file: no chunks');
|
||||||
}
|
}
|
||||||
|
|
||||||
for ($index = 0; $index < $chunkCount; $index++) {
|
$firstIndex = min(intdiv($offset, max($chunkSize, 1)), $chunkCount - 1);
|
||||||
$chunk = (string) fread($handle, $storedChunkSize);
|
$skip = $offset - $firstIndex * $chunkSize;
|
||||||
|
fseek($handle, $this->chunkOffset($firstIndex, $chunkSize));
|
||||||
|
|
||||||
yield $this->decryptChunk($chunk, $key, $noncePrefix, $index, $index === $chunkCount - 1);
|
for ($index = $firstIndex; $index < $chunkCount; $index++) {
|
||||||
|
$chunk = (string) fread($handle, $storedChunkSize);
|
||||||
|
$plaintext = $this->decryptChunk($chunk, $key, $noncePrefix, $index, $index === $chunkCount - 1);
|
||||||
|
|
||||||
|
yield $skip > 0 ? substr($plaintext, $skip) : $plaintext;
|
||||||
|
$skip = 0;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use Illuminate\Validation\ValidationException;
|
|||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use League\MimeTypeDetection\FinfoMimeTypeDetector;
|
use League\MimeTypeDetection\FinfoMimeTypeDetector;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A share's life: files registered into a pending share, their encrypted chunks stored as the
|
* A share's life: files registered into a pending share, their encrypted chunks stored as the
|
||||||
@@ -35,11 +36,13 @@ class ShareService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a file the uploader's browser is about to send, in the given pending share or in a
|
* 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.
|
* new one, and write its encrypted file's header. The share's private text is registered the
|
||||||
|
* same way, as `text.txt` with `$isText`: it is capped at ShareFile::MAX_TEXT_BYTES and does not
|
||||||
|
* count as one of the share's files.
|
||||||
*
|
*
|
||||||
* @throws ValidationException when the file breaks an admin limit
|
* @throws ValidationException when the file breaks an admin limit
|
||||||
*/
|
*/
|
||||||
public function registerFile(?Share $pendingShare, string $name, int $size, ?string $relativePath): ShareFile
|
public function registerFile(?Share $pendingShare, string $name, int $size, ?string $relativePath, bool $isText = false): ShareFile
|
||||||
{
|
{
|
||||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||||
@@ -49,7 +52,11 @@ class ShareService
|
|||||||
$this->rejectFile(__('The file could not be added.'));
|
$this->rejectFile(__('The file could not be added.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($size > $maxFileSize) {
|
if ($isText && ($size < 1 || $size > ShareFile::MAX_TEXT_BYTES)) {
|
||||||
|
$this->rejectFile(__('The text is too long (maximum 100 KB).'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $isText && $size > $maxFileSize) {
|
||||||
$this->rejectFile(__('":name" is too large (:size MB). Maximum file size is :max MB.', [
|
$this->rejectFile(__('":name" is too large (:size MB). Maximum file size is :max MB.', [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'size' => round($size / (1024 * 1024), 1),
|
'size' => round($size / (1024 * 1024), 1),
|
||||||
@@ -57,7 +64,7 @@ class ShareService
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pendingShare && $pendingShare->files()->count() >= $maxFilesPerShare) {
|
if (! $isText && $pendingShare && $pendingShare->files()->where('is_text', false)->count() >= $maxFilesPerShare) {
|
||||||
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +92,7 @@ class ShareService
|
|||||||
'relative_path' => $this->sanitizeRelativePath($relativePath),
|
'relative_path' => $this->sanitizeRelativePath($relativePath),
|
||||||
'stored_path' => 'shares/'.$share->token.'/'.$storedName,
|
'stored_path' => 'shares/'.$share->token.'/'.$storedName,
|
||||||
'file_size' => $size,
|
'file_size' => $size,
|
||||||
|
'is_text' => $isText,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$share->increment('total_size', $size);
|
$share->increment('total_size', $size);
|
||||||
@@ -192,14 +200,14 @@ class ShareService
|
|||||||
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
||||||
|
|
||||||
if ($files->isEmpty()) {
|
if ($files->isEmpty()) {
|
||||||
$this->rejectFile(__('Please select at least one file to upload.'));
|
$this->rejectFile(__('Add files or a text to share.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($files->contains(fn (ShareFile $file): bool => $file->completed_at === null)) {
|
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.'));
|
$this->rejectFile(__('Wait until every file has finished uploading, or remove the ones that failed.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($files->count() > $maxFilesPerShare) {
|
if ($files->where('is_text', false)->count() > $maxFilesPerShare) {
|
||||||
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,13 +231,13 @@ class ShareService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a share from files already on the server, through the same steps an upload from the
|
* Create a share from files already on the server, and optionally a private text, through the
|
||||||
* browser takes. Used by tests and demo data.
|
* 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<int, array{file: UploadedFile, relativePath: string|null}> $files
|
||||||
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
|
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
|
||||||
*/
|
*/
|
||||||
public function createShare(array $files, array $options = []): Share
|
public function createShare(array $files, array $options = [], ?string $text = null): Share
|
||||||
{
|
{
|
||||||
$share = null;
|
$share = null;
|
||||||
|
|
||||||
@@ -238,34 +246,92 @@ class ShareService
|
|||||||
$shareFile = $this->registerFile($share, $file->getClientOriginalName(), $file->getSize(), $fileData['relativePath'] ?? null);
|
$shareFile = $this->registerFile($share, $file->getClientOriginalName(), $file->getSize(), $fileData['relativePath'] ?? null);
|
||||||
$share = $shareFile->share;
|
$share = $shareFile->share;
|
||||||
|
|
||||||
$header = $this->readHeader($shareFile);
|
|
||||||
$source = fopen($file->getRealPath(), 'rb');
|
$source = fopen($file->getRealPath(), 'rb');
|
||||||
|
$this->storeContent($shareFile, $source);
|
||||||
|
fclose($source);
|
||||||
|
}
|
||||||
|
|
||||||
for ($index = 0; $index < $header['chunkCount']; $index++) {
|
if ($text !== null && $text !== '') {
|
||||||
$plaintextLength = min($header['chunkSize'], $shareFile->file_size - $index * $header['chunkSize']);
|
$shareFile = $this->registerFile($share, 'text.txt', strlen($text), null, isText: true);
|
||||||
|
$share = $shareFile->share;
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
$source = fopen('php://memory', 'r+b');
|
||||||
|
fwrite($source, $text);
|
||||||
|
rewind($source);
|
||||||
|
$this->storeContent($shareFile, $source);
|
||||||
fclose($source);
|
fclose($source);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($share === null) {
|
if ($share === null) {
|
||||||
$this->rejectFile(__('Please select at least one file to upload.'));
|
$this->rejectFile(__('Add files or a text to share.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->completeShare($share, $options);
|
return $this->completeShare($share, $options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a registered file's content chunk by chunk from a stream and store the chunks, as the
|
||||||
|
* uploader's browser would.
|
||||||
|
*
|
||||||
|
* @param resource $source
|
||||||
|
*/
|
||||||
|
private function storeContent(ShareFile $shareFile, $source): void
|
||||||
|
{
|
||||||
|
$share = $shareFile->share;
|
||||||
|
$header = $this->readHeader($shareFile);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A share's private text, decrypted in memory with the share's key; null when it has none.
|
||||||
|
*/
|
||||||
|
public function readText(Share $share, string $key): ?string
|
||||||
|
{
|
||||||
|
$textFile = $share->textFile()->first();
|
||||||
|
|
||||||
|
if ($textFile === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('', iterator_to_array(
|
||||||
|
$this->encryptionService->decryptedChunks($this->storedFilePath($textFile), $key),
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key this session decrypts a share with: the one unlocked with the password and kept in
|
||||||
|
* the session, or the stored one for a share without a password.
|
||||||
|
*
|
||||||
|
* @throws HttpException 403 while a password share is still locked for this session
|
||||||
|
*/
|
||||||
|
public function sessionDecryptionKey(Share $share, Session $session): string
|
||||||
|
{
|
||||||
|
if ($share->isPasswordProtected()) {
|
||||||
|
$key = $session->get('share_key_'.$share->token);
|
||||||
|
|
||||||
|
abort_if(! $key, 403, 'Password required');
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getDecryptionKey($share);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique share token with retry on collision.
|
* Generate a unique share token with retry on collision.
|
||||||
*/
|
*/
|
||||||
@@ -327,8 +393,10 @@ class ShareService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* When this session's download window for a share ends, or null while it has none open. The
|
* When this session's download window for a share ends, or null while it has none open. The
|
||||||
* window opens with the session's counted download; until it ends, the session may start more
|
* window opens with the session's counted download and each download started in it keeps it
|
||||||
* downloads of the share without counting them, even once the share has reached its limit.
|
* open, so downloading a large share file by file never outlasts it; until it ends, the session
|
||||||
|
* may start more downloads of the share without counting them, even once the share has
|
||||||
|
* reached its limit.
|
||||||
*/
|
*/
|
||||||
public function downloadWindowEndsAt(Share $share, Session $session): ?CarbonInterface
|
public function downloadWindowEndsAt(Share $share, Session $session): ?CarbonInterface
|
||||||
{
|
{
|
||||||
@@ -352,6 +420,8 @@ class ShareService
|
|||||||
public function claimDownload(Share $share, Session $session): bool
|
public function claimDownload(Share $share, Session $session): bool
|
||||||
{
|
{
|
||||||
if ($this->downloadWindowEndsAt($share, $session) !== null) {
|
if ($this->downloadWindowEndsAt($share, $session) !== null) {
|
||||||
|
$session->put($this->downloadSessionKey($share), now()->getTimestamp());
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +440,7 @@ class ShareService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The session key holding when this session's download of a share was counted.
|
* The session key holding when this session last started a download of a share.
|
||||||
*/
|
*/
|
||||||
private function downloadSessionKey(Share $share): string
|
private function downloadSessionKey(Share $share): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,6 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'version' => '2.2.0',
|
'version' => '2.4.0',
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,13 +8,19 @@ use Illuminate\Support\Str;
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
|
||||||
| Kept from earlier releases, where it differs from the framework's
|
| Kept from earlier releases, where it differs from the framework's
|
||||||
| "<app>_session": renaming the cookie would sign everyone out. Every
|
| "<app>_session": renaming the cookie would sign everyone out.
|
||||||
| other key comes from the framework's defaults.
|
|
|
||||||
|
| Sessions are encrypted unless SESSION_ENCRYPT=false: they hold the keys
|
||||||
|
| of the password shares a recipient unlocked, which must never sit
|
||||||
|
| readable in the sessions table. Every other key comes from the
|
||||||
|
| framework's defaults.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
|
'encrypt' => env('SESSION_ENCRYPT', true),
|
||||||
|
|
||||||
'cookie' => env(
|
'cookie' => env(
|
||||||
'SESSION_COOKIE',
|
'SESSION_COOKIE',
|
||||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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',
|
||||||
|
'is_text' => false,
|
||||||
'uploaded_chunks' => 1,
|
'uploaded_chunks' => 1,
|
||||||
'completed_at' => now(),
|
'completed_at' => now(),
|
||||||
];
|
];
|
||||||
@@ -41,4 +42,17 @@ class ShareFileFactory extends Factory
|
|||||||
'completed_at' => null,
|
'completed_at' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A share's private text.
|
||||||
|
*/
|
||||||
|
public function text(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'original_name' => 'text.txt',
|
||||||
|
'relative_path' => null,
|
||||||
|
'file_size' => fake()->numberBetween(1, 1024),
|
||||||
|
'is_text' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* A share's private text is stored as one of its files, encrypted like the others; this flag
|
||||||
|
* keeps it out of the file list, the ZIP and the file counts.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('share_files', function (Blueprint $table) {
|
||||||
|
$table->boolean('is_text')->default(false)->after('mime_type');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('share_files', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('is_text');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,12 +4,13 @@
|
|||||||
#
|
#
|
||||||
# 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 is required; uploads need HTTPS, see below)
|
# 2. Edit the settings below (APP_URL and APP_KEY are 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
|
||||||
#
|
#
|
||||||
# Note: APP_KEY is auto-generated on first start if not set.
|
# APP_KEY is required: the container does not start without it. Create one with
|
||||||
# Copy it from the logs into your docker-compose.yml to persist across restarts.
|
# docker run --rm --entrypoint php gitea.nonameweb.ch/nonameweb/sealshare:latest artisan key:generate --show
|
||||||
|
# and keep it: it decrypts the shares, so a new key makes the existing ones unreadable.
|
||||||
#
|
#
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ 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
|
||||||
# APP_KEY: # Auto-generated if not set. Copy from logs to persist across restarts.
|
APP_KEY: # Required. Create one with php artisan key:generate --show (see above)
|
||||||
DB_DATABASE: /app/database/sqlite/database.sqlite # The SQLite file in sealshare_database
|
DB_DATABASE: /app/database/sqlite/database.sqlite # The SQLite file in sealshare_database
|
||||||
|
|
||||||
# --- HTTPS ---
|
# --- HTTPS ---
|
||||||
@@ -90,7 +91,7 @@ services:
|
|||||||
- sealshare_storage:/app/storage/app
|
- sealshare_storage:/app/storage/app
|
||||||
- sealshare_database:/app/database/sqlite
|
- sealshare_database:/app/database/sqlite
|
||||||
environment:
|
environment:
|
||||||
# APP_KEY: # Same key as the app service above (auto-generated if not set)
|
APP_KEY: # Same key as the app service above
|
||||||
APP_URL: # Same URL as the app service above
|
APP_URL: # Same URL as the app service above
|
||||||
DB_DATABASE: /app/database/sqlite/database.sqlite # Same as the app service above
|
DB_DATABASE: /app/database/sqlite/database.sqlite # Same as the app service above
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ set -e
|
|||||||
|
|
||||||
cd /app
|
cd /app
|
||||||
|
|
||||||
# Auto-generate APP_KEY if not provided
|
# APP_KEY decrypts the shares, so it must stay the same across restarts: a key made up here would
|
||||||
|
# change on the next start and leave every existing share unreadable.
|
||||||
if [ -z "$APP_KEY" ]; then
|
if [ -z "$APP_KEY" ]; then
|
||||||
echo "[entrypoint] No APP_KEY set, generating one..."
|
echo "[entrypoint] APP_KEY is not set. Create one and set it for the app and the scheduler:" >&2
|
||||||
APP_KEY=$(php artisan key:generate --show)
|
echo "[entrypoint] docker run --rm --entrypoint php <this image> artisan key:generate --show" >&2
|
||||||
export APP_KEY
|
echo "[entrypoint] If this install ran without one before, use the key from its old logs" >&2
|
||||||
echo "[entrypoint] Generated APP_KEY: $APP_KEY"
|
echo "[entrypoint] (\"Generated APP_KEY: ...\") so its shares stay readable." >&2
|
||||||
echo "[entrypoint] WARNING: Set this APP_KEY in your docker-compose.yml to persist across restarts!"
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A docker-compose.yml from before 2.1.1 mounts the SQLite volume over all of /app/database, so the
|
# A docker-compose.yml from before 2.1.1 mounts the SQLite volume over all of /app/database, so the
|
||||||
|
|||||||
@@ -18,18 +18,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.150.0",
|
"version": "0.153.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
|
||||||
"integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
|
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/oxc-project"
|
"url": "https://github.com/sponsors/oxc-project"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
|
||||||
"integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==",
|
"integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -43,9 +43,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
|
||||||
"integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==",
|
"integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -59,9 +59,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
|
||||||
"integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==",
|
"integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -75,9 +75,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
|
||||||
"integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==",
|
"integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -91,9 +91,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
|
||||||
"integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
|
"integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -107,9 +107,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
|
||||||
"integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==",
|
"integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -123,9 +123,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
|
||||||
"integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==",
|
"integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -142,9 +142,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
|
||||||
"integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==",
|
"integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -161,9 +161,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
|
||||||
"integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==",
|
"integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -180,9 +180,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
|
||||||
"integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==",
|
"integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -199,9 +199,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
|
||||||
"integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==",
|
"integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -218,9 +218,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
|
||||||
"integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==",
|
"integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -237,9 +237,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
|
||||||
"integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==",
|
"integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -253,9 +253,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
|
||||||
"integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==",
|
"integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -269,9 +269,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
|
||||||
"integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==",
|
"integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -634,9 +634,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.19",
|
"version": "3.3.20",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -670,13 +670,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.63.0",
|
"version": "1.64.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
|
||||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.63.0"
|
"playwright-core": "1.64.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -686,9 +686,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.63.0",
|
"version": "1.64.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
|
||||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -699,9 +699,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.29",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
||||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -718,9 +718,9 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.18",
|
"nanoid": "^3.3.19",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
@@ -741,12 +741,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.2.9",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
|
||||||
"integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
|
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.150.0",
|
"@oxc-project/types": "=0.153.0",
|
||||||
"@rolldown/pluginutils": "^1.0.0"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -756,27 +756,27 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm-eabi": "1.2.9",
|
"@rolldown/binding-android-arm-eabi": "1.2.13",
|
||||||
"@rolldown/binding-android-arm64": "1.2.9",
|
"@rolldown/binding-android-arm64": "1.2.13",
|
||||||
"@rolldown/binding-darwin-arm64": "1.2.9",
|
"@rolldown/binding-darwin-arm64": "1.2.13",
|
||||||
"@rolldown/binding-darwin-x64": "1.2.9",
|
"@rolldown/binding-darwin-x64": "1.2.13",
|
||||||
"@rolldown/binding-freebsd-x64": "1.2.9",
|
"@rolldown/binding-freebsd-x64": "1.2.13",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.9",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.2.9",
|
"@rolldown/binding-linux-arm64-gnu": "1.2.13",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.2.9",
|
"@rolldown/binding-linux-arm64-musl": "1.2.13",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.9",
|
"@rolldown/binding-linux-ppc64-gnu": "1.2.13",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.2.9",
|
"@rolldown/binding-linux-s390x-gnu": "1.2.13",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.2.9",
|
"@rolldown/binding-linux-x64-gnu": "1.2.13",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.2.9",
|
"@rolldown/binding-linux-x64-musl": "1.2.13",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.2.9",
|
"@rolldown/binding-openharmony-arm64": "1.2.13",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.2.9",
|
"@rolldown/binding-win32-arm64-msvc": "1.2.13",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.2.9"
|
"@rolldown/binding-win32-x64-msvc": "1.2.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -799,15 +799,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
|
||||||
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.33.0",
|
"lightningcss": "^1.33.0",
|
||||||
"picomatch": "^4.0.7",
|
"picomatch": "^4.0.7",
|
||||||
"postcss": "^8.5.28",
|
"postcss": "^8.5.29",
|
||||||
"rolldown": "~1.2.6",
|
"rolldown": "~1.2.12",
|
||||||
"tinyglobby": "^0.2.17"
|
"tinyglobby": "^0.2.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -243,6 +243,29 @@
|
|||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/share-download.blade.php: a long file list scrolls on its own, as the
|
||||||
|
* upload page's does, so the download buttons stay near the top of the page. It is the page's main
|
||||||
|
* content, so it may take more of the window.
|
||||||
|
*/
|
||||||
|
.share-file-list {
|
||||||
|
max-block-size: min(32rem, 60dvh);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* resources/views/livewire/share-download.blade.php: the private text once the recipient shows it,
|
||||||
|
* as it was typed — its line breaks kept and a long word or key wrapped rather than overflowing.
|
||||||
|
*/
|
||||||
|
.share-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
padding: var(--md-sys-measurement-space200);
|
||||||
|
border-radius: var(--md-sys-shape-corner-md);
|
||||||
|
background-color: var(--md-sys-color-surface-container-highest);
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* resources/views/pages/settings/two-factor.blade.php: the setup QR code. Fortify's own
|
* 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
|
* twoFactorQrCodeSvg() draws no quiet zone, so the SVG comes from App\Services\QrCodeService
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
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'
|
import './share-uploader.js'
|
||||||
|
import './share-downloader.js'
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* `shareDownloader`: the download page's file list. It remembers, in this browser, which files the
|
||||||
|
* recipient has downloaded, and where the browser can write to a folder (the File System Access
|
||||||
|
* API: Chrome and Edge on a computer) it downloads every file into the folder the recipient picks,
|
||||||
|
* one at a time, in its folder path.
|
||||||
|
*
|
||||||
|
* One request per file keeps every request short enough for a proxy in front of the server. A
|
||||||
|
* request that breaks off is retried after 1, 2, 4, 8 and 16 seconds from the byte the file on
|
||||||
|
* disk has reached (an HTTP Range request); a file already there at its full size is skipped, so
|
||||||
|
* running it again after a stop picks up where it stopped. A file that still fails is listed for
|
||||||
|
* the Retry button; a share that is gone (404) stops the run.
|
||||||
|
*/
|
||||||
|
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||||
|
|
||||||
|
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
let unit = 0
|
||||||
|
|
||||||
|
while (bytes >= 1024 && unit < UNITS.length - 1) {
|
||||||
|
bytes /= 1024
|
||||||
|
unit++
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${bytes.toFixed(unit === 0 ? 0 : 1)} ${UNITS[unit]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStored(key) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(window.localStorage.getItem(key)) ?? []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The file's handle under the picked folder, creating its subfolders; never a path out of it. */
|
||||||
|
async function fileHandle(directory, path) {
|
||||||
|
const segments = path.split('/').filter((segment) => segment !== '' && segment !== '.' && segment !== '..')
|
||||||
|
const name = segments.pop()
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
directory = await directory.getDirectoryHandle(segment, { create: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return directory.getFileHandle(name, { create: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
window.Alpine.data('shareDownloader', ({ token, files, messages }) => {
|
||||||
|
const storageKey = `sealshare.downloaded.${token}`
|
||||||
|
const total = files.reduce((sum, file) => sum + file.size, 0)
|
||||||
|
|
||||||
|
// Kept out of Alpine's reactive data: a proxied native handle throws on its own methods.
|
||||||
|
let directory = null
|
||||||
|
let controller = null
|
||||||
|
|
||||||
|
return {
|
||||||
|
supported: window.isSecureContext && 'showDirectoryPicker' in window,
|
||||||
|
|
||||||
|
/** The ids of the files this browser has downloaded. */
|
||||||
|
done: readStored(storageKey),
|
||||||
|
|
||||||
|
running: false,
|
||||||
|
|
||||||
|
/** Bytes of the run's files on disk, for the progress bar. */
|
||||||
|
received: 0,
|
||||||
|
|
||||||
|
/** How many of the run's files are in the folder. */
|
||||||
|
saved: 0,
|
||||||
|
|
||||||
|
/** The file on its way: its path for the status line, its id for its row in the list. */
|
||||||
|
current: null,
|
||||||
|
|
||||||
|
currentId: null,
|
||||||
|
|
||||||
|
/** The files that failed, kept for the Retry button. */
|
||||||
|
failed: [],
|
||||||
|
|
||||||
|
/** Why the run ended early, if it did. */
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
finished: false,
|
||||||
|
|
||||||
|
get progress() {
|
||||||
|
return total === 0 ? 100 : (this.received / total) * 100
|
||||||
|
},
|
||||||
|
|
||||||
|
get status() {
|
||||||
|
return messages.progress
|
||||||
|
.replace(':saved', this.saved)
|
||||||
|
.replace(':count', files.length)
|
||||||
|
.replace(':received', formatSize(this.received))
|
||||||
|
.replace(':total', formatSize(total))
|
||||||
|
},
|
||||||
|
|
||||||
|
isDone(id) {
|
||||||
|
return this.done.includes(id)
|
||||||
|
},
|
||||||
|
|
||||||
|
markDone(id) {
|
||||||
|
if (this.done.includes(id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.done.push(id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(this.done))
|
||||||
|
} catch {
|
||||||
|
// Private mode or storage blocked: the marks last as long as the page.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadAll() {
|
||||||
|
try {
|
||||||
|
directory = await window.showDirectoryPicker({ id: 'sealshare', mode: 'readwrite' })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.run(files)
|
||||||
|
},
|
||||||
|
|
||||||
|
retry() {
|
||||||
|
this.run(this.failed)
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
controller?.abort()
|
||||||
|
},
|
||||||
|
|
||||||
|
async run(list) {
|
||||||
|
controller = new AbortController()
|
||||||
|
this.running = true
|
||||||
|
this.finished = false
|
||||||
|
this.error = null
|
||||||
|
this.failed = []
|
||||||
|
this.saved = files.length - list.length
|
||||||
|
this.received = total - list.reduce((sum, file) => sum + file.size, 0)
|
||||||
|
this.downloaded = true
|
||||||
|
|
||||||
|
for (const file of list) {
|
||||||
|
const before = this.received
|
||||||
|
this.current = file.path
|
||||||
|
this.currentId = file.id
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.save(file, controller.signal)
|
||||||
|
this.saved++
|
||||||
|
this.markDone(file.id)
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.status === 404) {
|
||||||
|
this.error = messages.gone
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
this.failed.push(file)
|
||||||
|
this.received = before + file.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = false
|
||||||
|
this.current = null
|
||||||
|
this.currentId = null
|
||||||
|
this.finished = ! controller.signal.aborted
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(file, signal) {
|
||||||
|
const handle = await fileHandle(directory, file.path)
|
||||||
|
const before = this.received
|
||||||
|
// ponytail: a file is matched by name and size only, so a different file of the same name and size already in the picked folder is kept; compare a checksum if that ever bites.
|
||||||
|
let written = (await handle.getFile()).size
|
||||||
|
|
||||||
|
if (written > file.size) {
|
||||||
|
written = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; written < file.size; attempt++) {
|
||||||
|
try {
|
||||||
|
written = await this.fetchInto(handle, file, written, signal, (bytes) => (this.received = before + bytes))
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted || error.status === 404 || attempt >= RETRY_DELAYS.length) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt]))
|
||||||
|
written = (await handle.getFile()).size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.received = before + file.size
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Write the file from byte `offset` on; the bytes that arrived are kept even when the connection breaks. */
|
||||||
|
async fetchInto(handle, file, offset, signal, onProgress) {
|
||||||
|
const response = await fetch(file.url, {
|
||||||
|
headers: offset > 0 ? { Range: `bytes=${offset}-` } : {},
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (! response.ok) {
|
||||||
|
throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 206) {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const writable = await handle.createWritable({ keepExistingData: offset > 0 })
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writable.truncate(offset)
|
||||||
|
await writable.seek(offset)
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
await writable.write(value)
|
||||||
|
offset += value.length
|
||||||
|
onProgress(offset)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Closing commits what was written, so a retry continues from there.
|
||||||
|
await writable.close().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset < file.size) {
|
||||||
|
throw new Error('The download ended early')
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset
|
||||||
|
},
|
||||||
|
|
||||||
|
warnBeforeLeaving(event) {
|
||||||
|
if (this.running) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
* A failed request is retried after 1, 2, 4, 8 and 16 seconds; after that the file waits for its
|
* 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
|
* 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.
|
* with its own count when a chunk skips ahead, and acknowledges a chunk it already has.
|
||||||
|
*
|
||||||
|
* The private text stays in this component (Alpine only, never a Livewire property) until the share
|
||||||
|
* is created: `submit()` registers its size, sends it through the same queue as a file named
|
||||||
|
* text.txt, and only then asks the component to create the share.
|
||||||
*/
|
*/
|
||||||
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||||
|
|
||||||
@@ -33,6 +37,14 @@ document.addEventListener('alpine:init', () => {
|
|||||||
/** The request on its way, so Cancel and Remove can abort it. */
|
/** The request on its way, so Cancel and Remove can abort it. */
|
||||||
request: null,
|
request: null,
|
||||||
|
|
||||||
|
/** The private text, as typed. */
|
||||||
|
text: '',
|
||||||
|
|
||||||
|
/** The private text's size as the server counts it, in UTF-8 bytes. */
|
||||||
|
get textBytes() {
|
||||||
|
return new TextEncoder().encode(this.text).length
|
||||||
|
},
|
||||||
|
|
||||||
get progress() {
|
get progress() {
|
||||||
const unfinished = Object.values(this.uploads).filter((upload) => upload.state !== 'failed')
|
const unfinished = Object.values(this.uploads).filter((upload) => upload.state !== 'failed')
|
||||||
const size = unfinished.reduce((total, upload) => total + upload.size, 0)
|
const size = unfinished.reduce((total, upload) => total + upload.size, 0)
|
||||||
@@ -41,7 +53,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
choose(event) {
|
choose(event) {
|
||||||
this.add([...event.target.files].map((file) => ({ file, path: null })))
|
// A chosen folder's files carry their path from it, as a dropped folder's do.
|
||||||
|
this.add([...event.target.files].map((file) => ({ file, path: file.webkitRelativePath || null })))
|
||||||
|
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
},
|
},
|
||||||
@@ -223,6 +236,43 @@ document.addEventListener('alpine:init', () => {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the private text, if there is one, then create the share. The text replaces any the
|
||||||
|
* server kept from an earlier attempt, so what is shared is what the field holds now.
|
||||||
|
*/
|
||||||
|
async submit() {
|
||||||
|
if (this.busy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = this.text.trim() === '' ? '' : this.text
|
||||||
|
const target = await this.$wire.registerText(text === '' ? 0 : this.textBytes)
|
||||||
|
|
||||||
|
if (text !== '') {
|
||||||
|
if (! target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = { id: target.id, file: new File([text], 'text.txt', { type: 'text/plain' }), target, nextIndex: 0 }
|
||||||
|
|
||||||
|
this.uploads[target.id] = { state: 'queued', sent: 0, size: item.file.size }
|
||||||
|
this.queue.push(item)
|
||||||
|
await this.run()
|
||||||
|
|
||||||
|
const sent = this.uploads[target.id]?.state === 'uploaded'
|
||||||
|
|
||||||
|
this.forget([target.id])
|
||||||
|
|
||||||
|
if (! sent) {
|
||||||
|
window.materialToast(messages.textFailed, { type: 'error' })
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$wire.createShare()
|
||||||
|
},
|
||||||
|
|
||||||
retry(id) {
|
retry(id) {
|
||||||
const item = this.failed[id]
|
const item = this.failed[id]
|
||||||
|
|
||||||
@@ -283,7 +333,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
warnBeforeLeaving(event) {
|
warnBeforeLeaving(event) {
|
||||||
if (this.busy) {
|
if (this.busy || this.text !== '') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.returnValue = ''
|
event.returnValue = ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a>
|
<a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a>
|
||||||
|
|
||||||
<x-slot:description>
|
<x-slot:description>
|
||||||
<span class="admin-share-detail md-tabular">{{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ $share->max_downloads ? trans_choice(':count of :max download|:count of :max downloads', $share->max_downloads, ['count' => $share->download_count, 'max' => $share->max_downloads]) : trans_choice(':count download|:count downloads', $share->download_count) }}</span>
|
<span class="admin-share-detail md-tabular">{{ collect([$share->files_count || ! $share->text_file_exists ? trans_choice(':count file|:count files', $share->files_count) : null, $share->text_file_exists ? __('Text') : null])->filter()->implode(' · ') }} · {{ Number::fileSize($share->total_size) }} · {{ $share->max_downloads ? trans_choice(':count of :max download|:count of :max downloads', $share->max_downloads, ['count' => $share->download_count, 'max' => $share->max_downloads]) : trans_choice(':count download|:count downloads', $share->download_count) }}</span>
|
||||||
|
|
||||||
{{-- A share at its limit is closed; the cleanup deletes it a day after its last download. --}}
|
{{-- A share at its limit is closed; the cleanup deletes it a day after its last download. --}}
|
||||||
@if ($share->hasReachedDownloadLimit())
|
@if ($share->hasReachedDownloadLimit())
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
@if ($isStorageFull && $pendingFiles->isEmpty())
|
@if ($isStorageFull && $pendingFiles->isEmpty())
|
||||||
<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
|
||||||
|
{{-- Submitting sends the private text first (submit(), resources/js/share-uploader.js), then creates the share. --}}
|
||||||
<x-form
|
<x-form
|
||||||
wire:submit="createShare"
|
x-on:submit.prevent="submit()"
|
||||||
x-data="shareUploader({
|
x-data="shareUploader({
|
||||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||||
messages: {{ \Illuminate\Support\Js::from([
|
messages: {{ \Illuminate\Support\Js::from([
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
'uploaded' => __('Uploaded'),
|
'uploaded' => __('Uploaded'),
|
||||||
'failed' => __('Upload failed'),
|
'failed' => __('Upload failed'),
|
||||||
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
|
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
|
||||||
|
'textFailed' => __('The text could not be sent. Try again.'),
|
||||||
]) }},
|
]) }},
|
||||||
})"
|
})"
|
||||||
x-on:beforeunload.window="warnBeforeLeaving($event)"
|
x-on:beforeunload.window="warnBeforeLeaving($event)"
|
||||||
@@ -43,9 +45,14 @@
|
|||||||
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
|
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
|
||||||
</x-stack>
|
</x-stack>
|
||||||
|
|
||||||
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}}
|
{{-- Each button is a tab stop and opens the browser's own picker, for files or for a folder with
|
||||||
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
|
everything in it; the inputs only carry the selection. --}}
|
||||||
|
<x-row gap="space100" justify="center" wrap>
|
||||||
|
<x-button :label="__('Browse Files')" icon="upload_file" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
|
||||||
|
<x-button :label="__('Browse Folder')" icon="folder_open" variant="outlined" x-on:click="$refs.folderPicker.click()" x-bind:disabled="! secure" data-test="browse-folder" />
|
||||||
|
</x-row>
|
||||||
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
|
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
|
||||||
|
<input type="file" webkitdirectory hidden x-ref="folderPicker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="folder-input" />
|
||||||
</x-stack>
|
</x-stack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,6 +102,30 @@
|
|||||||
</x-stack>
|
</x-stack>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- Private text: bound in Alpine only, never to the component, so it never reaches the server
|
||||||
|
unencrypted; it is sent with the share (submit()). The limit is in UTF-8 bytes, as the server counts. --}}
|
||||||
|
<x-card :title="__('Private text')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space100">
|
||||||
|
<x-textarea
|
||||||
|
full
|
||||||
|
x-model="text"
|
||||||
|
:label="__('Text')"
|
||||||
|
:maxlength="$maxTextBytes"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
data-test="share-text"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="md-type-body-sm md-tabular" x-bind:class="textBytes > {{ $maxTextBytes }} ? 'md-ink-error' : 'md-ink-variant'" data-test="share-text-size">
|
||||||
|
<span x-text="textBytes"></span> {{ __('bytes') }} / {{ Number::fileSize($maxTextBytes) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@error('text')
|
||||||
|
<x-alert color="error">{{ $message }}</x-alert>
|
||||||
|
@enderror
|
||||||
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
|
||||||
{{-- Options --}}
|
{{-- Options --}}
|
||||||
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
|
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
|
||||||
<x-stack gap="space200">
|
<x-stack gap="space200">
|
||||||
@@ -148,7 +179,7 @@
|
|||||||
size="md"
|
size="md"
|
||||||
icon="link"
|
icon="link"
|
||||||
spinner="createShare"
|
spinner="createShare"
|
||||||
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }}"
|
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }} || ({{ $pendingFiles->isEmpty() ? 'true' : 'false' }} && text.trim() === '') || textBytes > {{ $maxTextBytes }}"
|
||||||
data-test="create-share"
|
data-test="create-share"
|
||||||
/>
|
/>
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<x-page :title="__('Share Created!')" :description="__('Your files are ready to share')">
|
<x-page :title="__('Share Created!')" :description="__('Your share is ready')">
|
||||||
{{-- 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 (share-ready, app.css). --}}
|
||||||
<x-slot:mark>
|
<x-slot:mark>
|
||||||
<div class="share-check">
|
<div class="share-check">
|
||||||
@@ -73,10 +73,14 @@
|
|||||||
</x-stack>
|
</x-stack>
|
||||||
|
|
||||||
<x-grid :columns="2" gap="space200">
|
<x-grid :columns="2" gap="space200">
|
||||||
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
|
<x-stat :title="__('Files')" :value="$share->files->where('is_text', false)->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" />
|
||||||
|
|
||||||
|
@if ($share->hasText())
|
||||||
|
<x-stat :title="__('Private text')" :value="Number::fileSize($share->textFile->file_size)" icon="notes" />
|
||||||
|
@endif
|
||||||
</x-grid>
|
</x-grid>
|
||||||
|
|
||||||
@if ($share->isPasswordProtected())
|
@if ($share->isPasswordProtected())
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
iOS before Safari 18.4, which cannot position them. --}}
|
iOS before Safari 18.4, which cannot position them. --}}
|
||||||
|
|
||||||
<x-page brand>
|
<x-page brand>
|
||||||
{{-- Each state is one card under the page's h1: the card holds everything the recipient acts
|
{{-- Under the page's h1, what the recipient acts on is in cards: the password, or the share's
|
||||||
on, and it is the shape SealShare has always shown them. --}}
|
private text and its files, each in its own card, with the share's expiry and limit under them. --}}
|
||||||
@if (! $authenticated)
|
@if (! $authenticated)
|
||||||
<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 open this share')" heading="h2" variant="outlined">
|
||||||
<x-form wire:submit="verifyPassword">
|
<x-form wire:submit="verifyPassword">
|
||||||
<x-password
|
<x-password
|
||||||
full
|
full
|
||||||
@@ -22,32 +22,160 @@
|
|||||||
</x-form>
|
</x-form>
|
||||||
</x-card>
|
</x-card>
|
||||||
@else
|
@else
|
||||||
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
|
{{-- A download link does not render the page again: the download limit's note switches on
|
||||||
{{-- A download link does not render the page again: the download limit's note switches
|
the first press of any download or "Show text", and the server draws the open window on
|
||||||
on the first press here, and the server draws the open window on the next visit. --}}
|
the next visit. --}}
|
||||||
<x-stack gap="space200" x-data="{ downloaded: false }">
|
<x-stack gap="space200" x-data="{ downloaded: false }">
|
||||||
<x-stack gap="space100">
|
{{-- The text is fetched only when the recipient asks for it, never with the page: a link
|
||||||
<x-list :label="__('Shared Files')">
|
preview must not use up a share limited to one download. It stays in Alpine and is
|
||||||
@foreach ($share->files as $file)
|
drawn as plain text. --}}
|
||||||
<x-list-item
|
@if ($hasText)
|
||||||
:title="$file->relative_path ?: $file->original_name"
|
<x-card :title="__('Private text')" heading="h2" variant="outlined">
|
||||||
icon="description"
|
<x-stack
|
||||||
wire:key="file-{{ $file->id }}"
|
gap="space200"
|
||||||
>
|
x-data="{
|
||||||
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
|
text: null,
|
||||||
<x-slot:end>
|
async reveal() {
|
||||||
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true" />
|
const text = await $wire.revealText();
|
||||||
</x-slot:end>
|
|
||||||
</x-list-item>
|
|
||||||
@endforeach
|
|
||||||
</x-list>
|
|
||||||
|
|
||||||
|
if (text === null) {
|
||||||
|
window.materialToast({{ \Illuminate\Support\Js::from(__('This text is no longer available.')) }}, { type: 'error' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.text = text;
|
||||||
|
downloaded = true;
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template x-if="text !== null">
|
||||||
|
<x-stack gap="space200">
|
||||||
|
<div class="share-text md-type-body-lg" x-text="text" data-test="shared-text"></div>
|
||||||
|
|
||||||
|
<x-row justify="end">
|
||||||
|
<x-button
|
||||||
|
:label="__('Copy')"
|
||||||
|
icon="content_copy"
|
||||||
|
variant="tonal"
|
||||||
|
x-on:click="navigator.clipboard.writeText(text).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
|
||||||
|
data-test="copy-text"
|
||||||
|
/>
|
||||||
|
</x-row>
|
||||||
|
</x-stack>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<x-row justify="end" x-show="text === null">
|
||||||
|
<x-button :label="__('Show text')" icon="visibility" variant="filled" x-on:click="reveal()" spinner="revealText" data-test="show-text" />
|
||||||
|
</x-row>
|
||||||
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($files->isNotEmpty())
|
||||||
|
{{-- Which files this browser has downloaded is remembered per share (resources/js/share-downloader.js),
|
||||||
|
and where the browser can write to a folder, "Download all to a folder" fetches them one at a
|
||||||
|
time, each resumable, which a single ZIP of a large share is not. --}}
|
||||||
|
<x-card
|
||||||
|
:title="__('Shared Files')"
|
||||||
|
:subtitle="trans_choice('{1} :count file, :size|[2,*] :count files, :size', $files->count(), ['size' => Number::fileSize($files->sum('file_size'))])"
|
||||||
|
heading="h2"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<x-stack
|
||||||
|
gap="space200"
|
||||||
|
x-data="shareDownloader({
|
||||||
|
token: {{ \Illuminate\Support\Js::from($share->token) }},
|
||||||
|
files: {{ \Illuminate\Support\Js::from($downloads) }},
|
||||||
|
messages: {{ \Illuminate\Support\Js::from([
|
||||||
|
'progress' => __(':saved of :count files, :received of :total'),
|
||||||
|
'gone' => __('This share is no longer available.'),
|
||||||
|
]) }},
|
||||||
|
})"
|
||||||
|
x-on:beforeunload.window="warnBeforeLeaving($event)"
|
||||||
|
>
|
||||||
|
<div class="share-file-list" data-test="share-file-list">
|
||||||
|
<x-list :label="__('Shared Files')">
|
||||||
|
@foreach ($files as $file)
|
||||||
|
<x-list-item
|
||||||
|
:title="$file->relative_path ?: $file->original_name"
|
||||||
|
icon="description"
|
||||||
|
wire:key="file-{{ $file->id }}"
|
||||||
|
>
|
||||||
|
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
|
||||||
|
<x-slot:end>
|
||||||
|
<x-row gap="space100">
|
||||||
|
{{-- The file the folder download is on; its row, always shown, is brought into view in the list as it starts. --}}
|
||||||
|
<span
|
||||||
|
x-show="currentId === {{ $file->id }}"
|
||||||
|
x-effect="if (currentId === {{ $file->id }}) $el.closest('[data-md-list-item]').scrollIntoView({ block: 'nearest' })"
|
||||||
|
x-cloak
|
||||||
|
data-test="file-downloading"
|
||||||
|
>
|
||||||
|
<x-loading size="24" :label="__('Downloading')" />
|
||||||
|
</span>
|
||||||
|
<span x-show="isDone({{ $file->id }}) && currentId !== {{ $file->id }}" x-cloak data-test="file-downloaded">
|
||||||
|
<x-icon name="download_done" class="md-ink-success" :label="__('Downloaded')" />
|
||||||
|
</span>
|
||||||
|
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true; markDone({{ $file->id }})" />
|
||||||
|
</x-row>
|
||||||
|
</x-slot:end>
|
||||||
|
</x-list-item>
|
||||||
|
@endforeach
|
||||||
|
</x-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="running || finished" x-cloak data-test="folder-download">
|
||||||
|
<x-stack gap="space100">
|
||||||
|
<x-row justify="between">
|
||||||
|
<span class="md-type-label-lg md-tabular" x-text="status"></span>
|
||||||
|
<x-button :label="__('Cancel')" size="xs" x-show="running" x-on:click="cancel()" />
|
||||||
|
</x-row>
|
||||||
|
<x-progress bind="progress" :label="__('Downloading')" />
|
||||||
|
<p class="md-type-body-sm md-ink-variant" x-show="running" x-text="current"></p>
|
||||||
|
<p class="md-type-body-sm md-ink-success" x-show="finished && failed.length === 0">{{ __('All files are in the folder.') }}</p>
|
||||||
|
</x-stack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="error" x-cloak>
|
||||||
|
<x-alert color="error" assertive><span x-text="error"></span></x-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="! running && failed.length > 0" x-cloak>
|
||||||
|
<x-alert color="error" assertive>
|
||||||
|
{{ __('Files that could not be downloaded:') }} <span class="md-tabular" x-text="failed.length"></span>
|
||||||
|
<x-slot:actions><x-button :label="__('Retry')" x-on:click="retry()" /></x-slot:actions>
|
||||||
|
</x-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-row justify="end" gap="space100" wrap x-show="! running">
|
||||||
|
@if ($files->count() > 1)
|
||||||
|
<x-button :label="__('Download All as ZIP')" icon="folder_zip" variant="tonal" :link="route('share.download.all', $share)" no-wire-navigate x-show="supported" x-cloak x-on:click="downloaded = true" />
|
||||||
|
<x-button :label="__('Download all to a folder')" icon="download" variant="filled" x-show="supported" x-cloak x-on:click="downloadAll()" data-test="download-to-folder" />
|
||||||
|
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-show="! supported" x-on:click="downloaded = true" />
|
||||||
|
@else
|
||||||
|
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $files->first()])" no-wire-navigate x-on:click="downloaded = true; markDone({{ $files->first()->id }})" />
|
||||||
|
@endif
|
||||||
|
</x-row>
|
||||||
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($share->expires_at || $share->max_downloads)
|
||||||
|
<x-stack gap="space100">
|
||||||
@if ($share->expires_at)
|
@if ($share->expires_at)
|
||||||
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
|
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($share->max_downloads)
|
@if ($share->max_downloads)
|
||||||
@if ($downloadWindowEndsAt)
|
@if ($hasText)
|
||||||
|
@if ($downloadWindowEndsAt)
|
||||||
|
<p class="md-type-body-sm md-ink-variant">{{ __('You can open this share for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
|
||||||
|
@elseif ($remainingDownloads > 0)
|
||||||
|
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Showing the text or downloading uses the last remaining download. You then have :window to open this share.|[2,*] Showing the text or downloading uses 1 of :count remaining downloads. You then have :window to open this share.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
|
||||||
|
<p class="md-type-body-sm md-ink-variant" x-show="downloaded" x-cloak>{{ __('You have :window to open this share.', ['window' => $downloadWindow]) }}</p>
|
||||||
|
@endif
|
||||||
|
@elseif ($downloadWindowEndsAt)
|
||||||
<p class="md-type-body-sm md-ink-variant">{{ __('You can download these files for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
|
<p class="md-type-body-sm md-ink-variant">{{ __('You can download these files for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
|
||||||
@elseif ($remainingDownloads > 0)
|
@elseif ($remainingDownloads > 0)
|
||||||
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Downloading uses the last remaining download. You then have :window to download the files.|[2,*] Downloading uses 1 of :count remaining downloads. You then have :window to download the files.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
|
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Downloading uses the last remaining download. You then have :window to download the files.|[2,*] Downloading uses 1 of :count remaining downloads. You then have :window to download the files.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
|
||||||
@@ -55,15 +183,7 @@
|
|||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</x-stack>
|
</x-stack>
|
||||||
|
@endif
|
||||||
<x-row justify="end">
|
</x-stack>
|
||||||
@if ($share->files->count() > 1)
|
|
||||||
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-on:click="downloaded = true" />
|
|
||||||
@else
|
|
||||||
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate x-on:click="downloaded = true" />
|
|
||||||
@endif
|
|
||||||
</x-row>
|
|
||||||
</x-stack>
|
|
||||||
</x-card>
|
|
||||||
@endif
|
@endif
|
||||||
</x-page>
|
</x-page>
|
||||||
|
|||||||
@@ -175,6 +175,28 @@ test('the settings Save button is end-aligned at less than the form\'s width', f
|
|||||||
['/admin/settings', '[data-test="save-settings"]'],
|
['/admin/settings', '[data-test="save-settings"]'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
test('a recipient reveals a share\'s private text on a phone, and the page has no popovers', function () {
|
||||||
|
$page = ready(visit('/upload'));
|
||||||
|
|
||||||
|
$page->type('[data-test="share-text"]', 'the wifi password is sunflower')
|
||||||
|
->click('[data-test="create-share"]');
|
||||||
|
$page->wait(1);
|
||||||
|
$page->assertSee('Share Created!');
|
||||||
|
|
||||||
|
$shareUrl = $page->script('document.querySelector(\'[data-test="share-link"]\').value');
|
||||||
|
|
||||||
|
$page = ready(visit($shareUrl)->resize(393, 852));
|
||||||
|
|
||||||
|
$page->click('[data-test="show-text"]');
|
||||||
|
$page->wait(1);
|
||||||
|
$page->assertSeeIn('[data-test="shared-text"]', 'the wifi password is sunflower');
|
||||||
|
|
||||||
|
$page
|
||||||
|
->assertScript("document.querySelectorAll('[popover]').length === 0")
|
||||||
|
->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
test('the admin dashboard heads its shares list with an h2, both in one card', function () {
|
test('the admin dashboard heads its shares list with an h2, both in one card', function () {
|
||||||
$admin = User::factory()->admin()->create();
|
$admin = User::factory()->admin()->create();
|
||||||
Share::factory()->count(2)->create();
|
Share::factory()->count(2)->create();
|
||||||
@@ -220,3 +242,111 @@ test('the admin settings page has six sections, each a card headed by an h2', fu
|
|||||||
expect($result['cardCount'])->toBe(6);
|
expect($result['cardCount'])->toBe(6);
|
||||||
expect($result['everyCardHeaded'])->toBeTrue();
|
expect($result['everyCardHeaded'])->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('download all to a folder writes every file in its folder path, resumes a partial file and marks them downloaded', function () {
|
||||||
|
config(['uploads.chunk_size' => 1000]);
|
||||||
|
$binary = random_bytes(2500);
|
||||||
|
$share = app(ShareService::class)->createShare([
|
||||||
|
['file' => UploadedFile::fake()->createWithContent('notes.txt', 'hello folder download'), 'relativePath' => null],
|
||||||
|
['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => 'holiday/photo.bin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$page = ready(visit(route('share.download', $share, false)));
|
||||||
|
|
||||||
|
// The browser's private file system stands in for the folder the recipient would pick; notes.txt
|
||||||
|
// is already half there, as a stopped run leaves it.
|
||||||
|
$page->script("(async () => {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
for await (const name of root.keys()) { await root.removeEntry(name, { recursive: true }); }
|
||||||
|
const partial = await (await root.getFileHandle('notes.txt', { create: true })).createWritable();
|
||||||
|
await partial.write('hello ');
|
||||||
|
await partial.close();
|
||||||
|
window.showDirectoryPicker = () => Promise.resolve(root);
|
||||||
|
Alpine.\$data(document.querySelector('[data-test=download-to-folder]')).supported = true;
|
||||||
|
})()");
|
||||||
|
|
||||||
|
$page->click('[data-test="download-to-folder"]');
|
||||||
|
$page->assertScript("Alpine.\$data(document.querySelector('[data-test=folder-download]')).finished === true");
|
||||||
|
|
||||||
|
$files = $page->script("(async () => {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const notes = await (await root.getFileHandle('notes.txt')).getFile();
|
||||||
|
const photo = await (await (await root.getDirectoryHandle('holiday')).getFileHandle('photo.bin')).getFile();
|
||||||
|
const bytes = new Uint8Array(await photo.arrayBuffer());
|
||||||
|
return { notes: await notes.text(), photo: btoa(String.fromCharCode(...bytes)) };
|
||||||
|
})()");
|
||||||
|
|
||||||
|
expect($files['notes'])->toBe('hello folder download');
|
||||||
|
expect(base64_decode($files['photo']))->toBe($binary);
|
||||||
|
$page->assertScript("[...document.querySelectorAll('[data-test=file-downloaded]')].filter((mark) => mark.offsetParent !== null).length === 2")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Browse Folder picks a whole folder and keeps each file\'s path in it', function () {
|
||||||
|
$page = ready(visit('/upload'));
|
||||||
|
|
||||||
|
$page->assertScript("document.querySelector('[data-test=folder-input]').webkitdirectory === true");
|
||||||
|
|
||||||
|
// A folder picker's files, as the browser hands them over: each with its path from the folder.
|
||||||
|
$page->script("(() => {
|
||||||
|
const file = new File(['inside the folder'], 'notes.txt', { type: 'text/plain' });
|
||||||
|
Object.defineProperty(file, 'webkitRelativePath', { value: 'holiday/docs/notes.txt' });
|
||||||
|
Alpine.\$data(document.querySelector('[data-test=drop-zone]')).choose({ target: { files: [file], value: '' } });
|
||||||
|
})()");
|
||||||
|
|
||||||
|
$page->assertSee('holiday/docs/notes.txt')
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long file list scrolls on its own and keeps the download buttons on the first screen', function () {
|
||||||
|
$share = app(ShareService::class)->createShare(collect(range(1, 40))->map(fn (int $number): array => [
|
||||||
|
'file' => UploadedFile::fake()->createWithContent("file-{$number}.txt", "file {$number}"),
|
||||||
|
'relativePath' => null,
|
||||||
|
])->all());
|
||||||
|
|
||||||
|
$page = ready(visit(route('share.download', $share, false))->resize(1280, 800));
|
||||||
|
|
||||||
|
$page->assertScript("(() => {
|
||||||
|
const list = document.querySelector('[data-test=share-file-list]');
|
||||||
|
return list.scrollHeight > list.clientHeight && getComputedStyle(list).overflowY === 'auto';
|
||||||
|
})()")
|
||||||
|
->assertScript("[...document.querySelectorAll('[data-md-button]')].filter((button) => button.offsetParent !== null && button.textContent.trim().toLowerCase().startsWith('download all')).every((button) => button.getBoundingClientRect().bottom <= window.innerHeight)")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the folder download marks the file it is on and scrolls the list to it', function () {
|
||||||
|
$share = app(ShareService::class)->createShare(collect(range(1, 40))->map(fn (int $number): array => [
|
||||||
|
'file' => UploadedFile::fake()->createWithContent("file-{$number}.txt", "file {$number}"),
|
||||||
|
'relativePath' => null,
|
||||||
|
])->all());
|
||||||
|
$last = $share->files->last();
|
||||||
|
|
||||||
|
$page = ready(visit(route('share.download', $share, false))->resize(1280, 800));
|
||||||
|
|
||||||
|
// Every file but the last is already in the folder, and the last one's request never answers,
|
||||||
|
// so the run stays on it.
|
||||||
|
$page->script("(async () => {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
for await (const name of root.keys()) { await root.removeEntry(name, { recursive: true }); }
|
||||||
|
for (let number = 1; number < 40; number++) {
|
||||||
|
const writable = await (await root.getFileHandle(`file-\${number}.txt`, { create: true })).createWritable();
|
||||||
|
await writable.write(`file \${number}`);
|
||||||
|
await writable.close();
|
||||||
|
}
|
||||||
|
window.showDirectoryPicker = () => Promise.resolve(root);
|
||||||
|
window.fetch = () => new Promise(() => {});
|
||||||
|
Alpine.\$data(document.querySelector('[data-test=download-to-folder]')).supported = true;
|
||||||
|
})()");
|
||||||
|
|
||||||
|
$page->click('[data-test="download-to-folder"]');
|
||||||
|
|
||||||
|
$page->assertScript("[...document.querySelectorAll('[data-test=file-downloading]')].filter((mark) => mark.offsetParent !== null).length === 1")
|
||||||
|
->assertScript("(() => {
|
||||||
|
const mark = [...document.querySelectorAll('[data-test=file-downloading]')].find((mark) => mark.offsetParent !== null);
|
||||||
|
const row = mark.closest('[data-md-list-item]');
|
||||||
|
const list = document.querySelector('[data-test=share-file-list]').getBoundingClientRect();
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
return row.textContent.includes('{$last->original_name}') && rect.top >= list.top - 1 && rect.bottom <= list.bottom + 1;
|
||||||
|
})()")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|||||||
@@ -139,6 +139,18 @@ test('without shares the dashboard shows an empty state instead of the list', fu
|
|||||||
->assertDontSee('data-test="share-row"', false);
|
->assertDontSee('data-test="share-row"', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the shares list excludes the private text from its file count and shows it separately', function () {
|
||||||
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
$share = Share::factory()->create(['token' => 'textshare00000001']);
|
||||||
|
ShareFile::factory()->count(2)->for($share)->create();
|
||||||
|
ShareFile::factory()->for($share)->text()->create();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(AdminDashboard::class)
|
||||||
|
->assertSeeInOrder(['textshare00000001', '2 files', 'Text'])
|
||||||
|
->assertDontSee('3 files');
|
||||||
|
});
|
||||||
|
|
||||||
test('shares whose files are still uploading are neither listed nor counted, but their bytes count as used space', function () {
|
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();
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]);
|
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]);
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ test('reset password link can be requested', function () {
|
|||||||
Notification::assertSentTo($user, ResetPassword::class);
|
Notification::assertSentTo($user, ResetPassword::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the reset link points at APP_URL, whatever host the request claims', function () {
|
||||||
|
Notification::fake();
|
||||||
|
config(['app.url' => 'https://share.example.com']);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->withHeader('X-Forwarded-Host', 'attacker.example')
|
||||||
|
->post(route('password.request'), ['email' => $user->email]);
|
||||||
|
|
||||||
|
Notification::assertSentTo($user, ResetPassword::class, function (ResetPassword $notification) use ($user) {
|
||||||
|
return str_starts_with($notification->toMail($user)->actionUrl, 'https://share.example.com/reset-password/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('reset password screen can be rendered', function () {
|
test('reset password screen can be rendered', function () {
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,70 @@ test('removing files takes them out of the pending share', function () {
|
|||||||
expect(Share::query()->sole()->total_size)->toBe(4);
|
expect(Share::query()->sole()->total_size)->toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registering the private text hands the browser its chunk target, like a file', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$component = Livewire::test(FileUploader::class);
|
||||||
|
|
||||||
|
$component->call('registerText', 20)
|
||||||
|
->assertReturned(fn (?array $target): bool => $target !== null);
|
||||||
|
|
||||||
|
$file = ShareFile::query()->sole();
|
||||||
|
expect($file->is_text)->toBeTrue();
|
||||||
|
expect($file->file_size)->toBe(20);
|
||||||
|
expect(session('pending_shares'))->toBe([$file->share->token]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registering the private text again replaces the earlier text row', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$component = Livewire::test(FileUploader::class)
|
||||||
|
->call('registerText', 20);
|
||||||
|
$firstId = ShareFile::query()->sole()->id;
|
||||||
|
|
||||||
|
$component->call('registerText', 30);
|
||||||
|
|
||||||
|
$file = ShareFile::query()->sole();
|
||||||
|
expect($file->id)->not->toBe($firstId);
|
||||||
|
expect($file->file_size)->toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registering the private text with 0 bytes removes it', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$component = Livewire::test(FileUploader::class)
|
||||||
|
->call('registerText', 20);
|
||||||
|
|
||||||
|
$component->call('registerText', 0)
|
||||||
|
->assertReturned(fn (?array $target): bool => $target === null);
|
||||||
|
|
||||||
|
expect(ShareFile::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a text-only pending share completes', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$component = Livewire::test(FileUploader::class)
|
||||||
|
->call('registerText', 20);
|
||||||
|
$file = ShareFile::query()->sole();
|
||||||
|
app(ShareService::class)->storeChunk($file, 0, encryptedChunk($file, str_repeat('a', 20), 0, true));
|
||||||
|
|
||||||
|
$component->call('createShare')
|
||||||
|
->assertRedirectContains('/share/');
|
||||||
|
|
||||||
|
$share = Share::query()->sole();
|
||||||
|
expect($share->isCompleted())->toBeTrue();
|
||||||
|
expect($share->files->first()->is_text)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the selected files list does not show the private text', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$component = Livewire::test(FileUploader::class);
|
||||||
|
uploadThroughPage($component, ['document.pdf' => 'the document']);
|
||||||
|
|
||||||
|
$component->call('registerText', 20);
|
||||||
|
|
||||||
|
$component->assertSeeHtml('data-test="selected-file"')
|
||||||
|
->assertSee('document.pdf')
|
||||||
|
->assertDontSee('text.txt');
|
||||||
|
});
|
||||||
|
|
||||||
test('a share cannot be created while a file is still uploading', function () {
|
test('a share cannot be created while a file is still uploading', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
$component = Livewire::test(FileUploader::class)
|
$component = Livewire::test(FileUploader::class)
|
||||||
@@ -297,7 +361,7 @@ test('file upload requires at least one file', function () {
|
|||||||
$component = Livewire::test(FileUploader::class)
|
$component = Livewire::test(FileUploader::class)
|
||||||
->call('createShare');
|
->call('createShare');
|
||||||
|
|
||||||
expect($component->errors()->first('files'))->toBe('Please select at least one file to upload.');
|
expect($component->errors()->first('files'))->toBe('Add files or a text to share.');
|
||||||
expect(Share::query()->count())->toBe(0);
|
expect(Share::query()->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Services\ShareService;
|
|||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
// --- Session stores derived key, not plaintext password ---
|
// --- Session stores derived key, not plaintext password ---
|
||||||
@@ -35,6 +36,22 @@ test('session stores derived encryption key instead of plaintext password', func
|
|||||||
expect(strlen(session('share_key_'.$share->token)))->toBe(64);
|
expect(strlen(session('share_key_'.$share->token)))->toBe(64);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- The password gate cannot be opened from the browser ---
|
||||||
|
|
||||||
|
test('a visitor without the password cannot unlock a share\'s file list from the browser', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
|
||||||
|
$share = app(ShareService::class)->createShare([
|
||||||
|
['file' => UploadedFile::fake()->createWithContent('salary-review.pdf', 'secret'), 'relativePath' => null],
|
||||||
|
], [
|
||||||
|
'password' => 'test-password-secure',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ShareDownload::class, ['share' => $share])
|
||||||
|
->assertDontSee('salary-review.pdf')
|
||||||
|
->set('authenticated', true);
|
||||||
|
})->throws(CannotUpdateLockedPropertyException::class);
|
||||||
|
|
||||||
// --- Rate limiting on password verification ---
|
// --- Rate limiting on password verification ---
|
||||||
|
|
||||||
test('rate limiting blocks after 5 failed password attempts', function () {
|
test('rate limiting blocks after 5 failed password attempts', function () {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
use App\Models\Share;
|
use App\Models\Share;
|
||||||
use App\Services\QrCodeService;
|
use App\Services\QrCodeService;
|
||||||
|
use App\Services\ShareService;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Crypt;
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
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();
|
||||||
@@ -60,3 +63,20 @@ test('the password is not shown without a flash for this share', function (?stri
|
|||||||
'no flash (a reload or another visitor)' => [null],
|
'no flash (a reload or another visitor)' => [null],
|
||||||
'a flash for another share' => ['another-share-token'],
|
'a flash for another share' => ['another-share-token'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
test('the Private text stat shows for a share with text, and the Files stat excludes it', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = app(ShareService::class)->createShare(
|
||||||
|
[
|
||||||
|
['file' => UploadedFile::fake()->createWithContent('one.txt', 'one'), 'relativePath' => null],
|
||||||
|
['file' => UploadedFile::fake()->createWithContent('two.txt', 'two'), 'relativePath' => null],
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
'the secret note',
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->get(route('share.created', $share));
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertSeeInOrder(['Files', '2', 'Private text']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ test('share download page renders for valid share', function () {
|
|||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('share download page says how many files the share holds and their size', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = createShareWithFiles();
|
||||||
|
|
||||||
|
$response = $this->get(route('share.download', $share));
|
||||||
|
|
||||||
|
$response->assertSee('3 files, 31 B');
|
||||||
|
});
|
||||||
|
|
||||||
test('share download page returns 404 for expired share', function () {
|
test('share download page returns 404 for expired share', function () {
|
||||||
$share = Share::factory()->expired()->create();
|
$share = Share::factory()->expired()->create();
|
||||||
|
|
||||||
@@ -100,7 +109,10 @@ test('zip download streams a valid archive with every file\'s original content',
|
|||||||
]);
|
]);
|
||||||
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
|
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
|
||||||
|
|
||||||
file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent());
|
$response = $this->get(route('share.download.all', $share));
|
||||||
|
file_put_contents($zipPath, $content = $response->streamedContent());
|
||||||
|
|
||||||
|
$response->assertHeader('Content-Length', strlen($content));
|
||||||
|
|
||||||
$zip = new ZipArchive;
|
$zip = new ZipArchive;
|
||||||
expect($zip->open($zipPath))->toBeTrue();
|
expect($zip->open($zipPath))->toBeTrue();
|
||||||
@@ -111,6 +123,46 @@ test('zip download streams a valid archive with every file\'s original content',
|
|||||||
unlink($zipPath);
|
unlink($zipPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a range request gets the file from that byte on, decrypted across chunks', function (string $range, int $start, int $end) {
|
||||||
|
Storage::fake('shares');
|
||||||
|
config(['uploads.chunk_size' => 1000]);
|
||||||
|
$binary = random_bytes(2500);
|
||||||
|
$share = app(ShareService::class)->createShare([
|
||||||
|
['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => $range]);
|
||||||
|
|
||||||
|
$response->assertStatus(206)
|
||||||
|
->assertHeader('Content-Range', "bytes {$start}-{$end}/2500")
|
||||||
|
->assertHeader('Content-Length', $end - $start + 1);
|
||||||
|
expect($response->streamedContent())->toBe(substr($binary, $start, $end - $start + 1));
|
||||||
|
})->with([
|
||||||
|
'to the end' => ['bytes=1500-', 1500, 2499],
|
||||||
|
'across a chunk boundary' => ['bytes=999-1000', 999, 1000],
|
||||||
|
'the last bytes' => ['bytes=-10', 2490, 2499],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('a range request for another version of the file gets the whole file', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = createShareWithFile(content: 'whole file');
|
||||||
|
|
||||||
|
$response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => 'bytes=6-', 'If-Range' => '"other"']);
|
||||||
|
|
||||||
|
$response->assertOk()->assertHeader('Accept-Ranges', 'bytes');
|
||||||
|
expect($response->streamedContent())->toBe('whole file');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a range past the end of the file is refused and uses no download', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = createShareWithFile(content: 'short');
|
||||||
|
|
||||||
|
$response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => 'bytes=5-']);
|
||||||
|
|
||||||
|
$response->assertStatus(416)->assertHeader('Content-Range', 'bytes */5');
|
||||||
|
expect($share->fresh()->download_count)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('the last download streams and the share stays for the cleanup, with the time of that download', function () {
|
test('the last download streams and the share stays for the cleanup, with the time of that download', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
$this->freezeSecond();
|
$this->freezeSecond();
|
||||||
@@ -170,6 +222,20 @@ test('the recipient who took the last download can no longer open the share an h
|
|||||||
'download one file' => 'share.download.file',
|
'download one file' => 'share.download.file',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
test('each download keeps the recipient\'s window open', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = createShareWithFiles(maxDownloads: 1);
|
||||||
|
$this->get(route('share.download.file', [$share, $share->files->first()]));
|
||||||
|
$this->travel(50)->minutes();
|
||||||
|
$this->get(route('share.download.file', [$share, $share->files->first()]));
|
||||||
|
$this->travel(50)->minutes();
|
||||||
|
|
||||||
|
$response = $this->get(route('share.download.file', [$share, $share->files->last()]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
expect($share->fresh()->download_count)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('a recipient whose window has ended uses another download when one is left', function () {
|
test('a recipient whose window has ended uses another download when one is left', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
$share = createShareWithFiles(maxDownloads: 2);
|
$share = createShareWithFiles(maxDownloads: 2);
|
||||||
@@ -290,6 +356,107 @@ test('a password share created before key wrapping still unlocks and downloads',
|
|||||||
expect($this->get(route('share.download.file', [$share, $file]))->streamedContent())->toBe('old content');
|
expect($this->get(route('share.download.file', [$share, $file]))->streamedContent())->toBe('old content');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a text-only share page shows Show text and keeps the plaintext out of the response', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = app(ShareService::class)->createShare([], [], 'the secret note');
|
||||||
|
|
||||||
|
$response = $this->get(route('share.download', $share));
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertSeeHtml('data-test="show-text"')
|
||||||
|
->assertDontSee('the secret note');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revealText returns the private text and counts one download', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = app(ShareService::class)->createShare([], [], 'the secret note');
|
||||||
|
|
||||||
|
Livewire::test(ShareDownload::class, ['share' => $share])
|
||||||
|
->call('revealText')
|
||||||
|
->assertReturned('the secret note');
|
||||||
|
|
||||||
|
expect($share->fresh()->download_count)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second reveal in the same window is not counted again', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = app(ShareService::class)->createShare([], [], 'the secret note');
|
||||||
|
$component = Livewire::test(ShareDownload::class, ['share' => $share]);
|
||||||
|
$component->call('revealText');
|
||||||
|
|
||||||
|
$component->call('revealText')
|
||||||
|
->assertReturned('the secret note');
|
||||||
|
|
||||||
|
expect($share->fresh()->download_count)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('another session cannot open a text-only share whose one download was taken', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = app(ShareService::class)->createShare([], ['max_downloads' => 1], 'the secret note');
|
||||||
|
Livewire::test(ShareDownload::class, ['share' => $share])->call('revealText');
|
||||||
|
$this->flushSession();
|
||||||
|
|
||||||
|
$response = $this->get(route('share.download', $share));
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a password share\'s revealText returns null before unlocking and counts nothing', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = app(ShareService::class)->createShare([], ['password' => 'let-me-in'], 'the secret note');
|
||||||
|
|
||||||
|
Livewire::test(ShareDownload::class, ['share' => $share])
|
||||||
|
->call('revealText')
|
||||||
|
->assertReturned(null);
|
||||||
|
|
||||||
|
expect($share->fresh()->download_count)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('share.download.file for the text row returns 404', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = app(ShareService::class)->createShare(
|
||||||
|
[['file' => UploadedFile::fake()->createWithContent('notes.txt', 'file content'), 'relativePath' => null]],
|
||||||
|
[],
|
||||||
|
'the secret note',
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->get(route('share.download.file', [$share, $share->textFile]));
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the zip of a mixed share has only the files, not the private text', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = app(ShareService::class)->createShare(
|
||||||
|
[['file' => UploadedFile::fake()->createWithContent('notes.txt', 'file content'), 'relativePath' => null]],
|
||||||
|
[],
|
||||||
|
'the secret note',
|
||||||
|
);
|
||||||
|
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
|
||||||
|
|
||||||
|
$response = $this->get(route('share.download.all', $share));
|
||||||
|
file_put_contents($zipPath, $content = $response->streamedContent());
|
||||||
|
|
||||||
|
$response->assertHeader('Content-Length', strlen($content));
|
||||||
|
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
expect($zip->open($zipPath))->toBeTrue();
|
||||||
|
expect($zip->numFiles)->toBe(1);
|
||||||
|
expect($zip->getFromName('notes.txt'))->toBe('file content');
|
||||||
|
expect($zip->locateName('text.txt'))->toBeFalse();
|
||||||
|
$zip->close();
|
||||||
|
unlink($zipPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('share.download.all on a text-only share returns 404', function () {
|
||||||
|
Storage::fake('shares');
|
||||||
|
$share = app(ShareService::class)->createShare([], [], 'the secret note');
|
||||||
|
|
||||||
|
$response = $this->get(route('share.download.all', $share));
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to create a share with an actual encrypted file.
|
* Helper to create a share with an actual encrypted file.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ test('every file the pages, their stylesheets and the README refer to exists', f
|
|||||||
test('the screenshots are all there, for both themes and both widths', function () {
|
test('the screenshots are all there, for both themes and both widths', function () {
|
||||||
$expected = collect([
|
$expected = collect([
|
||||||
'desktop' => ['01-upload', '02-share-created', '03-qr-code', '04-download', '05-admin-dashboard', '06-admin-settings'],
|
'desktop' => ['01-upload', '02-share-created', '03-qr-code', '04-download', '05-admin-dashboard', '06-admin-settings'],
|
||||||
'phone' => ['01-upload', '02-password', '03-download', '04-qr-code'],
|
'phone' => ['01-upload', '02-password', '03-download', '04-qr-code', '05-private-text'],
|
||||||
])->flatMap(fn (array $names, string $device): array => collect(['light', 'dark'])
|
])->flatMap(fn (array $names, string $device): array => collect(['light', 'dark'])
|
||||||
->crossJoin($names, $device === 'desktop' ? [1600, 800] : [1080, 540])
|
->crossJoin($names, $device === 'desktop' ? [1600, 800] : [1080, 540])
|
||||||
->map(fn (array $shot): string => "{$device}/{$shot[0]}/{$shot[1]}-{$shot[2]}.webp")
|
->map(fn (array $shot): string => "{$device}/{$shot[0]}/{$shot[1]}-{$shot[2]}.webp")
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ final class DemoData
|
|||||||
/** The share the desktop upload creates, so its link and QR code read the same on every run. */
|
/** The share the desktop upload creates, so its link and QR code read the same on every run. */
|
||||||
public const CREATED_TOKEN = 'Tf8gH2jK4mN6pQ3r';
|
public const CREATED_TOKEN = 'Tf8gH2jK4mN6pQ3r';
|
||||||
|
|
||||||
|
/** The share holding only a private text, shown on the phone once the recipient asks for it. */
|
||||||
|
public const TEXT_TOKEN = 'Vx9bN4mQ7kR2tW5s';
|
||||||
|
|
||||||
public static function admin(): User
|
public static function admin(): User
|
||||||
{
|
{
|
||||||
return User::factory()->admin()->create([
|
return User::factory()->admin()->create([
|
||||||
@@ -36,13 +39,13 @@ final class DemoData
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eight shares of different ages, sizes and states: one expired, two password-protected, some
|
* Nine shares of different ages, sizes and states: one expired, two password-protected, some
|
||||||
* with a download limit, some never expiring.
|
* with a download limit, some never expiring, and one holding only a private text.
|
||||||
*/
|
*/
|
||||||
public static function shares(): void
|
public static function shares(): void
|
||||||
{
|
{
|
||||||
$shares = [
|
$shares = [
|
||||||
['token' => self::DELIVERY_TOKEN, 'daysAgo' => 0, 'expiresAfterDays' => 7, 'downloads' => 3, 'options' => [],
|
['token' => self::DELIVERY_TOKEN, 'daysAgo' => 0, 'expiresAfterDays' => 7, 'downloads' => 3, 'options' => ['max_downloads' => 5],
|
||||||
'files' => ['Q3 Report.pdf' => 2400, 'Contract 2026.pdf' => 380, 'Product photos/hero-shot.jpg' => 4800, 'Product photos/detail.jpg' => 3900]],
|
'files' => ['Q3 Report.pdf' => 2400, 'Contract 2026.pdf' => 380, 'Product photos/hero-shot.jpg' => 4800, 'Product photos/detail.jpg' => 3900]],
|
||||||
['token' => self::PROTECTED_TOKEN, 'daysAgo' => 1, 'expiresAfterDays' => 3, 'downloads' => 1, 'options' => ['password' => self::PROTECTED_PASSWORD, 'max_downloads' => 3],
|
['token' => self::PROTECTED_TOKEN, 'daysAgo' => 1, 'expiresAfterDays' => 3, 'downloads' => 1, 'options' => ['password' => self::PROTECTED_PASSWORD, 'max_downloads' => 3],
|
||||||
'files' => ['Payroll September.xlsx' => 210, 'Tax statement 2025.pdf' => 640]],
|
'files' => ['Payroll September.xlsx' => 210, 'Tax statement 2025.pdf' => 640]],
|
||||||
@@ -58,6 +61,8 @@ final class DemoData
|
|||||||
'files' => ['Onboarding/Welcome.pdf' => 520, 'Onboarding/Handbook.pdf' => 2900, 'Onboarding/IT checklist.docx' => 130]],
|
'files' => ['Onboarding/Welcome.pdf' => 520, 'Onboarding/Handbook.pdf' => 2900, 'Onboarding/IT checklist.docx' => 130]],
|
||||||
['token' => 'Ae6fG7hJ8kL9mN1p', 'daysAgo' => 40, 'expiresAfterDays' => 10, 'downloads' => 12, 'options' => [],
|
['token' => 'Ae6fG7hJ8kL9mN1p', 'daysAgo' => 40, 'expiresAfterDays' => 10, 'downloads' => 12, 'options' => [],
|
||||||
'files' => ['Event photos.zip' => 15700]],
|
'files' => ['Event photos.zip' => 15700]],
|
||||||
|
['token' => self::TEXT_TOKEN, 'daysAgo' => 3, 'expiresAfterDays' => 7, 'downloads' => 0, 'options' => ['max_downloads' => 1],
|
||||||
|
'files' => [], 'text' => "Guest Wi-Fi for the workshop\n\nNetwork: Harbour-Guest\nPassword: maple-lantern-8127\n\nIt works in meeting rooms 2 and 3."],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($shares as $share) {
|
foreach ($shares as $share) {
|
||||||
@@ -69,7 +74,7 @@ final class DemoData
|
|||||||
* @param array{password?: string, max_downloads?: int} $options
|
* @param array{password?: string, max_downloads?: int} $options
|
||||||
* @param array<string, int> $files relative path => size in kilobytes
|
* @param array<string, int> $files relative path => size in kilobytes
|
||||||
*/
|
*/
|
||||||
private static function share(string $token, int $daysAgo, ?int $expiresAfterDays, int $downloads, array $options, array $files): void
|
private static function share(string $token, int $daysAgo, ?int $expiresAfterDays, int $downloads, array $options, array $files, ?string $text = null): void
|
||||||
{
|
{
|
||||||
$createdAt = Carbon::now()->subDays($daysAgo)->subHours(2);
|
$createdAt = Carbon::now()->subDays($daysAgo)->subHours(2);
|
||||||
|
|
||||||
@@ -82,6 +87,7 @@ final class DemoData
|
|||||||
'relativePath' => str_contains($path, '/') ? $path : null,
|
'relativePath' => str_contains($path, '/') ? $path : null,
|
||||||
])->values()->all(),
|
])->values()->all(),
|
||||||
[...$options, 'expires_at' => $expiresAfterDays === null ? null : $createdAt->copy()->addDays($expiresAfterDays)],
|
[...$options, 'expires_at' => $expiresAfterDays === null ? null : $createdAt->copy()->addDays($expiresAfterDays)],
|
||||||
|
$text,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
Str::createRandomStringsNormally();
|
Str::createRandomStringsNormally();
|
||||||
|
|||||||
@@ -115,13 +115,15 @@ test('desktop', function (string $theme) use ($files) {
|
|||||||
$upload = visit('/upload');
|
$upload = visit('/upload');
|
||||||
$page = shotPage($upload, 'desktop', $theme);
|
$page = shotPage($upload, 'desktop', $theme);
|
||||||
selectFiles($page, $files);
|
selectFiles($page, $files);
|
||||||
|
// Creating the share sends this text through the page's own upload before the share is created.
|
||||||
|
$page->type('[data-test="share-text"]', 'The signed contract is in the folder. Call me on +41 44 555 01 23 if anything is missing.');
|
||||||
$page->click('label:has-text("Password protect")')
|
$page->click('label:has-text("Password protect")')
|
||||||
->click('[data-test="generate-password"]')
|
->click('[data-test="generate-password"]')
|
||||||
->wait(1)
|
->wait(1)
|
||||||
->assertScript("document.querySelector('input[autocomplete=\"new-password\"]').value.length > 0");
|
->assertScript("document.querySelector('input[autocomplete=\"new-password\"]').value.length > 0");
|
||||||
$page->type('input[wire\:model="maxDownloads"]', '5');
|
$page->type('input[wire\:model="maxDownloads"]', '5');
|
||||||
// The drop zone, the files and the options fill the window; typing left the page wherever it scrolled.
|
// The foot of the drop zone, the files, the text and the password fill the window; typing left the page wherever it scrolled.
|
||||||
$page->script("document.activeElement?.blur(); window.scrollTo(0, document.querySelector('[data-test=drop-zone]').getBoundingClientRect().top + window.scrollY - 24)");
|
$page->script("document.activeElement?.blur(); window.scrollTo(0, document.querySelector('[data-test=drop-zone]').getBoundingClientRect().bottom + window.scrollY - 80)");
|
||||||
shoot($page, 'desktop', $theme, '01-upload');
|
shoot($page, 'desktop', $theme, '01-upload');
|
||||||
|
|
||||||
// Creating the share is what offers the password once more beside the link.
|
// Creating the share is what offers the password once more beside the link.
|
||||||
@@ -135,7 +137,7 @@ test('desktop', function (string $theme) use ($files) {
|
|||||||
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
|
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
|
||||||
shoot($page, 'desktop', $theme, '03-qr-code', DemoData::CREATED_TOKEN);
|
shoot($page, 'desktop', $theme, '03-qr-code', DemoData::CREATED_TOKEN);
|
||||||
|
|
||||||
// The dashboard shows the eight demo shares, as before.
|
// The dashboard shows the nine demo shares, as before.
|
||||||
app(ShareService::class)->deleteShare(Share::query()->where('token', DemoData::CREATED_TOKEN)->firstOrFail());
|
app(ShareService::class)->deleteShare(Share::query()->where('token', DemoData::CREATED_TOKEN)->firstOrFail());
|
||||||
|
|
||||||
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
|
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
|
||||||
@@ -172,4 +174,12 @@ test('phone', function (string $theme) use ($files) {
|
|||||||
$page->click('[data-test="show-qr-code"]')
|
$page->click('[data-test="show-qr-code"]')
|
||||||
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
|
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
|
||||||
shoot($page, 'phone', $theme, '04-qr-code');
|
shoot($page, 'phone', $theme, '04-qr-code');
|
||||||
|
|
||||||
|
// The text is fetched only when the recipient asks for it.
|
||||||
|
$text = visit(route('share.download', DemoData::TEXT_TOKEN, false));
|
||||||
|
$page = shotPage($text, 'phone', $theme);
|
||||||
|
$page->click('[data-test="show-text"]')
|
||||||
|
->waitForText('maple-lantern-8127')
|
||||||
|
->assertVisible('[data-test="shared-text"]');
|
||||||
|
shoot($page, 'phone', $theme, '05-private-text', DemoData::TEXT_TOKEN);
|
||||||
})->with(['light', 'dark']);
|
})->with(['light', 'dark']);
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ test('completing a share without files is rejected', function () {
|
|||||||
$share = Share::factory()->pending()->create();
|
$share = Share::factory()->pending()->create();
|
||||||
|
|
||||||
expect(fn () => $this->service->completeShare($share))
|
expect(fn () => $this->service->completeShare($share))
|
||||||
->toThrow(ValidationException::class, 'Please select at least one file to upload.');
|
->toThrow(ValidationException::class, 'Add files or a text to share.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('completing a share with a password wraps its data key instead of storing it', function () {
|
test('completing a share with a password wraps its data key instead of storing it', function () {
|
||||||
@@ -355,3 +355,47 @@ test('create share stores an empty file', function () {
|
|||||||
expect($share->files->first()->completed_at)->not->toBeNull();
|
expect($share->files->first()->completed_at)->not->toBeNull();
|
||||||
expect(filesize($this->service->storedFilePath($share->files->first())))->toBe(FileEncryptionService::HEADER_LENGTH + FileEncryptionService::TAG_LENGTH);
|
expect(filesize($this->service->storedFilePath($share->files->first())))->toBe(FileEncryptionService::HEADER_LENGTH + FileEncryptionService::TAG_LENGTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a private text over the maximum length is rejected', function () {
|
||||||
|
$text = str_repeat('a', ShareFile::MAX_TEXT_BYTES + 1);
|
||||||
|
|
||||||
|
expect(fn () => $this->service->registerFile(null, 'text.txt', strlen($text), null, isText: true))
|
||||||
|
->toThrow(ValidationException::class, 'The text is too long (maximum 100 KB).');
|
||||||
|
expect(Share::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a private text does not count against the admin limit of files per share', function () {
|
||||||
|
Setting::set('max_files_per_share', 1);
|
||||||
|
$file = $this->service->registerFile(null, 'one.txt', 10, null);
|
||||||
|
|
||||||
|
$text = $this->service->registerFile($file->share, 'text.txt', 20, null, isText: true);
|
||||||
|
|
||||||
|
expect($text->share_id)->toBe($file->share_id);
|
||||||
|
expect(ShareFile::query()->count())->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create share with no files and a text completes a text-only share', function () {
|
||||||
|
$share = $this->service->createShare([], [], 'hi');
|
||||||
|
|
||||||
|
expect($share->isCompleted())->toBeTrue();
|
||||||
|
expect($share->files)->toHaveCount(1);
|
||||||
|
expect($share->files->first()->is_text)->toBeTrue();
|
||||||
|
expect($share->total_size)->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readText decrypts a share\'s private text without a password', function () {
|
||||||
|
$share = $this->service->createShare([], [], 'the secret note');
|
||||||
|
|
||||||
|
$text = $this->service->readText($share, $share->encryption_key);
|
||||||
|
|
||||||
|
expect($text)->toBe('the secret note');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readText decrypts a share\'s private text with the unwrapped key of a password share', function () {
|
||||||
|
$share = $this->service->createShare([], ['password' => 'a-long-password'], 'the secret note');
|
||||||
|
$key = $this->service->getDecryptionKey($share, 'a-long-password');
|
||||||
|
|
||||||
|
$text = $this->service->readText($share, $key);
|
||||||
|
|
||||||
|
expect($text)->toBe('the secret note');
|
||||||
|
});
|
||||||
|
|||||||
@@ -113,14 +113,17 @@
|
|||||||
|
|
||||||
/* ---------------------------------------------------------------- phone */
|
/* ---------------------------------------------------------------- phone */
|
||||||
|
|
||||||
|
/* The bezel is the screen's margin, not the phone's padding: a percentage margin is of the phone's
|
||||||
|
own width, where a padding would be of whatever the phone is positioned in (the hero). */
|
||||||
.device--phone {
|
.device--phone {
|
||||||
padding: 3.4%;
|
display: flow-root;
|
||||||
border-radius: 15% / 7%;
|
border-radius: 15% / 7%;
|
||||||
background: var(--bezel);
|
background: var(--bezel);
|
||||||
box-shadow: inset 0 0 0 2px var(--bezel-edge), 0 24px 48px -20px var(--device-shadow);
|
box-shadow: inset 0 0 0 2px var(--bezel-edge), 0 24px 48px -20px var(--device-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.device--phone .device__screen {
|
.device--phone .device__screen {
|
||||||
|
margin: 3.4%;
|
||||||
aspect-ratio: 393 / 852;
|
aspect-ratio: 393 / 852;
|
||||||
border-radius: 12.5% / 5.8%;
|
border-radius: 12.5% / 5.8%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1195,6 +1195,13 @@ a.skip-link:focus-visible {
|
|||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Five phones in two columns: the last one sits centred on its own row. */
|
||||||
|
.gallery--phone > li:last-child:nth-child(odd) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: center;
|
||||||
|
width: calc((100% - var(--md-sys-measurement-space200)) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
.gallery[hidden] {
|
.gallery[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1229,9 +1236,15 @@ a.skip-link:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gallery--phone {
|
.gallery--phone {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
column-gap: var(--md-sys-measurement-space400);
|
column-gap: var(--md-sys-measurement-space400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gallery--phone > li:last-child:nth-child(odd) {
|
||||||
|
grid-column: auto;
|
||||||
|
justify-self: stretch;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width >= 1200px) {
|
@media (width >= 1200px) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -106,6 +106,7 @@
|
|||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="sprite" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" class="sprite" aria-hidden="true">
|
||||||
<symbol id="i-cloud-upload" viewBox="0 -960 960 960"><path d="m440-446-36 35q-11 11-27.5 11T348-412q-11-11-11-28t11-28l104-104q12-12 28-12t28 12l104 104q11 11 11.5 27.5T612-412q-11 11-27.5 11.5T556-411l-36-35v206h220q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-83 0-141.5 58.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h60q17 0 28.5 11.5T360-200q0 17-11.5 28.5T320-160h-60q-91 0-155.5-63T40-377q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H520q-33 0-56.5-23.5T440-240v-206Zm40 6Z"/></symbol>
|
<symbol id="i-cloud-upload" viewBox="0 -960 960 960"><path d="m440-446-36 35q-11 11-27.5 11T348-412q-11-11-11-28t11-28l104-104q12-12 28-12t28 12l104 104q11 11 11.5 27.5T612-412q-11 11-27.5 11.5T556-411l-36-35v206h220q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-83 0-141.5 58.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h60q17 0 28.5 11.5T360-200q0 17-11.5 28.5T320-160h-60q-91 0-155.5-63T40-377q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H520q-33 0-56.5-23.5T440-240v-206Zm40 6Z"/></symbol>
|
||||||
|
<symbol id="i-sticky-note-2" viewBox="0 -960 960 960"><path d="M200-200h360v-160q0-17 11.5-28.5T600-400h160v-360H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v367q0 16-6 30.5T817-337L623-143q-11 11-25.5 17t-30.5 6H200Zm240-280H320q-17 0-28.5-11.5T280-440q0-17 11.5-28.5T320-480h120q17 0 28.5 11.5T480-440q0 17-11.5 28.5T440-400Zm200-160H320q-17 0-28.5-11.5T280-600q0-17 11.5-28.5T320-640h320q17 0 28.5 11.5T680-600q0 17-11.5 28.5T640-560ZM200-200v-560 560Z"/></symbol>
|
||||||
<symbol id="i-link" viewBox="0 -960 960 960"><path d="M280-280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h120q17 0 28.5 11.5T440-640q0 17-11.5 28.5T400-600H280q-50 0-85 35t-35 85q0 50 35 85t85 35h120q17 0 28.5 11.5T440-320q0 17-11.5 28.5T400-280H280Zm80-160q-17 0-28.5-11.5T320-480q0-17 11.5-28.5T360-520h240q17 0 28.5 11.5T640-480q0 17-11.5 28.5T600-440H360Zm200 160q-17 0-28.5-11.5T520-320q0-17 11.5-28.5T560-360h120q50 0 85-35t35-85q0-50-35-85t-85-35H560q-17 0-28.5-11.5T520-640q0-17 11.5-28.5T560-680h120q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H560Z"/></symbol>
|
<symbol id="i-link" viewBox="0 -960 960 960"><path d="M280-280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h120q17 0 28.5 11.5T440-640q0 17-11.5 28.5T400-600H280q-50 0-85 35t-35 85q0 50 35 85t85 35h120q17 0 28.5 11.5T440-320q0 17-11.5 28.5T400-280H280Zm80-160q-17 0-28.5-11.5T320-480q0-17 11.5-28.5T360-520h240q17 0 28.5 11.5T640-480q0 17-11.5 28.5T600-440H360Zm200 160q-17 0-28.5-11.5T520-320q0-17 11.5-28.5T560-360h120q50 0 85-35t35-85q0-50-35-85t-85-35H560q-17 0-28.5-11.5T520-640q0-17 11.5-28.5T560-680h120q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H560Z"/></symbol>
|
||||||
<symbol id="i-qr-code-2" viewBox="0 -960 960 960"><path d="M520-120v-80h80v80h-80Zm-80-80v-200h80v200h-80Zm320-120v-160h80v160h-80Zm-80-160v-80h80v80h-80Zm-480 80v-80h80v80h-80Zm-80-80v-80h80v80h-80Zm360-280v-80h80v80h-80ZM180-660h120v-120H180v120Zm-60 20v-160q0-17 11.5-28.5T160-840h160q17 0 28.5 11.5T360-800v160q0 17-11.5 28.5T320-600H160q-17 0-28.5-11.5T120-640Zm60 460h120v-120H180v120Zm-60 20v-160q0-17 11.5-28.5T160-360h160q17 0 28.5 11.5T360-320v160q0 17-11.5 28.5T320-120H160q-17 0-28.5-11.5T120-160Zm540-500h120v-120H660v120Zm-60 20v-160q0-17 11.5-28.5T640-840h160q17 0 28.5 11.5T840-800v160q0 17-11.5 28.5T800-600H640q-17 0-28.5-11.5T600-640Zm80 520v-120h-80v-80h160v120h80v80H680ZM520-400v-80h160v80H520Zm-160 0v-80h-80v-80h240v80h-80v80h-80Zm40-200v-160h80v80h80v80H400Zm-190-90v-60h60v60h-60Zm0 480v-60h60v60h-60Zm480-480v-60h60v60h-60Z"/></symbol>
|
<symbol id="i-qr-code-2" viewBox="0 -960 960 960"><path d="M520-120v-80h80v80h-80Zm-80-80v-200h80v200h-80Zm320-120v-160h80v160h-80Zm-80-160v-80h80v80h-80Zm-480 80v-80h80v80h-80Zm-80-80v-80h80v80h-80Zm360-280v-80h80v80h-80ZM180-660h120v-120H180v120Zm-60 20v-160q0-17 11.5-28.5T160-840h160q17 0 28.5 11.5T360-800v160q0 17-11.5 28.5T320-600H160q-17 0-28.5-11.5T120-640Zm60 460h120v-120H180v120Zm-60 20v-160q0-17 11.5-28.5T160-360h160q17 0 28.5 11.5T360-320v160q0 17-11.5 28.5T320-120H160q-17 0-28.5-11.5T120-160Zm540-500h120v-120H660v120Zm-60 20v-160q0-17 11.5-28.5T640-840h160q17 0 28.5 11.5T840-800v160q0 17-11.5 28.5T800-600H640q-17 0-28.5-11.5T600-640Zm80 520v-120h-80v-80h160v120h80v80H680ZM520-400v-80h160v80H520Zm-160 0v-80h-80v-80h240v80h-80v80h-80Zm40-200v-160h80v80h80v80H400Zm-190-90v-60h60v60h-60Zm0 480v-60h60v60h-60Zm480-480v-60h60v60h-60Z"/></symbol>
|
||||||
<symbol id="i-lock" viewBox="0 -960 960 960"><path d="M240-80q-33 0-56.5-23.5T160-160v-400q0-33 23.5-56.5T240-640h40v-80q0-83 58.5-141.5T480-920q83 0 141.5 58.5T680-720v80h40q33 0 56.5 23.5T800-560v400q0 33-23.5 56.5T720-80H240Zm0-80h480v-400H240v400Zm240-120q33 0 56.5-23.5T560-360q0-33-23.5-56.5T480-440q-33 0-56.5 23.5T400-360q0 33 23.5 56.5T480-280ZM360-640h240v-80q0-50-35-85t-85-35q-50 0-85 35t-35 85v80ZM240-160v-400 400Z"/></symbol>
|
<symbol id="i-lock" viewBox="0 -960 960 960"><path d="M240-80q-33 0-56.5-23.5T160-160v-400q0-33 23.5-56.5T240-640h40v-80q0-83 58.5-141.5T480-920q83 0 141.5 58.5T680-720v80h40q33 0 56.5 23.5T800-560v400q0 33-23.5 56.5T720-80H240Zm0-80h480v-400H240v400Zm240-120q33 0 56.5-23.5T560-360q0-33-23.5-56.5T480-440q-33 0-56.5 23.5T400-360q0 33 23.5 56.5T480-280ZM360-640h240v-80q0-50-35-85t-85-35q-50 0-85 35t-35 85v80ZM240-160v-400 400Z"/></symbol>
|
||||||
@@ -293,72 +294,77 @@
|
|||||||
<p>Drag & drop or browse; folders keep their structure. Files are encrypted in the browser and sent in chunks, with progress per file and a failed chunk retried on its own.</p>
|
<p>Drag & drop or browse; folders keep their structure. Files are encrypted in the browser and sent in chunks, with progress per file and a failed chunk retried on its own.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-link"/></svg></span>
|
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-sticky-note-2"/></svg></span>
|
||||||
|
<h3>Private text</h3>
|
||||||
|
<p>Share a password, a key or a short note, with files or on its own. It is encrypted in the browser like a file, and stays hidden until the recipient presses Show text, which counts as a download.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature">
|
||||||
|
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-link"/></svg></span>
|
||||||
<h3>A link per share</h3>
|
<h3>A link per share</h3>
|
||||||
<p>Every upload gets its own random, hard-to-guess link.</p>
|
<p>Every upload gets its own random, hard-to-guess link.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-qr-code-2"/></svg></span>
|
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-qr-code-2"/></svg></span>
|
||||||
<h3>QR code and share sheet</h3>
|
<h3>QR code and share sheet</h3>
|
||||||
<p>Show the link as a QR code, save it as a PNG, or pass it to the phone's share sheet.</p>
|
<p>Show the link as a QR code, save it as a PNG, or pass it to the phone's share sheet.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-lock"/></svg></span>
|
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-lock"/></svg></span>
|
||||||
<h3>Password protection</h3>
|
<h3>Password protection</h3>
|
||||||
<p>Type a password or generate one, as random characters or a passphrase. It is shown once more beside the new link, and it protects the share's own random key.</p>
|
<p>Type a password or generate one, as random characters or a passphrase. It is shown once more beside the new link, and it protects the share's own random key.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-encrypted"/></svg></span>
|
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-encrypted"/></svg></span>
|
||||||
<h3>Encrypted at rest</h3>
|
<h3>Encrypted at rest</h3>
|
||||||
<p>Encrypted in the browser with AES-256-GCM before upload, and stored only encrypted. With a share password the key is never stored as it is.</p>
|
<p>Encrypted in the browser with AES-256-GCM before upload, and stored only encrypted. With a share password the key is never stored as it is.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-schedule"/></svg></span>
|
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-schedule"/></svg></span>
|
||||||
<h3>Expiry</h3>
|
<h3>Expiry</h3>
|
||||||
<p>From 1 hour to 30 days, or never when the admin allows it.</p>
|
<p>From 1 hour to 30 days, or never when the admin allows it.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-counter-5"/></svg></span>
|
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-counter-5"/></svg></span>
|
||||||
<h3>Download limits</h3>
|
<h3>Download limits</h3>
|
||||||
<p>Close a share after a set number of downloads.</p>
|
<p>Close a share after a set number of downloads. The page tells recipients how many are left, and each one then has an hour for all the files and the ZIP.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-folder-zip"/></svg></span>
|
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-folder-zip"/></svg></span>
|
||||||
<h3>ZIP download</h3>
|
<h3>ZIP download</h3>
|
||||||
<p>Recipients download everything in one archive.</p>
|
<p>Recipients download everything in one archive.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-delete-sweep"/></svg></span>
|
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-delete-sweep"/></svg></span>
|
||||||
<h3>Automatic clean-up</h3>
|
<h3>Automatic clean-up</h3>
|
||||||
<p>Expired shares and their files are deleted every hour, and unfinished uploads after 4 hours.</p>
|
<p>Expired shares and their files are deleted every hour, and unfinished uploads after 4 hours.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-dashboard"/></svg></span>
|
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-dashboard"/></svg></span>
|
||||||
<h3>Admin dashboard</h3>
|
<h3>Admin dashboard</h3>
|
||||||
<p>Every share with its size, downloads and expiry; storage use at a glance.</p>
|
<p>Every share with its size, downloads against its limit and expiry; storage use and the installed version at a glance.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-admin-panel-settings"/></svg></span>
|
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-admin-panel-settings"/></svg></span>
|
||||||
<h3>Limits and defaults</h3>
|
<h3>Limits and defaults</h3>
|
||||||
<p>Maximum file and share size, files per share, storage quota, default expiry, and a password generator that is off, on request or prefilled.</p>
|
<p>Maximum file and share size, files per share, storage quota, default expiry, and a password generator that is off, on request or prefilled.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-palette"/></svg></span>
|
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-palette"/></svg></span>
|
||||||
<h3>Your branding</h3>
|
<h3>Your branding</h3>
|
||||||
<p>Your logo, site title and description on the upload page, in one of eight colour profiles.</p>
|
<p>Your logo, site title and description on the upload page, in one of eight colour profiles.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-shield-lock"/></svg></span>
|
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-shield-lock"/></svg></span>
|
||||||
<h3>System password</h3>
|
<h3>System password</h3>
|
||||||
<p>Optionally close the upload page to everyone who does not have the password.</p>
|
<p>Optionally close the upload page to everyone who does not have the password.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-phonelink-lock"/></svg></span>
|
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-phonelink-lock"/></svg></span>
|
||||||
<h3>Two-factor sign-in</h3>
|
<h3>Two-factor sign-in</h3>
|
||||||
<p>Admins protect their accounts with authenticator app codes.</p>
|
<p>Admins protect their accounts with authenticator app codes.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature">
|
||||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-deployed-code"/></svg></span>
|
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-deployed-code"/></svg></span>
|
||||||
<h3>One Docker image</h3>
|
<h3>One Docker image</h3>
|
||||||
<p>FrankenPHP with Laravel Octane and SQLite — no separate database. Uploads need HTTPS: turn on automatic TLS, or use your reverse proxy.</p>
|
<p>FrankenPHP with Laravel Octane and SQLite — no separate database. Uploads need HTTPS: turn on automatic TLS, or use your reverse proxy.</p>
|
||||||
</article>
|
</article>
|
||||||
@@ -440,7 +446,7 @@
|
|||||||
data-light-src="img/screenshots/desktop/light/04-download-800.webp" data-light-srcset="img/screenshots/desktop/light/04-download-1600.webp 1600w, img/screenshots/desktop/light/04-download-800.webp 800w"
|
data-light-src="img/screenshots/desktop/light/04-download-800.webp" data-light-srcset="img/screenshots/desktop/light/04-download-1600.webp 1600w, img/screenshots/desktop/light/04-download-800.webp 800w"
|
||||||
data-dark-src="img/screenshots/desktop/dark/04-download-800.webp" data-dark-srcset="img/screenshots/desktop/dark/04-download-1600.webp 1600w, img/screenshots/desktop/dark/04-download-800.webp 800w"
|
data-dark-src="img/screenshots/desktop/dark/04-download-800.webp" data-dark-srcset="img/screenshots/desktop/dark/04-download-1600.webp 1600w, img/screenshots/desktop/dark/04-download-800.webp 800w"
|
||||||
width="1512" height="982" loading="lazy" decoding="async"
|
width="1512" height="982" loading="lazy" decoding="async"
|
||||||
alt="The download page a recipient sees, listing the shared files">
|
alt="The download page a recipient sees, listing the shared files and how many downloads are left">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,7 +462,7 @@
|
|||||||
data-light-src="img/screenshots/desktop/light/05-admin-dashboard-800.webp" data-light-srcset="img/screenshots/desktop/light/05-admin-dashboard-1600.webp 1600w, img/screenshots/desktop/light/05-admin-dashboard-800.webp 800w"
|
data-light-src="img/screenshots/desktop/light/05-admin-dashboard-800.webp" data-light-srcset="img/screenshots/desktop/light/05-admin-dashboard-1600.webp 1600w, img/screenshots/desktop/light/05-admin-dashboard-800.webp 800w"
|
||||||
data-dark-src="img/screenshots/desktop/dark/05-admin-dashboard-800.webp" data-dark-srcset="img/screenshots/desktop/dark/05-admin-dashboard-1600.webp 1600w, img/screenshots/desktop/dark/05-admin-dashboard-800.webp 800w"
|
data-dark-src="img/screenshots/desktop/dark/05-admin-dashboard-800.webp" data-dark-srcset="img/screenshots/desktop/dark/05-admin-dashboard-1600.webp 1600w, img/screenshots/desktop/dark/05-admin-dashboard-800.webp 800w"
|
||||||
width="1512" height="982" loading="lazy" decoding="async"
|
width="1512" height="982" loading="lazy" decoding="async"
|
||||||
alt="The admin dashboard with share and file counts, disk usage and the list of shares with Sort by">
|
alt="The admin dashboard with share and file counts, disk usage and the list of shares with Sort by, each with its downloads out of its limit">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -485,7 +491,7 @@
|
|||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<div class="device device--phone">
|
<div class="device device--phone">
|
||||||
<div class="device__screen">
|
<div class="device__screen">
|
||||||
<img src="img/screenshots/phone/light/01-upload-540.webp" srcset="img/screenshots/phone/light/01-upload-1080.webp 1080w, img/screenshots/phone/light/01-upload-540.webp 540w" sizes="(max-width: 839px) 44vw, 232px"
|
<img src="img/screenshots/phone/light/01-upload-540.webp" srcset="img/screenshots/phone/light/01-upload-1080.webp 1080w, img/screenshots/phone/light/01-upload-540.webp 540w" sizes="(max-width: 839px) 44vw, 184px"
|
||||||
data-light-src="img/screenshots/phone/light/01-upload-540.webp" data-light-srcset="img/screenshots/phone/light/01-upload-1080.webp 1080w, img/screenshots/phone/light/01-upload-540.webp 540w"
|
data-light-src="img/screenshots/phone/light/01-upload-540.webp" data-light-srcset="img/screenshots/phone/light/01-upload-1080.webp 1080w, img/screenshots/phone/light/01-upload-540.webp 540w"
|
||||||
data-dark-src="img/screenshots/phone/dark/01-upload-540.webp" data-dark-srcset="img/screenshots/phone/dark/01-upload-1080.webp 1080w, img/screenshots/phone/dark/01-upload-540.webp 540w"
|
data-dark-src="img/screenshots/phone/dark/01-upload-540.webp" data-dark-srcset="img/screenshots/phone/dark/01-upload-1080.webp 1080w, img/screenshots/phone/dark/01-upload-540.webp 540w"
|
||||||
width="393" height="852" loading="lazy" decoding="async"
|
width="393" height="852" loading="lazy" decoding="async"
|
||||||
@@ -499,7 +505,7 @@
|
|||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<div class="device device--phone">
|
<div class="device device--phone">
|
||||||
<div class="device__screen">
|
<div class="device__screen">
|
||||||
<img src="img/screenshots/phone/light/02-password-540.webp" srcset="img/screenshots/phone/light/02-password-1080.webp 1080w, img/screenshots/phone/light/02-password-540.webp 540w" sizes="(max-width: 839px) 44vw, 232px"
|
<img src="img/screenshots/phone/light/02-password-540.webp" srcset="img/screenshots/phone/light/02-password-1080.webp 1080w, img/screenshots/phone/light/02-password-540.webp 540w" sizes="(max-width: 839px) 44vw, 184px"
|
||||||
data-light-src="img/screenshots/phone/light/02-password-540.webp" data-light-srcset="img/screenshots/phone/light/02-password-1080.webp 1080w, img/screenshots/phone/light/02-password-540.webp 540w"
|
data-light-src="img/screenshots/phone/light/02-password-540.webp" data-light-srcset="img/screenshots/phone/light/02-password-1080.webp 1080w, img/screenshots/phone/light/02-password-540.webp 540w"
|
||||||
data-dark-src="img/screenshots/phone/dark/02-password-540.webp" data-dark-srcset="img/screenshots/phone/dark/02-password-1080.webp 1080w, img/screenshots/phone/dark/02-password-540.webp 540w"
|
data-dark-src="img/screenshots/phone/dark/02-password-540.webp" data-dark-srcset="img/screenshots/phone/dark/02-password-1080.webp 1080w, img/screenshots/phone/dark/02-password-540.webp 540w"
|
||||||
width="393" height="852" loading="lazy" decoding="async"
|
width="393" height="852" loading="lazy" decoding="async"
|
||||||
@@ -513,11 +519,11 @@
|
|||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<div class="device device--phone">
|
<div class="device device--phone">
|
||||||
<div class="device__screen">
|
<div class="device__screen">
|
||||||
<img src="img/screenshots/phone/light/03-download-540.webp" srcset="img/screenshots/phone/light/03-download-1080.webp 1080w, img/screenshots/phone/light/03-download-540.webp 540w" sizes="(max-width: 839px) 44vw, 232px"
|
<img src="img/screenshots/phone/light/03-download-540.webp" srcset="img/screenshots/phone/light/03-download-1080.webp 1080w, img/screenshots/phone/light/03-download-540.webp 540w" sizes="(max-width: 839px) 44vw, 184px"
|
||||||
data-light-src="img/screenshots/phone/light/03-download-540.webp" data-light-srcset="img/screenshots/phone/light/03-download-1080.webp 1080w, img/screenshots/phone/light/03-download-540.webp 540w"
|
data-light-src="img/screenshots/phone/light/03-download-540.webp" data-light-srcset="img/screenshots/phone/light/03-download-1080.webp 1080w, img/screenshots/phone/light/03-download-540.webp 540w"
|
||||||
data-dark-src="img/screenshots/phone/dark/03-download-540.webp" data-dark-srcset="img/screenshots/phone/dark/03-download-1080.webp 1080w, img/screenshots/phone/dark/03-download-540.webp 540w"
|
data-dark-src="img/screenshots/phone/dark/03-download-540.webp" data-dark-srcset="img/screenshots/phone/dark/03-download-1080.webp 1080w, img/screenshots/phone/dark/03-download-540.webp 540w"
|
||||||
width="393" height="852" loading="lazy" decoding="async"
|
width="393" height="852" loading="lazy" decoding="async"
|
||||||
alt="The list of shared files on a phone">
|
alt="The list of shared files on a phone, with the downloads left">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<figcaption><strong>Download on the go</strong><span>Files and a ZIP of all of them.</span></figcaption>
|
<figcaption><strong>Download on the go</strong><span>Files and a ZIP of all of them.</span></figcaption>
|
||||||
@@ -527,7 +533,7 @@
|
|||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<div class="device device--phone">
|
<div class="device device--phone">
|
||||||
<div class="device__screen">
|
<div class="device__screen">
|
||||||
<img src="img/screenshots/phone/light/04-qr-code-540.webp" srcset="img/screenshots/phone/light/04-qr-code-1080.webp 1080w, img/screenshots/phone/light/04-qr-code-540.webp 540w" sizes="(max-width: 839px) 44vw, 232px"
|
<img src="img/screenshots/phone/light/04-qr-code-540.webp" srcset="img/screenshots/phone/light/04-qr-code-1080.webp 1080w, img/screenshots/phone/light/04-qr-code-540.webp 540w" sizes="(max-width: 839px) 44vw, 184px"
|
||||||
data-light-src="img/screenshots/phone/light/04-qr-code-540.webp" data-light-srcset="img/screenshots/phone/light/04-qr-code-1080.webp 1080w, img/screenshots/phone/light/04-qr-code-540.webp 540w"
|
data-light-src="img/screenshots/phone/light/04-qr-code-540.webp" data-light-srcset="img/screenshots/phone/light/04-qr-code-1080.webp 1080w, img/screenshots/phone/light/04-qr-code-540.webp 540w"
|
||||||
data-dark-src="img/screenshots/phone/dark/04-qr-code-540.webp" data-dark-srcset="img/screenshots/phone/dark/04-qr-code-1080.webp 1080w, img/screenshots/phone/dark/04-qr-code-540.webp 540w"
|
data-dark-src="img/screenshots/phone/dark/04-qr-code-540.webp" data-dark-srcset="img/screenshots/phone/dark/04-qr-code-1080.webp 1080w, img/screenshots/phone/dark/04-qr-code-540.webp 540w"
|
||||||
width="393" height="852" loading="lazy" decoding="async"
|
width="393" height="852" loading="lazy" decoding="async"
|
||||||
@@ -537,6 +543,20 @@
|
|||||||
<figcaption><strong>Scan to open</strong><span>The QR code fills the screen.</span></figcaption>
|
<figcaption><strong>Scan to open</strong><span>The QR code fills the screen.</span></figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<figure class="shot">
|
||||||
|
<div class="device device--phone">
|
||||||
|
<div class="device__screen">
|
||||||
|
<img src="img/screenshots/phone/light/05-private-text-540.webp" srcset="img/screenshots/phone/light/05-private-text-1080.webp 1080w, img/screenshots/phone/light/05-private-text-540.webp 540w" sizes="(max-width: 839px) 44vw, 184px"
|
||||||
|
data-light-src="img/screenshots/phone/light/05-private-text-540.webp" data-light-srcset="img/screenshots/phone/light/05-private-text-1080.webp 1080w, img/screenshots/phone/light/05-private-text-540.webp 540w"
|
||||||
|
data-dark-src="img/screenshots/phone/dark/05-private-text-540.webp" data-dark-srcset="img/screenshots/phone/dark/05-private-text-1080.webp 1080w, img/screenshots/phone/dark/05-private-text-540.webp 540w"
|
||||||
|
width="393" height="852" loading="lazy" decoding="async"
|
||||||
|
alt="A shared Wi-Fi password on a phone, shown after the recipient pressed Show text">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<figcaption><strong>Private text</strong><span>A password or note, shown once the recipient asks for it.</span></figcaption>
|
||||||
|
</figure>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||