12 Commits
Author SHA1 Message Date
surtic86andClaude Opus 5.5 cf73052e0e Release 2.4.2
linter / quality (push) Successful in 1m3s
tests / ci (8.5) (push) Successful in 3m33s
docker / test (8.5) (push) Successful in 3m29s
docker / build-and-push (push) Successful in 50s
docker / release (push) Successful in 4s
Fixes the nine findings of a security audit of 2.4.1. The most
important: an anonymous uploader could fill the storage quota without
sending data, and a forged X-Forwarded-For header could get around the
limits on password attempts. Password attempts now also have a limit
per share, per login e-mail and for the system password.

Whoever entered the system password enters it once more after the
update. Update now: docker compose pull and docker compose up -d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:36:31 +02:00
surtic86andClaude Opus 5.5 84c8b81f0a Fix the security audit's findings
Fixes the nine findings of security audit run 1 on 2.4.1 (T1-T11).

F1: Uploads that are not complete now count only the bytes the server
received, and a chunk that goes above the storage quota gets 422. An
anonymous uploader can no longer fill the quota with declared sizes.

F2: The share password and the admin login have a second limit without
the address (20 attempts per minute per share and per e-mail), because
a forged X-Forwarded-For changes the address.

F3: The system password gate and the admin check now also run on
Livewire updates, so an old uploader snapshot cannot register files.

F4: The session keeps a fingerprint of the system password's hash.
When the admin changes the system password, earlier verifications end.

F5: The page after the upload opens only in the session that created
the share, and only while recipients can still reach the share.

F6: The share password limit counts an attempt before the password is
checked, so parallel attempts cannot pass it.

F7: The system password has the same two limits (5 per address, 20 in
total per minute).

F8: A password change or reset signs the user's other sessions out.

F9: The only admin cannot delete the account, which would open /setup
to every visitor.

The setup wizard also holds the admin password to the password
defaults, and Livewire applies EnsureAdmin to updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:46:38 +02:00
surtic86andClaude Opus 5.5 014184c824 Build Docker images only for releases
linter / quality (push) Successful in 1m3s
tests / ci (8.5) (push) Successful in 8m18s
A push to main built and pushed the full multi-arch image as :main and
sha-<commit>, next to the release build of the same commit. The docker
workflow now runs only for v*.*.* tags (and builds without pushing for
pull requests), and a release publishes only its version tags and
latest. The tests and lint workflows still run on each push to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:18:56 +02:00
surtic86andClaude Opus 5.5 25250b7df3 Release 2.4.1
linter / quality (push) Canceled after 0s
tests / ci (8.5) (push) Canceled after 3s
docker / test (8.5) (push) Successful in 3m27s
docker / build-and-push (push) Successful in 13m7s
docker / release (push) Successful in 5s
The Docker image runs PHP 8.5.12, which fixes PHP2Shell
(CVE-2026-109618, CVE-2026-109619): two flaws that let an attacker
without an account run code on the server when a request ends
abnormally. Every earlier image runs an affected PHP.

Dependencies are up to date (Laravel 13.38, Livewire 4.4.8, Octane
2.22, Vite 8.3.5). Update now: docker compose pull and docker compose
up -d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:16:39 +02:00
surtic86andClaude Opus 5.5 307e57b23c Update dependencies
Composer: Laravel 13.38, Octane 2.22, Livewire 4.4.8 and Pest 5.3.3,
with the packages they bring (35 in all). npm: Vite 8.3.5 with
Rolldown 1.2.15, and Playwright 1.64.1. chokidar stays on 5: a major
update does not go into a patch release. Both audits are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:16:19 +02:00
Andreas Reinhold / reiniandClaude Opus 5.5 368a3b8cb5 Describe SealShare 2.4 on the website
tests / ci (8.5) (push) Successful in 3m24s
docker / build-and-push (push) Successful in 9m41s
docker / release (push) Skipped
docker / test (8.5) (push) Successful in 3m25s
linter / quality (push) Successful in 50s
A new "Download into a folder" feature card describes the folder
download for large shares: the files saved one by one into a folder the
recipient picks, in their folders, resuming after a broken connection,
in Chrome and Edge on a computer. How it works, Files and folders
(Browse Folder), Download limits (the hour starts again with every
download) and ZIP download (progress and size) say what 2.4 changed,
and the download screenshots' captions and alt texts match the page.

Every screenshot is retaken: the upload page shows Browse Folder, the
download page the file count, the total size and the folder download.
The phone shots switch the folder download off, as real phones have no
folder picker and desktop Chromium would otherwise show it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:38:42 +02:00
Andreas Reinhold / reiniandClaude Opus 5.5 f597181f91 Release 2.4.0
docker / build-and-push (push) Successful in 9m42s
docker / test (8.5) (push) Successful in 3m22s
docker / release (push) Successful in 5s
linter / quality (push) Successful in 1m2s
tests / ci (8.5) (push) Successful in 3m19s
Large shares download reliably: "Download all to a folder" saves the
files one at a time into a folder the recipient picks, resuming where a
connection broke off, and single-file downloads answer Range requests.
The ZIP sends its size, and downloads no longer stop after 5 minutes.

The download page shows the file count and total size and marks the
files already downloaded; the upload page gains "Browse Folder".

Every dependency is up to date (Laravel 13.35, Livewire 4.4.7, Vite
8.3.4), which closes three security advisories, and the Gitea workflows
run on Node 24 actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:28:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5.5 30a485f5d9 Mark the file the folder download is on
During "Download all to a folder" the recipient saw only the file name
under the progress bar. The file's row in the list now shows the loading
indicator while it is on its way, and the list scrolls that row into
view, so a run through hundreds of files can be followed in the list.
The row is scrolled rather than the indicator, which is still hidden
when the run moves on to the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:28:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5.5 6578335568 Fix the security audit's findings
ShareDownload::$authenticated was a public property without #[Locked],
so a visitor with a share's link but not its password could set it from
the browser and see the file list: names, folder paths, sizes and
whether the share holds a text. The contents stayed protected; the flag
is now locked, and a test tries to set it.

Sessions hold the keys of the password shares a recipient unlocked, and
were stored unencrypted in the sessions table wherever SESSION_ENCRYPT
was not set, which includes every Docker install. They are now
encrypted unless SESSION_ENCRYPT=false. Share keys never live in the
session, so every share stays readable; people sign in again once.

The container generated an APP_KEY when none was set, printed it to the
logs and generated another on the next start, which left the shares
without a password unreadable. It now refuses to start without one and
says how to create it; docker-compose.example.yml requires it for both
services. The command it names, and the README's, now pass
--entrypoint php: the image's entrypoint ignores its arguments, so
"docker run <image> php artisan key:generate --show" never ran it.

Every proxy is trusted, so the password-reset mail built its link from
a host a forged X-Forwarded-Host could choose. It is now built from
APP_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:23:00 +02:00
Andreas Reinhold / reiniandClaude Opus 5.5 6c3202a0cf Scroll a long file list on the download page on its own
A share with hundreds of files made the download page as tall as its
list, so the recipient scrolled far past the buttons. The list now sits
in .share-file-list, capped at min(32rem, 60dvh) with its own scroll, as
the upload page's selected-files list already is; the download buttons
stay on the first screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:40:34 +02:00
Andreas Reinhold / reiniandClaude Opus 5.5 63bae2d1a6 Update dependencies
Composer: Laravel 13.35, Livewire 4.4.7, Octane 2.21, Fortify 1.41,
Boost 2.10.3 and Pest 5.3.1, with the packages they bring (51 in all,
brick/math to 1.0). league/commonmark 2.10.3 closes a high and a medium
advisory. npm: Vite 8.3.4 and Playwright 1.64; npm audit fix updates
source-map-js past a high advisory. Both audits are now clean.

Boost's update rewrote its guidelines in CLAUDE.md and four skills.

The Gitea workflows move to the current majors of checkout, setup-node
and the Docker actions, which run on Node 24 instead of the end-of-life
Node 20. QEMU stays pinned to 9.2.2: 10.2 still segfaults on the arm64
runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:28:45 +02:00
Andreas Reinhold / reiniandClaude Opus 5.5 b594fb2300 Download large shares file by file, resumably
A 29 GB share with 254 files failed as one ZIP: the single request ran
into the 300-second request limit and, behind a proxy we cannot
configure, could be cut at any time with no way to resume.

Single-file downloads now answer HTTP Range requests (206, ETag and
If-Range, 416 past the end): a SEALCHK2 file is decrypted from the chunk
holding the first byte asked for. On that, the download page's new
"Download all to a folder" (File System Access API, Chrome and Edge on a
computer) saves every file into a folder the recipient picks, one
request per file, in its folder path; a broken connection is retried
from the byte on disk, complete files are skipped, and failed files get
a Retry button. Other browsers keep the ZIP and the per-file buttons.

The ZIP is sized up front with ZipStream's strict simulation and sent
with Content-Length, so the browser shows progress and notices a cut.
Both download routes lift the time limit for their streams.

The recipient's download window now restarts with each download, so a
long file-by-file download never outlasts it. The download page shows
the file count and total size and marks the files this browser has
downloaded, and the upload page gains "Browse Folder", which keeps each
file's path as a dropped folder does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:20:35 +02:00
79 changed files with 2074 additions and 755 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
---
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
license: MIT
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
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.
- 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.
- 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.
- 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.
- 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.
- 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`.
- 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`.
@@ -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`.
- 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.
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
- Hint: grep -- `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
## 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.
- 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.
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
- Hint: grep -- `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
## E. Architecture & organization
@@ -91,11 +91,11 @@ This app ships a frontend stack, so the items below apply.
## G. Database & migrations
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.
- Hint: grep `function down` vs the migration count.
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`.
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
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.
- 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.
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
- Hint: grep -- `->mock(`, `->spy(`, `Mockery::` in `tests/`.
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
## I. Responses & API resources
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.
- 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.
@@ -128,7 +128,7 @@ This app ships a frontend stack, so the items below apply.
## J. Strings, collections & dates
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`).
- 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.
@@ -103,7 +103,7 @@ $posts = Post::query()
## 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
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.
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
+9 -13
View File
@@ -2,8 +2,6 @@ name: docker
on:
push:
branches:
- main
tags:
- "v*.*.*"
pull_request:
@@ -24,7 +22,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
@@ -35,7 +33,7 @@ jobs:
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: "24"
@@ -65,25 +63,25 @@ jobs:
needs: test
steps:
- 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
# crashes running x86_64 programs on an arm64 host (QEMU issue 2168, "QEMU internal
# SIGSEGV {code=MAPERR, addr=0x20}") and 10.2 segfaults on this runner too; 9.2.2 was
# checked on the runner's host: node, composer and install-php-extensions all run.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
with:
image: tonistiigi/binfmt:qemu-v9.2.2
- 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
# package write rights, owned by the account that pushes (gitea.actor).
- name: Log in to the Gitea container registry
if: gitea.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: gitea.nonameweb.ch
username: ${{ gitea.actor }}
@@ -91,18 +89,16 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: gitea.nonameweb.ch/nonameweb/sealshare
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -118,7 +114,7 @@ jobs:
if: startsWith(gitea.ref, 'refs/tags/v')
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Take the release notes from the changelog
run: |
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
environment: Testing
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
@@ -35,7 +35,7 @@ jobs:
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '24'
+56
View File
@@ -5,6 +5,59 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.4.2] - 2026-10-22
### Changed
- Whoever entered the system password enters it once more after the update. A change of the system password now ends every earlier entry.
- The page after an upload, with the link and the QR code, opens only in the browser session that created the share, and only while the share can still be downloaded. Other visitors get "not found".
- The admin password in the setup wizard follows the same rules as a changed password (in production: at least 12 characters, upper and lower case, numbers and symbols).
### Fixed
- Security: an anonymous uploader could reserve the whole storage quota with declared file sizes and send no data. Uploads that are not complete now count only the bytes received, and a chunk that goes above the quota is refused.
- Security: password attempts are limited per share, per login e-mail and for the system password (20 per minute), in addition to the limit per address (5 per minute), which a forged `X-Forwarded-For` header could change. The share password limit counts an attempt before the check, so parallel attempts cannot pass it. The system password had no limit.
- Security: the system password also protects the upload page's actions, so a page opened before the system password was set cannot register files.
- Security: the page after an upload showed a share's details to everyone with its link, also for a password-protected or expired share.
- Security: a password change or reset signs the admin's other sessions out.
- Security: the only admin can no longer delete their account, which opened the setup page to every visitor.
## [2.4.1] - 2026-10-22
### Changed
- Updated to Laravel 13.38, Livewire 4.4.8 and Laravel Octane 2.22, and Vite 8.3.5.
### Fixed
- Security: the Docker image runs PHP 8.5.12, which fixes PHP2Shell (CVE-2026-109618, CVE-2026-109619): two flaws that let an attacker without an account run code on the server when a request ends abnormally. Every earlier image runs an affected PHP (2.4.0 runs PHP 8.5.8). Update now: `docker compose pull` and `docker compose up -d`.
## [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
@@ -174,6 +227,9 @@ 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.
- Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane.
[2.4.2]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.4.1...v2.4.2
[2.4.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.4.0...v2.4.1
[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.1.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.1...v2.1.0
+3 -15
View File
@@ -3,11 +3,9 @@
# 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
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:
- 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
- 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
- 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 ===
# Laravel Boost
@@ -72,8 +66,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
## 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.
- 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.
- 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.
## Artisan
@@ -103,7 +96,6 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
# Deployment
- 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 ===
@@ -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()`.
- 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
+1 -1
View File
@@ -94,7 +94,7 @@ curl -O https://gitea.nonameweb.ch/noNameWEB/SealShare/raw/branch/main/docker-co
cp docker-compose.example.yml 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)
# Then start:
+104 -36
View File
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\StreamedResponse;
use ZipStream\CompressionMethod;
use ZipStream\OperationMode;
use ZipStream\ZipStream;
class DownloadController extends Controller
@@ -24,7 +25,9 @@ class DownloadController extends Controller
/**
* 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. The
* private text is left out: it is only ever shown on the page.
* 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
{
@@ -38,45 +41,40 @@ class DownloadController extends Controller
// Counted before the body streams: the session is saved by then.
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
return new StreamedResponse(function () use ($share, $key): void {
$zip = new ZipStream(
defaultCompressionMethod: CompressionMethod::STORE,
defaultEnableZeroHeader: true,
sendHttpHeaders: false,
flushOutput: true,
$zip = new ZipStream(
operationMode: OperationMode::SIMULATE_STRICT,
defaultCompressionMethod: CompressionMethod::STORE,
defaultEnableZeroHeader: true,
sendHttpHeaders: false,
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) {
$chunks = $this->encryptionService->decryptedChunks(
Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)),
$key,
);
$size = $zip->finish();
$zip->addFileFromPsr7Stream(fileName: $this->archiveName($file), stream: new PumpStream(function () use ($chunks): string|false {
while ($chunks->valid() && $chunks->current() === '') {
$chunks->next();
}
return new StreamedResponse(function () use ($zip): void {
// A large share takes longer than any request limit to send.
set_time_limit(0);
if (! $chunks->valid()) {
return false;
}
$chunk = $chunks->current();
$chunks->next();
return $chunk;
}));
}
$zip->finish();
$zip->executeSimulation();
}, 200, [
'Content-Type' => 'application/zip',
'Content-Length' => $size,
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'),
]);
}
/**
* Download a single file; never the private text, which is only ever shown on the page.
* 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
{
@@ -85,30 +83,100 @@ class DownloadController extends Controller
$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);
$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 = [
'Content-Type' => $mimeType,
'Content-Type' => $shareFile->mime_type ?? 'application/octet-stream',
'Content-Disposition' => HeaderUtils::makeDisposition(
'attachment',
$shareFile->original_name,
'download',
),
'Content-Length' => max($end - $start + 1, 0),
'Accept-Ranges' => 'bytes',
'ETag' => $etag,
];
if ($shareFile->file_size !== null) {
$headers['Content-Length'] = $shareFile->file_size;
if ($partial) {
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
}
return new StreamedResponse(function () use ($encryptedPath, $key): void {
foreach ($this->encryptionService->decryptedChunks($encryptedPath, $key) as $chunk) {
return new StreamedResponse(function () use ($encryptedPath, $key, $start, $end): void {
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;
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;
});
}
/**
+4 -1
View File
@@ -17,7 +17,10 @@ class SystemPasswordGate
return $next($request);
}
if ($request->session()->get('system_password_verified') === true) {
// A fingerprint of the current hash: changing the system password ends every earlier verification.
$verified = $request->session()->get('system_password_verified');
if (is_string($verified) && hash_equals(hash('sha256', $systemPassword), $verified)) {
return $next($request);
}
+1
View File
@@ -192,6 +192,7 @@ class FileUploader extends Component
]);
session()->put('pending_shares', array_values(array_diff(session('pending_shares', []), [$share->token])));
session()->push('created_shares', $share->token);
// The page the upload leads to offers the password once more, next to the link; it is
// never stored in the clear, so this flash is the only way it gets there.
+11 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -18,11 +19,20 @@ class SetupWizard extends Component
#[Validate('required|string|email|max:255|unique:users')]
public string $email = '';
#[Validate('required|string|min:8|confirmed')]
public string $password = '';
public string $password_confirmation = '';
/**
* @return array<string, array<int, mixed>>
*/
protected function rules(): array
{
return [
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
];
}
public function mount(): void
{
if (User::query()->where('is_admin', true)->exists()) {
+4 -2
View File
@@ -15,13 +15,15 @@ class ShareCreated extends Component
{
public Share $share;
/** The share's password, offered once to the uploader who just set it; `null` on any other visit. */
/** The share's password, offered once to the uploader who just set it; `null` on a reload. */
#[Locked]
public ?string $password = null;
public function mount(Share $share): void
{
abort_unless($share->isCompleted(), 404);
// Only for the session that created the share, and only while recipients can still reach it.
abort_unless($share->isCompleted() && ! $share->isExpired() && ! $share->hasReachedDownloadLimit()
&& in_array($share->token, session('created_shares', []), true), 404);
$this->share = $share;
+24 -6
View File
@@ -7,6 +7,7 @@ use App\Services\ShareService;
use Carbon\CarbonInterval;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Renderless;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -16,6 +17,8 @@ class ShareDownload extends Component
{
public Share $share;
/** Set only by mount() and verifyPassword(): the browser must never unlock the file list. */
#[Locked]
public bool $authenticated = false;
#[Validate('required|string')]
@@ -36,10 +39,18 @@ class ShareDownload extends Component
public function verifyPassword(ShareService $shareService): void
{
$rateLimitKey = 'share-password:'.$this->share->token.'|'.request()->ip();
$ipKey = 'share-password:'.$this->share->token.'|'.request()->ip();
$shareKey = 'share-password:'.$this->share->token;
if (RateLimiter::tooManyAttempts($rateLimitKey, 5)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
// Count before the slow hash check, so parallel requests cannot all pass a read-only check.
$ipHits = RateLimiter::hit($ipKey, 60);
$shareHits = RateLimiter::hit($shareKey, 60);
if ($ipHits > 5 || $shareHits > 20) {
$seconds = max(
$ipHits > 5 ? RateLimiter::availableIn($ipKey) : 0,
$shareHits > 20 ? RateLimiter::availableIn($shareKey) : 0,
);
$this->addError('password', __('Too many attempts. Please try again in :seconds seconds.', ['seconds' => $seconds]));
return;
@@ -48,13 +59,13 @@ class ShareDownload extends Component
$this->validate();
if (! $shareService->verifyPassword($this->share, $this->password)) {
RateLimiter::hit($rateLimitKey, 60);
$this->addError('password', __('The password is incorrect.'));
return;
}
RateLimiter::clear($rateLimitKey);
RateLimiter::clear($ipKey);
RateLimiter::clear($shareKey);
$encryptionKey = $shareService->getDecryptionKey($this->share, $this->password);
session(['share_key_'.$this->share->token => $encryptionKey]);
@@ -85,9 +96,16 @@ class ShareDownload extends Component
public function render(): mixed
{
$shareService = app(ShareService::class);
$files = $this->share->files->where('is_text', false)->values();
return view('livewire.share-download', [
'files' => $this->share->files->where('is_text', false)->values(),
'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()),
'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null,
+22 -1
View File
@@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Models\Setting;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -16,6 +17,23 @@ class SystemPasswordPrompt extends Component
public function verify(): void
{
$ipKey = 'system-password|'.request()->ip();
$globalKey = 'system-password';
// Count before the slow hash check, so parallel requests cannot all pass a read-only check.
$ipHits = RateLimiter::hit($ipKey, 60);
$globalHits = RateLimiter::hit($globalKey, 60);
if ($ipHits > 5 || $globalHits > 20) {
$seconds = max(
$ipHits > 5 ? RateLimiter::availableIn($ipKey) : 0,
$globalHits > 20 ? RateLimiter::availableIn($globalKey) : 0,
);
$this->addError('password', __('Too many attempts. Please try again in :seconds seconds.', ['seconds' => $seconds]));
return;
}
$this->validate();
$systemPassword = Setting::get('system_password');
@@ -26,7 +44,10 @@ class SystemPasswordPrompt extends Component
return;
}
session(['system_password_verified' => true]);
RateLimiter::clear($ipKey);
RateLimiter::clear($globalKey);
session(['system_password_verified' => hash('sha256', $systemPassword)]);
$this->redirect(route('upload'), navigate: true);
}
+6
View File
@@ -2,12 +2,15 @@
namespace App\Providers;
use App\Http\Middleware\EnsureAdmin;
use App\Http\Middleware\SystemPasswordGate;
use App\Models\Setting;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Livewire\Livewire;
use NoNameWeb\LivewireMaterial\Support\Scheme;
class AppServiceProvider extends ServiceProvider
@@ -22,6 +25,9 @@ class AppServiceProvider extends ServiceProvider
// The colour profile the admin chose in Admin settings; asked each time a page, mail or
// error page draws its colours, so a new choice applies at once in every Octane worker.
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
// Livewire updates apply a page route's middleware only when it is listed as persistent.
Livewire::addPersistentMiddleware([SystemPasswordGate::class, EnsureAdmin::class]);
}
/**
+13 -1
View File
@@ -3,6 +3,8 @@
namespace App\Providers;
use App\Actions\Fortify\ResetUserPassword;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
@@ -28,6 +30,12 @@ class FortifyServiceProvider extends ServiceProvider
private function configureActions(): void
{
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));
}
/**
@@ -54,7 +62,11 @@ class FortifyServiceProvider extends ServiceProvider
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
// The address comes from X-Forwarded-For behind the proxy, so a second limit per e-mail does not use it.
return [
Limit::perMinute(5)->by($throttleKey),
Limit::perMinute(20)->by(Str::transliterate(Str::lower($request->input(Fortify::username())))),
];
});
}
}
+32 -11
View File
@@ -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>
*/
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);
if ($magic === self::MAGIC) {
yield from $this->decryptChunks($encryptedPath, $key);
} elseif ($magic === self::LEGACY_CHUNKED_MAGIC) {
yield from $this->decryptLegacyChunks($encryptedPath, $key);
} else {
yield $this->decryptLegacy($encryptedPath, $key);
yield from $this->decryptChunks($encryptedPath, $key, $offset);
return;
}
$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>
*/
private function decryptChunks(string $encryptedPath, string $key): Generator
private function decryptChunks(string $encryptedPath, string $key, int $offset = 0): Generator
{
$handle = fopen($encryptedPath, 'rb');
@@ -270,10 +285,16 @@ class FileEncryptionService
throw new RuntimeException('Invalid encrypted file: no chunks');
}
for ($index = 0; $index < $chunkCount; $index++) {
$chunk = (string) fread($handle, $storedChunkSize);
$firstIndex = min(intdiv($offset, max($chunkSize, 1)), $chunkCount - 1);
$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 {
fclose($handle);
+21 -6
View File
@@ -10,6 +10,7 @@ use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -104,7 +105,7 @@ class ShareService
* Verify the encrypted chunk that comes next for a file and write it into place; returns how
* many of the file's chunks are stored. The plaintext only exists in memory, to be checked.
*
* @throws InvalidArgumentException when the chunk has the wrong length or fails authentication
* @throws InvalidArgumentException when the chunk has the wrong length, exceeds the storage quota or fails authentication
* @throws ModelNotFoundException when the file was removed meanwhile
*/
public function storeChunk(ShareFile $file, int $index, string $chunk): int
@@ -134,6 +135,10 @@ class ShareService
throw new InvalidArgumentException('Chunk '.$index.' has the wrong length');
}
if ($this->getTotalUsedSpace() + $plaintextLength > $this->getMaxStorageQuota()) {
throw new InvalidArgumentException('Chunk '.$index.' exceeds the storage quota');
}
try {
$plaintext = $this->encryptionService->decryptChunk($chunk, $share->encryption_key, $noncePrefix, $index, $isLast);
} catch (RuntimeException) {
@@ -393,8 +398,10 @@ class ShareService
/**
* When this session's download window for a share ends, or null while it has none open. The
* window opens with the session's counted download; until it ends, the session may start more
* downloads of the share without counting them, even once the share has reached its limit.
* window opens with the session's counted download and each download started in it keeps it
* 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
{
@@ -418,6 +425,8 @@ class ShareService
public function claimDownload(Share $share, Session $session): bool
{
if ($this->downloadWindowEndsAt($share, $session) !== null) {
$session->put($this->downloadSessionKey($share), now()->getTimestamp());
return true;
}
@@ -436,7 +445,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
{
@@ -444,11 +453,17 @@ class ShareService
}
/**
* Get total used space in bytes, files still being uploaded included.
* Get total used space in bytes; files still being uploaded count with the bytes received so far.
*/
public function getTotalUsedSpace(): int
{
return (int) Share::query()->sum('total_size');
$chunkSize = (int) config('uploads.chunk_size');
$received = ShareFile::query()
->whereHas('share', fn ($query) => $query->whereNull('completed_at'))
->sum(DB::raw("CASE WHEN uploaded_chunks * {$chunkSize} < file_size THEN uploaded_chunks * {$chunkSize} ELSE file_size END"));
return (int) Share::query()->whereNotNull('completed_at')->sum('total_size') + (int) $received;
}
/**
+3
View File
@@ -16,6 +16,9 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
// Ends every other session of a user whose password changed.
$middleware->authenticateSessions();
$middleware->web(append: [
EnsureSetupComplete::class,
SecurityHeaders::class,
Generated
+572 -477
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,6 +25,6 @@ return [
|
*/
'version' => '2.3.0',
'version' => '2.4.2',
];
+8 -2
View File
@@ -8,13 +8,19 @@ use Illuminate\Support\Str;
|--------------------------------------------------------------------------
|
| Kept from earlier releases, where it differs from the framework's
| "<app>_session": renaming the cookie would sign everyone out. Every
| other key comes from the framework's defaults.
| "<app>_session": renaming the cookie would sign everyone out.
|
| 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 [
'encrypt' => env('SESSION_ENCRYPT', true),
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
+6 -5
View File
@@ -4,12 +4,13 @@
#
# Quick start:
# 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
# 4. Open your browser to your configured domain
#
# Note: APP_KEY is auto-generated on first start if not set.
# Copy it from the logs into your docker-compose.yml to persist across restarts.
# APP_KEY is required: the container does not start without it. Create one with
# 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:
# --- REQUIRED ---
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
# --- HTTPS ---
@@ -90,7 +91,7 @@ services:
- sealshare_storage:/app/storage/app
- sealshare_database:/app/database/sqlite
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
DB_DATABASE: /app/database/sqlite/database.sqlite # Same as the app service above
depends_on:
+7 -6
View File
@@ -3,13 +3,14 @@ set -e
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
echo "[entrypoint] No APP_KEY set, generating one..."
APP_KEY=$(php artisan key:generate --show)
export APP_KEY
echo "[entrypoint] Generated APP_KEY: $APP_KEY"
echo "[entrypoint] WARNING: Set this APP_KEY in your docker-compose.yml to persist across restarts!"
echo "[entrypoint] APP_KEY is not set. Create one and set it for the app and the scheduler:" >&2
echo "[entrypoint] docker run --rm --entrypoint php <this image> artisan key:generate --show" >&2
echo "[entrypoint] If this install ran without one before, use the key from its old logs" >&2
echo "[entrypoint] (\"Generated APP_KEY: ...\") so its shares stay readable." >&2
exit 1
fi
# A docker-compose.yml from before 2.1.1 mounts the SQLite volume over all of /app/database, so the
+91 -91
View File
@@ -18,18 +18,18 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.150.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
"integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
"version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
"integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
"cpu": [
"arm"
],
@@ -43,9 +43,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz",
"integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
"cpu": [
"arm64"
],
@@ -59,9 +59,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz",
"integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
"cpu": [
"arm64"
],
@@ -75,9 +75,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz",
"integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
"cpu": [
"x64"
],
@@ -91,9 +91,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
"integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
"cpu": [
"x64"
],
@@ -107,9 +107,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz",
"integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
"cpu": [
"arm"
],
@@ -123,9 +123,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz",
"integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
"cpu": [
"arm64"
],
@@ -142,9 +142,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz",
"integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
"cpu": [
"arm64"
],
@@ -161,9 +161,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz",
"integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
"cpu": [
"ppc64"
],
@@ -180,9 +180,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz",
"integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
"cpu": [
"s390x"
],
@@ -199,9 +199,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz",
"integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
"cpu": [
"x64"
],
@@ -218,9 +218,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz",
"integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
"cpu": [
"x64"
],
@@ -237,9 +237,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz",
"integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
"cpu": [
"arm64"
],
@@ -253,9 +253,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz",
"integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
"cpu": [
"arm64"
],
@@ -269,9 +269,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz",
"integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
"cpu": [
"x64"
],
@@ -634,9 +634,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"funding": [
{
"type": "github",
@@ -670,13 +670,13 @@
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
@@ -686,9 +686,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -699,9 +699,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"funding": [
{
"type": "opencollective",
@@ -718,9 +718,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.18",
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -741,12 +741,12 @@
}
},
"node_modules/rolldown": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
"integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.150.0",
"@oxc-project/types": "=0.155.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -756,27 +756,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.9",
"@rolldown/binding-android-arm64": "1.2.9",
"@rolldown/binding-darwin-arm64": "1.2.9",
"@rolldown/binding-darwin-x64": "1.2.9",
"@rolldown/binding-freebsd-x64": "1.2.9",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.9",
"@rolldown/binding-linux-arm64-gnu": "1.2.9",
"@rolldown/binding-linux-arm64-musl": "1.2.9",
"@rolldown/binding-linux-ppc64-gnu": "1.2.9",
"@rolldown/binding-linux-s390x-gnu": "1.2.9",
"@rolldown/binding-linux-x64-gnu": "1.2.9",
"@rolldown/binding-linux-x64-musl": "1.2.9",
"@rolldown/binding-openharmony-arm64": "1.2.9",
"@rolldown/binding-win32-arm64-msvc": "1.2.9",
"@rolldown/binding-win32-x64-msvc": "1.2.9"
"@rolldown/binding-android-arm-eabi": "1.2.15",
"@rolldown/binding-android-arm64": "1.2.15",
"@rolldown/binding-darwin-arm64": "1.2.15",
"@rolldown/binding-darwin-x64": "1.2.15",
"@rolldown/binding-freebsd-x64": "1.2.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
"@rolldown/binding-linux-arm64-musl": "1.2.15",
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
"@rolldown/binding-linux-x64-gnu": "1.2.15",
"@rolldown/binding-linux-x64-musl": "1.2.15",
"@rolldown/binding-openharmony-arm64": "1.2.15",
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
"@rolldown/binding-win32-x64-msvc": "1.2.15"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -799,15 +799,15 @@
}
},
"node_modules/vite": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.28",
"rolldown": "~1.2.6",
"postcss": "^8.5.30",
"rolldown": "~1.2.14",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -824,7 +824,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1",
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
+10
View File
@@ -243,6 +243,16 @@
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.
+1
View File
@@ -2,3 +2,4 @@
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
import './share-created.js'
import './share-uploader.js'
import './share-downloader.js'
+251
View File
@@ -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 = ''
}
},
}
})
})
+2 -1
View File
@@ -53,7 +53,8 @@ document.addEventListener('alpine:init', () => {
},
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 = ''
},
@@ -45,9 +45,14 @@
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
</x-stack>
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}}
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
{{-- Each button is a tab stop and opens the browser's own picker, for files or for a folder with
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" webkitdirectory hidden x-ref="folderPicker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="folder-input" />
</x-stack>
</div>
@@ -73,28 +73,88 @@
@endif
@if ($files->isNotEmpty())
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
<x-stack gap="space200">
<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-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
{{-- 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>
<x-row justify="end">
<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="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-on:click="downloaded = true" />
<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" />
<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>
@@ -2,7 +2,9 @@
use App\Concerns\PasswordValidationRules;
use App\Livewire\Actions\Logout;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
new class extends Component {
@@ -12,7 +14,15 @@ new class extends Component {
public bool $showDeleteModal = false;
/**
* Delete the currently authenticated user.
* Whether the user is the only admin: deleting that account would open /setup to anyone.
*/
public function isLastAdmin(): bool
{
return Auth::user()->is_admin && ! User::query()->where('is_admin', true)->whereKeyNot(Auth::id())->exists();
}
/**
* Delete the currently authenticated user, unless it is the only admin.
*/
public function deleteUser(Logout $logout): void
{
@@ -20,29 +30,50 @@ new class extends Component {
'password' => $this->currentPasswordRules(),
]);
tap(Auth::user(), $logout(...))->delete();
// The admin rows stay locked until the delete, so two admins cannot delete each other's last peer at once.
$deleted = DB::transaction(function () use ($logout): bool {
User::query()->where('is_admin', true)->lockForUpdate()->get();
if ($this->isLastAdmin()) {
return false;
}
tap(Auth::user(), $logout(...))->delete();
return true;
});
if (! $deleted) {
$this->addError('password', __('You are the only admin, so this account cannot be deleted.'));
return;
}
$this->redirect('/', navigate: true);
}
}; ?>
<x-card :title="__('Delete account')" :subtitle="__('Delete your account and all of its resources')" heading="h2" variant="outlined">
<x-button :label="__('Delete account')" danger icon="delete" wire:click="$set('showDeleteModal', true)" data-test="delete-user-button" />
@if ($this->isLastAdmin())
<p class="md-type-body-md md-ink-variant">{{ __('You are the only admin, so this account cannot be deleted.') }}</p>
@else
<x-button :label="__('Delete account')" danger icon="delete" wire:click="$set('showDeleteModal', true)" data-test="delete-user-button" />
<x-modal wire:model="showDeleteModal" :title="__('Are you sure you want to delete your account?')" icon="delete">
<x-stack gap="space200">
<p>
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<x-modal wire:model="showDeleteModal" :title="__('Are you sure you want to delete your account?')" icon="delete">
<x-stack gap="space200">
<p>
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<x-form id="delete-user-form" wire:submit="deleteUser">
<x-password wire:model="password" :label="__('Password')" autocomplete="current-password" />
</x-form>
</x-stack>
<x-form id="delete-user-form" wire:submit="deleteUser">
<x-password wire:model="password" :label="__('Password')" autocomplete="current-password" />
</x-form>
</x-stack>
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button type="submit" form="delete-user-form" :label="__('Delete account')" danger data-test="confirm-delete-user-button" />
</x-slot:actions>
</x-modal>
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button type="submit" form="delete-user-form" :label="__('Delete account')" danger data-test="confirm-delete-user-button" />
</x-slot:actions>
</x-modal>
@endif
</x-card>
+2
View File
@@ -136,6 +136,8 @@ test('the share-created page holds at every breakpoint', function () {
[],
);
$this->withSession(['created_shares' => [$share->token]]);
walkBreakpoints(ready(visit(route('share.created', $share, false))));
});
+10 -1
View File
@@ -57,6 +57,8 @@ test('a new share\'s link can be copied from the page the upload leads to', func
[],
);
$this->withSession(['created_shares' => [$share->token]]);
$page = ready(visit(route('share.created', $share, false)));
$page->assertSee('Share Created!')
@@ -89,7 +91,10 @@ test('the new share\'s password stays masked on the page and is copied without b
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
['password' => 'violet-orbit-canyon'],
);
$this->withSession(['share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')]]);
$this->withSession([
'created_shares' => [$share->token],
'share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')],
]);
$page = ready(visit(route('share.created', $share, false)));
@@ -109,6 +114,8 @@ test('the new share\'s password stays masked on the page and is copied without b
test('a new share\'s QR code opens in a dialog and saves as a PNG', function () {
$share = Share::factory()->withPassword()->create();
$this->withSession(['created_shares' => [$share->token]]);
$page = ready(visit(route('share.created', $share, false)));
$page->click('[data-test="show-qr-code"]')
@@ -138,6 +145,8 @@ test('the share sheet gets the link, and says so only when it fails for another
$share = Share::factory()->create();
$actions = "Alpine.\$data(document.querySelector('[data-test=share-actions]'))";
$this->withSession(['created_shares' => [$share->token]]);
$page = ready(visit(route('share.created', $share, false)));
// Shown only where the browser has a share sheet.
+110
View File
@@ -95,6 +95,8 @@ test('the drop zone hides its burst again once the drag leaves', function () {
test('the QR dialog holds its code inside the box, and Escape returns focus to the button that opened it', function () {
$share = Share::factory()->create();
$this->withSession(['created_shares' => [$share->token]]);
$page = ready(visit(route('share.created', $share, false)));
$page->click('[data-test="show-qr-code"]');
@@ -242,3 +244,111 @@ test('the admin settings page has six sections, each a card headed by an h2', fu
expect($result['cardCount'])->toBe(6);
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();
});
+2 -2
View File
@@ -151,7 +151,7 @@ test('the shares list excludes the private text from its file count and shows it
->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 only their received bytes count as used space', function () {
$admin = User::query()->where('is_admin', true)->first();
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]);
ShareFile::factory()->for($completed)->create();
@@ -165,5 +165,5 @@ test('shares whose files are still uploading are neither listed nor counted, but
->assertViewHas('totalShares', 1)
->assertViewHas('activeShares', 1)
->assertViewHas('totalFiles', 1)
->assertViewHas('usedSpace', 1500);
->assertViewHas('usedSpace', 1000);
});
+22
View File
@@ -37,6 +37,28 @@ test('users can not authenticate with invalid password', function () {
$this->assertGuest();
});
test('the login limit per e-mail holds when each attempt claims another address', function () {
$user = User::factory()->create();
for ($i = 1; $i <= 20; $i++) {
$response = $this->withHeader('X-Forwarded-For', "203.0.113.$i")->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
if ($i === 1) {
expect($response->status())->not->toBe(429);
}
}
$this->withHeader('X-Forwarded-For', '203.0.113.21')->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
])->assertTooManyRequests();
$this->assertGuest();
});
test('users with two factor enabled are redirected to two factor challenge', function () {
if (! Features::canManageTwoFactorAuthentication()) {
$this->markTestSkipped('Two-factor authentication is not enabled.');
+14
View File
@@ -20,6 +20,20 @@ test('reset password link can be requested', function () {
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 () {
Notification::fake();
+79 -2
View File
@@ -7,6 +7,7 @@ use App\Models\Share;
use App\Models\ShareFile;
use App\Services\ShareService;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
@@ -54,14 +55,33 @@ test('upload page requires system password when configured', function () {
});
test('upload page accessible after system password verified', function () {
Setting::set('system_password', bcrypt('system-secret'));
$systemPassword = bcrypt('system-secret');
Setting::set('system_password', $systemPassword);
$response = $this->withSession(['system_password_verified' => true])
$response = $this->withSession(['system_password_verified' => hash('sha256', $systemPassword)])
->get(route('upload'));
$response->assertOk();
});
test('a verification of an earlier system password no longer opens the upload page', function () {
$oldSystemPassword = bcrypt('system-secret');
Setting::set('system_password', $oldSystemPassword);
$this->withSession(['system_password_verified' => hash('sha256', $oldSystemPassword)]);
Setting::set('system_password', bcrypt('new-system-secret'));
$this->get(route('upload'))->assertRedirect(route('system-password'));
});
test('a verification stored before 2.4.2 no longer opens the upload page', function () {
Setting::set('system_password', bcrypt('system-secret'));
$this->withSession(['system_password_verified' => true])
->get(route('upload'))
->assertRedirect(route('system-password'));
});
test('file upload creates share', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
@@ -74,6 +94,7 @@ test('file upload creates share', function () {
expect($share->isCompleted())->toBeTrue();
expect($share->files->pluck('original_name')->all())->toBe(['document.pdf']);
expect(session('pending_shares'))->not->toContain($share->token);
expect(session('created_shares'))->toBe([$share->token]);
});
test('registering files hands the browser each file\'s chunk URL, the share key and the file\'s nonce prefix', function () {
@@ -377,6 +398,27 @@ test('file upload blocks when storage is full', function () {
expect(ShareFile::query()->count())->toBe(0);
});
test('files declared by one session without any chunk do not block another session', function () {
Storage::fake('shares');
Setting::set('max_file_size', 200 * 1024 * 1024);
Setting::set('max_size_per_share', 1024 * 1024 * 1024);
Setting::set('max_files_per_share', 10);
Setting::set('max_storage_quota', 300 * 1024 * 1024);
Livewire::test(FileUploader::class)->call('registerFiles', [
['name' => 'a.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
['name' => 'b.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
['name' => 'c.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
])->assertHasNoErrors('files');
session()->flush();
$component = Livewire::test(FileUploader::class)
->call('registerFiles', [['name' => 'd.txt', 'size' => 1024, 'path' => null]]);
$component->assertHasNoErrors('files');
expect(ShareFile::query()->count())->toBe(4);
});
test('system password prompt verifies correct password', function () {
Setting::set('system_password', bcrypt('system-secret'));
@@ -394,3 +436,38 @@ test('system password prompt rejects incorrect password', function () {
->call('verify')
->assertHasErrors(['password']);
});
test('the system password prompt refuses a sixth attempt from the same address, even with the correct password', function () {
Setting::set('system_password', bcrypt('system-secret'));
$component = Livewire::test(SystemPasswordPrompt::class);
for ($i = 0; $i < 5; $i++) {
$component->set('password', 'wrong')
->call('verify')
->assertHasErrors(['password']);
}
$component->set('password', 'system-secret')
->call('verify')
->assertHasErrors(['password'])
->assertSee('Too many attempts')
->assertNoRedirect();
});
test('the global system password limit refuses the correct password from any address', function () {
Setting::set('system_password', bcrypt('system-secret'));
for ($i = 0; $i < 20; $i++) {
RateLimiter::hit('system-password', 60);
}
Livewire::test(SystemPasswordPrompt::class)
->set('password', 'system-secret')
->call('verify')
->assertHasErrors(['password'])
->assertSee('Too many attempts')
->assertNoRedirect();
expect(session()->has('system_password_verified'))->toBeFalse();
});
+5 -1
View File
@@ -26,7 +26,11 @@ test('every page is one page template, one h1 and the same width', function (Clo
})->with([
'upload' => [fn (TestCase $test): TestResponse => $test->get(route('upload'))],
'download' => [fn (TestCase $test): TestResponse => $test->get(route('share.download', Share::factory()->withPassword()->create()))],
'share created' => [fn (TestCase $test): TestResponse => $test->get(route('share.created', Share::factory()->create()))],
'share created' => [function (TestCase $test): TestResponse {
$share = Share::factory()->create();
return $test->withSession(['created_shares' => [$share->token]])->get(route('share.created', $share));
}],
'login' => [fn (TestCase $test): TestResponse => $test->get(route('login'))],
'forgot password' => [fn (TestCase $test): TestResponse => $test->get(route('password.request'))],
'reset password' => [fn (TestCase $test): TestResponse => $test->get(route('password.reset', 'token'))],
+115
View File
@@ -3,13 +3,17 @@
use App\Livewire\Admin\AdminSettings;
use App\Livewire\FileUploader;
use App\Livewire\ShareDownload;
use App\Models\Setting;
use App\Models\Share;
use App\Models\ShareFile;
use App\Models\User;
use App\Services\ShareService;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
// --- Session stores derived key, not plaintext password ---
@@ -35,6 +39,22 @@ test('session stores derived encryption key instead of plaintext password', func
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 ---
test('rate limiting blocks after 5 failed password attempts', function () {
@@ -89,6 +109,101 @@ test('rate limiter clears after successful password verification', function () {
expect(RateLimiter::remaining($rateLimitKey, 5))->toBe(5);
});
test('a share password attempt is counted before the password is checked', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare([
['file' => UploadedFile::fake()->create('file.txt', 100), 'relativePath' => null],
], [
'password' => 'correct-password',
]);
$ipKey = 'share-password:'.$share->token.'|127.0.0.1';
$attemptsSeenDuringCheck = new ArrayObject;
Hash::swap(new class(Hash::getFacadeRoot(), $ipKey, $attemptsSeenDuringCheck) implements Hasher
{
public function __construct(private Hasher $inner, private string $ipKey, private ArrayObject $seen) {}
public function info($hashedValue): array
{
return $this->inner->info($hashedValue);
}
public function make(#[SensitiveParameter] $value, array $options = []): string
{
return $this->inner->make($value, $options);
}
public function check(#[SensitiveParameter] $value, $hashedValue, array $options = []): bool
{
$this->seen[] = RateLimiter::attempts($this->ipKey);
return $this->inner->check($value, $hashedValue, $options);
}
public function needsRehash($hashedValue, array $options = []): bool
{
return false;
}
});
Livewire::test(ShareDownload::class, ['share' => $share])
->set('password', 'wrong-password')
->call('verifyPassword');
expect($attemptsSeenDuringCheck->getArrayCopy())->not->toBeEmpty()
->each->toBeGreaterThanOrEqual(1);
});
test('the share-wide limit refuses the correct password from any address', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare([
['file' => UploadedFile::fake()->create('file.txt', 100), 'relativePath' => null],
], [
'password' => 'correct-password',
]);
for ($i = 0; $i < 20; $i++) {
RateLimiter::hit('share-password:'.$share->token, 60);
}
Livewire::test(ShareDownload::class, ['share' => $share])
->set('password', 'correct-password')
->call('verifyPassword')
->assertHasErrors(['password'])
->assertSee('Too many attempts')
->assertSet('authenticated', false);
});
// --- The system password gate holds for Livewire updates ---
test('a file uploader snapshot from before the system password was set cannot register files in an unverified session', function () {
Storage::fake('shares');
preg_match_all('/wire:snapshot="([^"]+)"/', $this->get(route('upload'))->assertOk()->getContent(), $matches);
$snapshot = collect($matches[1])
->map(fn (string $raw): array => json_decode(htmlspecialchars_decode($raw, ENT_QUOTES), true))
->first(fn (array $snapshot): bool => str_contains($snapshot['memo']['name'] ?? '', 'file-uploader'));
expect($snapshot)->not->toBeNull();
Setting::set('system_password', Hash::make('system-secret'));
$this->flushSession();
$response = $this->withHeaders(['X-Livewire' => '1'])
->postJson(app('livewire')->getUpdateUri(), [
'components' => [[
'snapshot' => json_encode($snapshot),
'updates' => [],
'calls' => [['method' => 'registerFiles', 'params' => [[['name' => 'file.bin', 'size' => 1024]]], 'path' => '']],
]],
]);
$response->assertRedirect(route('system-password'));
expect(Share::query()->count())->toBe(0);
});
// --- Share password minimum length ---
test('share password must be at least 8 characters', function () {
@@ -1,9 +1,41 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Livewire\Livewire;
/**
* Drop the guards and the session store, so the next request authenticates from its cookie only.
*/
function forgetRequestState(): void
{
Auth::forgetGuards();
app('session')->forgetDrivers();
app()->forgetInstance('session.store');
test()->flushSession();
}
/**
* Sign the admin in on a database session, use it once as an admin would, and return its ID.
*/
function signedInAdminSessionId(User $admin): string
{
config(['session.driver' => 'database']);
$sessionId = test()->post(route('login.store'), ['email' => $admin->email, 'password' => 'Old-Password-1!'])
->assertRedirect()
->getCookie(config('session.cookie'))
->getValue();
forgetRequestState();
test()->withCookie(config('session.cookie'), $sessionId)->get(route('admin.dashboard'))->assertOk();
forgetRequestState();
return $sessionId;
}
test('password can be updated', function () {
$user = User::factory()->create([
'password' => Hash::make('password'),
@@ -38,3 +70,65 @@ test('correct password must be provided to update password', function () {
$response->assertHasErrors(['current_password']);
});
test('changing the password signs the user\'s other sessions out', function () {
$admin = User::factory()->admin()->create(['password' => Hash::make('Old-Password-1!')]);
$sessionId = signedInAdminSessionId($admin);
Livewire::actingAs($admin)->test('pages::settings.password')
->set('current_password', 'Old-Password-1!')
->set('password', 'New-Password-2!xY')
->set('password_confirmation', 'New-Password-2!xY')
->call('updatePassword')
->assertHasNoErrors();
forgetRequestState();
$this->withCookie(config('session.cookie'), $sessionId)
->get(route('admin.dashboard'))
->assertRedirect(route('login'));
});
test('resetting the password signs the user\'s sessions out', function () {
$admin = User::factory()->admin()->create(['password' => Hash::make('Old-Password-1!')]);
$sessionId = signedInAdminSessionId($admin);
// The reset comes from a browser that is not signed in, without the session's cookie.
$this->defaultCookies = [];
$this->post(route('password.update'), [
'token' => Password::broker()->createToken($admin),
'email' => $admin->email,
'password' => 'New-Password-3!xY',
'password_confirmation' => 'New-Password-3!xY',
])->assertSessionHasNoErrors();
forgetRequestState();
$this->withCookie(config('session.cookie'), $sessionId)
->get(route('admin.dashboard'))
->assertRedirect(route('login'));
});
test('the session that changes the password stays signed in', function () {
$admin = User::factory()->admin()->create(['password' => Hash::make('Old-Password-1!')]);
$sessionId = signedInAdminSessionId($admin);
preg_match('/wire:snapshot="([^"]+)"/', $this->withCookie(config('session.cookie'), $sessionId)->get(route('user-password.edit'))->assertOk()->getContent(), $matches);
forgetRequestState();
$this->withCookie(config('session.cookie'), $sessionId)
->withCredentials()
->withHeaders(['X-Livewire' => '1'])
->postJson(app('livewire')->getUpdateUri(), [
'components' => [[
'snapshot' => htmlspecialchars_decode($matches[1], ENT_QUOTES),
'updates' => ['current_password' => 'Old-Password-1!', 'password' => 'New-Password-2!xY', 'password_confirmation' => 'New-Password-2!xY'],
'calls' => [['method' => 'updatePassword', 'params' => [], 'path' => '']],
]],
])
->assertOk();
expect(Hash::check('New-Password-2!xY', $admin->refresh()->password))->toBeTrue();
forgetRequestState();
$this->withCookie(config('session.cookie'), $sessionId)
->get(route('admin.dashboard'))
->assertOk();
});
@@ -74,3 +74,40 @@ test('correct password must be provided to delete account', function () {
expect($user->fresh())->not->toBeNull();
});
test('the only admin cannot delete their account', function () {
$admin = User::query()->where('is_admin', true)->sole();
$this->actingAs($admin);
Livewire::test('pages::settings.delete-user-form')
->set('password', 'password')
->call('deleteUser')
->assertHasErrors(['password'])
->assertNoRedirect();
expect($admin->fresh())->not->toBeNull();
expect(auth()->check())->toBeTrue();
});
test('an admin can delete their account while another admin remains', function () {
$admin = User::factory()->admin()->create();
$this->actingAs($admin);
Livewire::test('pages::settings.delete-user-form')
->set('password', 'password')
->call('deleteUser')
->assertHasNoErrors()
->assertRedirect('/');
expect($admin->fresh())->toBeNull();
});
test('the only admin\'s account card explains why it cannot be deleted instead of offering the button', function () {
$this->actingAs(User::query()->where('is_admin', true)->sole());
Livewire::test('pages::settings.delete-user-form')
->assertSee('You are the only admin, so this account cannot be deleted.')
->assertDontSee('data-test="delete-user-button"', false);
});
+16
View File
@@ -2,6 +2,7 @@
use App\Livewire\SetupWizard;
use App\Models\User;
use Illuminate\Validation\Rules\Password;
use Livewire\Livewire;
test('setup wizard renders when no admin exists', function () {
@@ -38,6 +39,21 @@ test('setup wizard creates admin user', function () {
expect($admin->is_admin)->toBeTrue();
});
test('the setup wizard holds the admin password to the password defaults', function () {
User::query()->where('is_admin', true)->delete();
Password::defaults(fn () => Password::min(12)->mixedCase()->symbols());
Livewire::test(SetupWizard::class)
->set('name', 'Admin User')
->set('email', 'admin@example.com')
->set('password', 'password123')
->set('password_confirmation', 'password123')
->call('createAdmin')
->assertHasErrors(['password']);
expect(User::query()->exists())->toBeFalse();
});
test('setup wizard validates required fields', function () {
User::query()->where('is_admin', true)->delete();
+28 -5
View File
@@ -12,7 +12,7 @@ test('the share created page offers the link as a QR code and through the share
$url = route('share.download', $share);
$svg = app(QrCodeService::class)->svg($url);
$response = $this->get(route('share.created', $share));
$response = $this->withSession(['created_shares' => [$share->token]])->get(route('share.created', $share));
$response->assertOk()
->assertSee($url, false)
@@ -30,7 +30,8 @@ test('the share created page offers the link as a QR code and through the share
test('the QR code dialog reminds that a protected share also needs its password', function () {
$share = Share::factory()->withPassword()->create();
$this->get(route('share.created', $share))
$this->withSession(['created_shares' => [$share->token]])
->get(route('share.created', $share))
->assertOk()
->assertSee('Recipients also need the password.');
});
@@ -38,7 +39,10 @@ test('the QR code dialog reminds that a protected share also needs its password'
test('the uploader who just set the password can copy it beside the link, without it being on screen', function () {
$share = Share::factory()->withPassword()->create();
$response = $this->withSession(['share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')]])
$response = $this->withSession([
'created_shares' => [$share->token],
'share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')],
])
->get(route('share.created', $share));
$response->assertSee('data-test="share-password"', false)
@@ -52,7 +56,11 @@ test('the uploader who just set the password can copy it beside the link, withou
test('the password is not shown without a flash for this share', function (?string $flashedFor) {
$share = Share::factory()->withPassword()->create();
$session = $flashedFor === null ? [] : ['share_password' => ['token' => $flashedFor, 'password' => Crypt::encryptString('violet-orbit-canyon')]];
$session = ['created_shares' => [$share->token]];
if ($flashedFor !== null) {
$session['share_password'] = ['token' => $flashedFor, 'password' => Crypt::encryptString('violet-orbit-canyon')];
}
$response = $this->withSession($session)->get(route('share.created', $share));
@@ -75,8 +83,23 @@ test('the Private text stat shows for a share with text, and the Files stat excl
'the secret note',
);
$response = $this->get(route('share.created', $share));
$response = $this->withSession(['created_shares' => [$share->token]])->get(route('share.created', $share));
$response->assertOk()
->assertSeeInOrder(['Files', '2', 'Private text']);
});
test('the share created page is not found for a session that did not create the share', function () {
$share = Share::factory()->withPassword()->create();
$this->get(route('share.created', $share))->assertNotFound();
});
test('the share created page is not found once the share is no longer reachable', function (Share $share) {
$this->withSession(['created_shares' => [$share->token]])
->get(route('share.created', $share))
->assertNotFound();
})->with([
'expired' => fn () => Share::factory()->expired()->create(),
'at its download limit' => fn () => Share::factory()->withMaxDownloads(1)->create(['download_count' => 1]),
]);
+71 -2
View File
@@ -18,6 +18,15 @@ test('share download page renders for valid share', function () {
$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 () {
$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');
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;
expect($zip->open($zipPath))->toBeTrue();
@@ -111,6 +123,46 @@ test('zip download streams a valid archive with every file\'s original content',
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 () {
Storage::fake('shares');
$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',
]);
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 () {
Storage::fake('shares');
$share = createShareWithFiles(maxDownloads: 2);
@@ -368,7 +434,10 @@ test('the zip of a mixed share has only the files, not the private text', functi
);
$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;
expect($zip->open($zipPath))->toBeTrue();
+14
View File
@@ -1,6 +1,7 @@
<?php
use App\Models\Setting;
use App\Models\Share;
use App\Models\ShareFile;
use App\Services\ShareService;
use Illuminate\Support\Facades\Storage;
@@ -31,6 +32,19 @@ test('a chunk for a pending share this session started is stored', function () {
expect($file->refresh()->uploaded_chunks)->toBe(1);
});
test('a chunk that takes the stored bytes beyond the storage quota returns 422', function () {
Storage::fake('shares');
config(['uploads.chunk_size' => 4]);
Setting::set('max_storage_quota', 100);
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
Share::factory()->create(['total_size' => 98]);
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, false), [$file->share->token]);
$response->assertStatus(422);
expect($file->refresh()->uploaded_chunks)->toBe(0);
});
test('a chunk for a pending share another session started returns 404', function () {
Storage::fake('shares');
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
+5 -1
View File
@@ -166,9 +166,13 @@ test('phone', function (string $theme) use ($files) {
$password = visit(route('share.download', DemoData::PROTECTED_TOKEN, false));
shoot(shotPage($password, 'phone', $theme), 'phone', $theme, '02-password');
// Phones have no folder picker, so the page offers the ZIP there; desktop Chromium would offer the folder download.
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
shoot(shotPage($download, 'phone', $theme), 'phone', $theme, '03-download');
$page = shotPage($download, 'phone', $theme);
$page->script("Alpine.\$data(document.querySelector('[data-test=share-file-list]')).supported = false");
shoot($page, 'phone', $theme, '03-download');
$this->withSession(['created_shares' => [DemoData::DELIVERY_TOKEN]]);
$created = visit(route('share.created', DemoData::DELIVERY_TOKEN, false));
$page = shotPage($created, 'phone', $theme);
$page->click('[data-test="show-qr-code"]')
+25 -3
View File
@@ -230,14 +230,36 @@ test('a file that takes the share beyond the admin size per share is rejected',
->toThrow(ValidationException::class, 'Total file size exceeds the maximum allowed per share.');
});
test('a file that does not fit the storage quota beside files still uploading is rejected', function () {
test('a file that does not fit the storage quota beside the bytes already received is rejected', function () {
config(['uploads.chunk_size' => 10]);
Setting::set('max_storage_quota', 100);
Share::factory()->pending()->create(['total_size' => 60]);
$pending = Share::factory()->pending()->create(['total_size' => 60]);
ShareFile::factory()->for($pending)->uploading()->create(['file_size' => 60, 'uploaded_chunks' => 1]);
expect(fn () => $this->service->registerFile(null, 'file.txt', 50, null))
expect(fn () => $this->service->registerFile(null, 'file.txt', 95, null))
->toThrow(ValidationException::class, 'Storage is full. Please contact the administrator.');
});
test('a file still uploading without received chunks does not use up the storage quota', function () {
config(['uploads.chunk_size' => 10]);
Setting::set('max_storage_quota', 100);
$pending = Share::factory()->pending()->create(['total_size' => 60]);
ShareFile::factory()->for($pending)->uploading()->create(['file_size' => 60, 'uploaded_chunks' => 0]);
expect($this->service->registerFile(null, 'file.txt', 50, null))->toBeInstanceOf(ShareFile::class);
});
test('a chunk that takes the stored bytes beyond the storage quota is rejected', function () {
config(['uploads.chunk_size' => 4]);
Setting::set('max_storage_quota', 100);
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
Share::factory()->create(['total_size' => 98]);
expect(fn () => $this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, false)))
->toThrow(InvalidArgumentException::class, 'Chunk 0 exceeds the storage quota');
expect($file->refresh()->uploaded_chunks)->toBe(0);
});
test('chunks stored in order complete the file', function () {
config(['uploads.chunk_size' => 4]);
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+12 -7
View File
@@ -274,7 +274,7 @@
<li class="step">
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-gem"/></svg><svg class="icon" aria-hidden="true"><use href="#i-download"/></svg></span>
<h3 class="title">Download</h3>
<p>Your customer opens the link and downloads single files or everything as a ZIP. Expired shares delete themselves.</p>
<p>Your customer opens the link and downloads single files, everything as a ZIP, or all of it straight into a folder. Expired shares delete themselves.</p>
</li>
</ol>
</div>
@@ -291,7 +291,7 @@
<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-drive-folder-upload"/></svg></span>
<h3>Files and folders</h3>
<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 for files or a whole folder; 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 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-sticky-note-2"/></svg></span>
@@ -326,12 +326,17 @@
<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-counter-5"/></svg></span>
<h3>Download limits</h3>
<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>
<p>Close a share after a set number of downloads. The page tells recipients how many are left; a recipient's download counts once, and their hour for the rest of the files starts again with every 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-folder-zip"/></svg></span>
<h3>ZIP download</h3>
<p>Recipients download everything in one archive.</p>
<p>Recipients download everything in one archive, with its progress and size shown as it arrives.</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-download"/></svg></span>
<h3>Download into a folder</h3>
<p>For large shares: the files are saved one by one into a folder the recipient picks, in their folders, and a broken connection resumes where it stopped. Single files resume too. In Chrome and Edge on a computer.</p>
</article>
<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-delete-sweep"/></svg></span>
@@ -446,11 +451,11 @@
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"
width="1512" height="982" loading="lazy" decoding="async"
alt="The download page a recipient sees, listing the shared files and how many downloads are left">
alt="The download page a recipient sees, with the number of files and their size, the file list, the buttons to download everything into a folder or as a ZIP, and how many downloads are left">
</div>
</div>
</div>
<figcaption><strong>Your customer downloads</strong><span>Single files or everything as a ZIP, without an account.</span></figcaption>
<figcaption><strong>Your customer downloads</strong><span>Single files, everything into a folder or as a ZIP, without an account.</span></figcaption>
</figure>
</li>
<li>
@@ -523,7 +528,7 @@
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"
width="393" height="852" loading="lazy" decoding="async"
alt="The list of shared files on a phone, with the downloads left">
alt="The list of shared files on a phone, with their number and size and the downloads left">
</div>
</div>
<figcaption><strong>Download on the go</strong><span>Files and a ZIP of all of them.</span></figcaption>