A download limit is checked when an archive is ordered and again while it
is built, but it is only spent when the archive is collected. Nothing
about ordering or building moves the count, so every check along the way
sees an allowance that is still untouched.
That turns a prepared archive into a voucher. Order the same limited file
into ten archives and all ten pass, because at the point each one is
checked nothing has been taken yet. Collect them all and the file has
been downloaded ten times against a limit of one. The three endpoints are
independent of the interface that normally drives them, so this needs
nothing more than calling store() in a loop — and no timing luck at all,
since the archives can be collected minutes apart.
DownloadAllowance says of itself that six routes put a file's bytes on
the wire and that every one of them asks, precisely because there is no
choke point to put the rule in. The zip pair asked in the two places that
do not count and not in the one that does.
So the delivery re-checks what the archive holds, where the count
actually moves. Refusing is 403, matching the single-file download route
for the same situation. It is also the only one of the two candidates
that reaches the person: an archive is fetched by navigating to it, and
there is no error view for 422, so the message would be replaced by the
framework's generic "something is broken" page.
One refused file refuses the whole delivery, because nothing can be taken
out of a finished archive without building it again. Ordering the same
selection afresh is the way through — the build leaves the spent file out
and names it in skipped_files, which the poll already reports. This is
stricter than store(), which drops spent files from a selection and
refuses only when nothing survives: there, a selection can still be
narrowed, and here it cannot.
Checking costs nothing where nothing is limited. An unlimited file is
answered from its own column and never reaches a count.
Claiming the delivery is a conditional update now rather than a read
followed by a write. Two fetches of one archive arriving together both
saw delivered_at unset and both wrote a full set of downloads, counting a
single delivery twice — the same shape as the conditional increment that
guards a share link's max_downloads. Only the fetch that moves the column
logs anything; the other still receives the archive, which is the
existing rule that re-fetching one prepared zip is one delivery.
Two things this deliberately leaves alone. Simultaneous downloads of one
file can still both pass before either is logged: that race is documented
in DownloadAllowance, and closing it needs the counter column it explains
why it does not have. And an archive already delivered stays fetchable
for its 24 hours even once the limit is spent — one delivery, re-fetched,
which is what that rule is for.
An archive built before the job recorded its contents is handed over the
way it always was, without this check. What it holds can only be guessed
at by resolving the selection a second time, and guessing is exactly what
must not decide a refusal: the same reconstruction refuses over files the
archive does not hold and misses files it does. Those rows stop existing
within a day or two of an upgrade, and until then they behave as they did
before this change rather than worse.
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.
This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.
Free software under the GNU General Public License v2, or (at your
option) any later version.