Count a share's download limit per recipient, not per file

With a download limit of 1, downloading one file of a share with
several files deleted the share and the files not yet downloaded.
ShareService::recordDownload() ran at the end of every download
request, one file or the ZIP alike, and deleted the share as soon as
download_count reached max_downloads. It has worked that way since
the first commit.

- One recipient's visit is one download. The first file or ZIP a
  session downloads is counted when it starts, in one conditional
  UPDATE that also checks the limit, so two recipients starting at
  once can't both take the last download. The session remembers the
  time, and for ShareService::DOWNLOAD_WINDOW_MINUTES (60) it may
  start more downloads of the share without counting them, even once
  the limit is reached. The claim happens in the controller before
  streaming, because the session is saved before the body is sent,
  and after the share key is resolved, so a request without the key
  uses nothing.
- A share at its limit is closed to everyone else at once. The hourly
  cleanup deletes it 24 hours after shares.last_downloaded_at (new
  column), since a ZIP opens each file only when it reaches it and a
  large download can outlast the hour.
- The download page of a limited share says how many downloads are
  left, switches to "You have 1 hour" on the first press (Alpine, as
  a download link does not render the page again), and shows the time
  left on the next visit.
- The admin dashboard shows "2 of 3 downloads", marks shares at their
  limit "Download limit reached" and leaves them out of Active Shares.
- Tests: the regression (3 files, limit 1: every file and the ZIP
  download, counted once), another recipient, the end of the hour,
  the last download going to one of two recipients, requests refused
  before streaming, unlimited shares, the page notes in PHP and in
  Chromium, the dashboard, and the cleanup at 23 and 25 hours. The
  tests of recordDownload() and of the instant deletion are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 15:35:13 +02:00
co-authored by Claude Opus 5
parent c1906d2009
commit e057cada3d
15 changed files with 344 additions and 59 deletions
+3
View File
@@ -10,10 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Docker installs updated from 2.0 answered every upload with "409 Conflict". The example `docker-compose.yml` mounted the SQLite volume over all of `/app/database`, which hid the image's new migration, so it never ran. The container now adds the migrations the volume is missing before migrating, so existing compose files keep working.
- A share with several files and a download limit was deleted as soon as one file was downloaded: every single file counted as a whole download. Now one recipient's visit counts once, and they have 1 hour to download all the files and the ZIP. Two recipients who start at the same moment can no longer both get the last download.
### Changed
- `docker-compose.example.yml` mounts `sealshare_database` at `/app/database/sqlite` and sets `DB_DATABASE` to the file in it. To switch an existing install, mount the same volume there and set `DB_DATABASE: /app/database/sqlite/database.sqlite` in both services; the database is kept.
- A share that reached its download limit is closed at once, but deleted by the hourly cleanup 24 hours after its last download instead of immediately, so downloads still running can finish. Until then its files still count towards the storage quota.
- The download page of a share with a download limit says how many downloads are left, or how long the recipient can still download. The admin dashboard shows downloads as "2 of 3 downloads", marks shares at their limit as "Download limit reached" and no longer counts them as active.
## [2.1.0] - 2026-09-16