Skip to content

Make spending a public link's view the thing that guards it - #813

Merged
blaipr merged 1 commit into
mainfrom
fix/spending-a-link-view-is-what-guards-it
Aug 18, 2026
Merged

Make spending a public link's view the thing that guards it#813
blaipr merged 1 commit into
mainfrom
fix/spending-a-link-view-is-what-guards-it

Conversation

@blaipr

@blaipr blaipr commented Aug 18, 2026

Copy link
Copy Markdown
Member

A public link issued for a single view could be followed twice.

The counter increment was already atomic — countViews + 1 in SQL, which is what stops a lost update — but the limit was checked in PHP, against a row that had already been read:

if ($publicLink !== null
    && time() < $publicLink->getDateExpire()
    && $publicLink->getCountViews() < $publicLink->getMaxCountViews()   // read
) {
    $this->publicLinkService->addLinkView($publicLink);                 // then write

Two requests arriving together both read countViews = max - 1, both pass, and both are served. An atomic increment prevents the counter going wrong; it never prevented exceeding the limit.

The guard moves into the update

addLinkView() now carries both conditions:

WHERE hash = :hash AND COALESCE(countViews, 0) < maxCountViews AND dateExpire > :now

and reports whether a row was affected. The controller serves only if the view was actually spent, so the moment the link is charged for and the moment it is handed over are the same moment.

COALESCE because countViews is nullable: a NULL would make the comparison NULL and refuse every link nobody had followed yet.

The password-reset flow already does exactly this — toggleUsedByHash() is a conditional update (… AND used = 0) that throws when it affects nothing, before the password is changed. The pattern was in the codebase; this path predated it.

Tests

The race is pinned deterministically, without concurrency. A second simultaneous request is precisely a process holding a stale copy of the row, so the test reads the link, exhausts it through the normal path, and then presents that copy. Under the old code the copy still said "views remaining" and spent the link past its limit.

The same for an expiry that passes while a request is in flight.

followLink() in the round-trip test replicates ViewLinkController, so it follows the controller's new shape — the guard is the return value of spending the view, not a pair of comparisons before it.

Reverting the SQL guard fails four tests, one of which shows an exhausted link handing back the plaintext password.

A harness note

Moving the guard out of PHP broke two refusal tests in ViewLinkRefusalsTest, correctly: they run on the mocked-database harness, which answers every update with "one row affected", so with the check now in SQL the double stopped refusing anything. The double now answers the way a server would. The link read had to move into the same closure, because databaseQueryResolver is consulted first and short-circuits the mapper resolvers.

Known limit, left in place

The useInfo usage list is still assembled from the row as it was read, so two views landing together record one entry between them. The counter is exact — that is what decides access — while the list is a log. Recorded at the call site.

3965 unit tests pass; PHPStan level 6 on src and PHPCS clean.

A public link issued for a single view could be followed twice.

The counter increment was already atomic — `countViews + 1` in SQL, which is
what stops a lost update — but the limit was checked in PHP, against a row that
had already been read:

    && $publicLink->getCountViews() < $publicLink->getMaxCountViews()   // read
    $this->publicLinkService->addLinkView($publicLink);                 // then write

Two requests arriving together both read `countViews = max - 1`, both pass, and
both are served. An atomic increment keeps the counter right; it never stopped
the limit being exceeded.

`addLinkView()` now carries both conditions — `COALESCE(countViews, 0) <
maxCountViews AND dateExpire > :now` — and reports whether a row was affected,
so the moment the link is charged for and the moment it is handed over are the
same moment. COALESCE because `countViews` is nullable, and a NULL comparison
would refuse every link nobody had followed yet.

The password-reset flow already does exactly this: `toggleUsedByHash()` is a
conditional update that throws when it affects nothing, before the password is
changed. The pattern was in the codebase; this path predated it.

The race is pinned without concurrency. A second simultaneous request is
precisely a process holding a stale copy of the row, so the test reads the link,
exhausts it through the normal path, and presents that copy — which under the
old code still said "views remaining" and spent the link past its limit.

Two refusal tests in ViewLinkRefusalsTest broke, correctly: they run on the
mocked-database harness, which answers every update with one row affected, so
with the check in SQL the double stopped refusing anything. It now answers the
way a server would. The link read moved into the same closure, because
`databaseQueryResolver` is consulted first and short-circuits the mapper ones.

The `useInfo` list is still assembled from the row as read, so two views landing
together record one entry between them. The counter is exact — that is what
decides access — while the list is a log. Recorded at the call site.
@blaipr
blaipr merged commit 0409dcd into main Aug 18, 2026
8 checks passed
@blaipr
blaipr deleted the fix/spending-a-link-view-is-what-guards-it branch August 18, 2026 07:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant