Both were applied by hand during an incident and existed only in /etc on one
server. server-bootstrap.sh generates the ladl.link vhost, and its version had no
caching at all — so rebuilding the box would have silently restored a ~44 req/s
ceiling on a link being handed to a very large audience, with nothing in git
recording that the cache should exist. Laravel log rotation had the same problem:
none was configured anywhere, and logs had reached 808M across apps.
Now tracked under deploy/ and installed by bootstrap:
nginx/conf.d/ladill-link-cache.conf FastCGI cache zone
nginx/snippets/ladill-link-cached.conf cached path, 10m
nginx/snippets/ladill-link-cached-short.conf same, 60s
nginx/snippets/ladill-link-public-cache.conf which routes are cached
nginx/snippets/ladill-link-hot-pages.conf per-campaign slugs
logrotate/ladill-laravel
Campaign slugs live in their own snippet so a hot link can be added or retired
with an nginx reload instead of a deploy, and bootstrap seeds that file only when
absent so it is never clobbered.
Caching stays deliberately narrow. ladl.link also serves the admin app, and the
cached path strips Set-Cookie, so anything reaching it would lose its session.
Only /q/, */bookshop-* and listed campaign pages are cached; SSO and dashboard
fall through untouched — verified after this change: both still set cookies and
carry no cache header.
Generated vhosts now include the snippet from the start, and an existing vhost is
patched in place (with a timestamped backup) rather than rewritten, since it may
carry local edits. Both paths are idempotent.
Applied to production as part of this change, so the committed config is the
config that is running rather than an untested transcription: page 12ms,
cover 14ms, vanity URL 11ms, all cache HIT.
Co-Authored-By: Claude <noreply@anthropic.com>
Every click ran three UPDATEs against the same short_links row plus one against
the owner's link_wallets row, all inside one transaction. Two consequences:
- A popular link serialised its entire traffic through a single row lock.
- Locking two rows per transaction meant concurrent clicks on different links of
the same owner could take them in opposite orders, so MySQL deadlocked.
Production logged 152 deadlocks, 146 of them on one row, surfacing as HTTP 500s to
real visitors. With a link now being handed to a very large audience, this is the
next thing that falls over, and it is independent of page weight.
Counters are now buffered and flushed instead of written inline:
- link_clicks stays the source of truth. It is insert-only, so it has no
contention no matter how popular a link gets.
- Increments accumulate in Redis hashes and drain via link:flush-click-counters,
scheduled every minute. The database sees one UPDATE per link per flush instead
of four per click.
- Draining reads and deletes each hash atomically, so a click landing mid-flush is
counted in that batch or the next, never dropped.
- --recount rebuilds totals from link_clicks after a Redis loss.
If the buffer is unavailable the recorder writes through to the database, keeping
today's behaviour (and today's contention) rather than losing counts. Nothing in
the click path may break the redirect the visitor actually came for, so the whole
recorder is wrapped and failures are logged.
Storage sits behind ClickCounterStore so the buffered path is covered by tests
rather than silently falling back to write-through when no Redis is present.
Also adds the (short_link_id, ip_hash, clicked_at) index. The unique-click check
filters on ip_hash but only (short_link_id, clicked_at) was indexed, so every
click scanned all of that link's rows in the dedupe window — cost growing with the
link's own popularity, the worst possible shape for a link going viral.
Tests: 11 new. The load-bearing one asserts the request path issues no UPDATE
against short_links or link_wallets at all.
Pre-existing suite failures go from 13 to 9; none of the remainder are related.
Co-Authored-By: Claude <noreply@anthropic.com>
Public order/pay POSTs hit ladl.link first; satellite CSRF tokens cannot bind
to Link (or platform/satellite) sessions, which returned 419 and showed
"Could not start checkout." Except public checkout paths and return
access_code from owner-gateway Paystack init. Sync contained Paystack sheet.
Paystack blocks iframes, so checkout uses a popup beside the sheet/modal chrome. Opening about:blank on the user tap keeps that window from being blocked after the async initiate response.
Paystack checkout.paystack.com returns X-Frame-Options: SAMEORIGIN, so the shared sheet opened empty. Keep mobile sheet/desktop modal chrome, launch checkout in a named window with a Continue CTA fallback, and escape payment-return via window.opener or top.
Standardize customer-facing Ladill Pay checkout shells across products, open them on all viewports, and escape iframe callbacks back to the product flow.
act_runner v0.2.x does not expose gitea.run_attempt, so release archive and
workspace paths rendered with a trailing dash. Use gitea.run_id only, matching
the Meet/CRM deploy workflows.
Move destination editing to the top of the link page, clarify that the short URL never changes, prevent caches from pinning old redirects, and test that later destination updates take effect immediately.
Forward query strings and path suffixes to the live destination, resolve public hosts from config/custom domains instead of hardcoding ladl.link, and keep proxy Location rewrites on the visitor host.
Proxied ladl.link/{slug}/pay requests carry CSRF tokens from Mini HTML, which cannot bind to Link's session — the same 419 failure mode as event registration.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the once-per-session sessionStorage guard (which suppressed the loading
screen after its first appearance) with entry-aware logic: show on app entry
and reloads, suppress on internal link navigations and back/forward.
Resolve countries from CDN headers first, then MaxMind GeoLite2 via visitor IP, with an artisan command to download and refresh the database.
Co-authored-by: Cursor <cursoragent@cursor.com>
Route /{slug}/speaker/{token} through the Events app so speaker invite links resolve instead of hitting the account QR hub or a broken proxy patch.
Co-authored-by: Cursor <cursoragent@cursor.com>
Registration forms post to ladl.link first; Events-issued CSRF tokens cannot
validate against the Link app session and caused token mismatch errors.
Co-authored-by: Cursor <cursoragent@cursor.com>
Event signup from ladl.link sends application/json; re-encoding as form
data dropped the payload and caused failed registrations to redirect to
ladl.link/undefined.
Co-authored-by: Cursor <cursoragent@cursor.com>
ladl.link was proxying registered/* through ladill.com first, where registrations
do not exist, causing 404s after successful event signup.
Co-authored-by: Cursor <cursoragent@cursor.com>
Event pages like sales-webinar live on events.ladill.com, but ladl.link
only proxied to ladill.com/q/* — fix by retrying events.ladill.com when
the platform hub has no matching code.
Co-authored-by: Cursor <cursoragent@cursor.com>
Proxy transfer paths directly to transfer.ladill.com and stop following ladl.link redirects server-side so recipient links resolve.
Co-authored-by: Cursor <cursoragent@cursor.com>