The previous run failed to push its image: the token behind FJ_TOKEN
had been regenerated, and the secret now holds the new one.
Assisted-by: Claude:opus-5-5
Forgejo reserves names starting with FORGEJO_, and since runner v13
secrets.FORGEJO_TOKEN, _USER and _REGISTRY no longer reach the job as
the values set on the repository: Kaniko logged in with the wrong
credentials and every image push failed with UNAUTHORIZED.
The registry host and user are not secret (they are in every image
name), so write them in the workflow; the token comes from FJ_TOKEN,
as in datacenterbingo.
Assisted-by: Claude:opus-5-5
rack 3.2.6 has a denial of service in the multipart parser
(CVE-2026-125111, GHSA-5v64-555j-wjxm): parsed data stays in the scan
buffer, so a crafted upload makes memory grow without bound. The app
is reachable from the internet and accepts multipart forms.
Only the lock changes; the Gemfile already allows ~> 3.2.
Assisted-by: Claude:fable-5-2
There was no way to put a file up for public use short of running an
S3 client with the uploader key. Add a "files" area to the admin:
upload one or more files, copy the link, edit the note, replace the
contents under the same link, delete. It has its own read/write/delete
permission flags like the other areas, and every action is audited.
The objects go to the storage stack's public bucket and are served by
Garage at s3.asxp.io/public/<key>, not by this app. The app's key can
only put and delete there, so a public_files table is the record of
what was published. Keys get a random prefix: links are not guessable
and names never collide.
That host is same-site with the admin, so a file that can carry script
must not render there. Only a fixed list of types (images, PDF, plain
text, audio, video) is served inline; the rest, HTML and SVG included,
is stored as an attachment. Image metadata is stripped by default, as
for the bingo photos, because of GPS tags.
A per-file limit and a total quota keep the small disk on asxpio-ops
from filling up.
Assisted-by: Claude:fable-5-2
CLAUDE.md and SECRETS.md still described MinIO: the endpoint, the
bootstrap script and the kind of key. Describe Garage, and say how the
deploy turns the ASXPIO_S3_* secrets into a Garage key.
Assisted-by: Claude:fable-5-2
The storage stack replaces MinIO, abandoned upstream, with Garage. Point
the app at garage:3900.
Garage only accepts keys in its own format, so hash the existing
ASXPIO_S3_* secrets into it the way storage's bootstrap does. The
secrets stay as they are; a pair already in Garage's format is used
unchanged.
Assisted-by: Claude:fable-5-2
Payments often arrive before they are recorded, so stamping paid_at
with the click time skews received-per-month and days-to-pay stats.
Mark paid now takes an optional date, and a paid invoice gets a form
to correct it. Past days are stored at 12:00 UTC so the date holds in
any timezone; future and malformed dates are refused. The PDF does not
print the date, so nothing is re-rendered.
Assisted-by: Claude:opus-5-5
Claude-Session: https://claude.ai/code/session_01TCc5TvWnAs5xPvGMQXTJw3
Opening /admin/ logged out stored next=/admin/ in the login form, and a
successful login then redirected to /admin/, which has no route (404).
Redirect trailing-slash admin URLs to the slashless route with a 301,
and drop trailing slashes from the post-login target too.
Assisted-by: Claude:opus-5-5
.dockerignore left out all of bin/, which only held bin/test until now.
The asxpio-monitor container runs bin/monitor from the image and
crash-looped with "No such file or directory -- bin/monitor". Exclude
just bin/test.
Assisted-by: Claude:opus-5-5
datacenterbingo gets a public page showing whether each datacenter's
network answers. The probing lives here, next to the schema and the
admin, since the public site only has a read-only role.
bin/monitor runs as a second container from the same image. Once a
minute it opens one TCP connection to every active target (a handshake
or a refusal both count as answered) and folds the results into
5-minute rows per datacenter, plus a per-bucket row for control hosts in
Russia, so a lost vantage point isn't mistaken for dark sites. Rows are
kept 30 days. Targets are managed at /admin/bingo/monitor; only hosts a
public source ties to that one site belong there.
Migration 017 also grants the new tables to the bingo role, so the
public site can read them without a storage redeploy.
Assisted-by: Claude:opus-5-5
The previous runs died on the runner, not in the code: its nix-store
volume no longer matched nixos/nix:latest. The runner now uses
nixos/nix:2.35.3 with a volume seeded from that image.
Assisted-by: Claude:opus-5-5
The runner mounts a persistent nix-store volume over /nix. The current
nixos/nix image makes /etc/nix/nix.conf a symlink into /nix/store, and
the volume does not hold that path, so the link dangles and appending
to it fails before checkout: "/etc/nix/nix.conf: No such file or
directory".
Pass the settings in NIX_CONFIG instead. Without a config file Nix also
falls back to build-users-group = nixbld, which the container lacks, so
set it empty and turn the sandbox off, as the image config does.
Assisted-by: Claude:opus-5-5
VK's own datacenter ИЦВА in Novosergiyevka is joining the card after
the drone strike of 18 October. Give it a provider slug so the filter
can show it, and guess it from the VK/ВКонтакте/М100 operator names.
Assisted-by: Claude:opus-5-5
The bingo photos sat under bingo/ in the invoice bucket. Store them in
BINGO_S3_BUCKET (datacenterbingo-photos in prod, created and pre-filled
by the storage repo with the same keys) and fall back to S3_BUCKET when
unset, so dev and tests need nothing new.
Assisted-by: Claude:opus-5
The public card now runs from its own repo and container, with a
Traefik router of its own. Drop BingoWeb, its views, icons, Host
dispatch and the asxpio-bingo router here; keep the admin, the schema
and the photo pipeline, which the new site reads through a read-only
role.
BINGO_HOST stays for IndexNow pings and the admin's site link.
Assisted-by: Claude:opus-5
Part of the list was collected and verified with Claude, and several
entries came from the hackyourmom data-center map (Cat Eyes OSINT data).
Say so in the footer of the card and data-center pages, in all three
languages, and note that each entry was re-verified against operator
sites and the press.
Assisted-by: Claude:opus-5
Add DataSpace, Cloud.ru, X5 Group, КРОК, Stack Group and Beeline to the
provider list (with operator patterns for the automatic guess), ahead of
adding their verified sites.
Assisted-by: Claude:opus-5
Let visitors tick providers (Yandex, IXcellerate, RTK-DC, Sber, …) and
get the usual 5×5 card made of only those providers' datacenters, first
25 in ranking order, with lines scored as normal. A selection with fewer
than 25 sites leaves the rest of the card empty, so the card is always
5×5. Each provider chip shows crossed/total for the current view.
Datacenters get a provider slug, backfilled from the free-text operator,
guessed for new rows and editable in the admin. Filtered URLs are
noindex with canonical to the plain card.
Assisted-by: Claude:opus-5
The card led with construction projects (Sber Balakovo at #1, MOS7,
MOS11, Yandex Vladimir-2), so the first thing visitors saw was not what
actually runs today. Make the operational-only card the bare URL and
move the full list to ?view=all. The variant is noindex with canonical
to the bare page, old ?view=operational links still land on the default,
and the admin dashboard tile follows the public default.
Assisted-by: Claude:opus-5
The facts sentence reused the dialog's compact label, so it read
"Capacity: 63 MW · design." Put the capacity type in parentheses
when it appears in prose.
Assisted-by: Claude:opus-5
A newly recorded incident only showed up in search once a crawler
happened to come back. IndexNow lets Bing, Yandex and the other
participating engines know about changed URLs right away.
Bingo::IndexNow posts the card URLs plus the affected datacenter pages
to api.indexnow.org, in a background thread so admin saves don't wait.
Incidents ping their datacenter. Edits ping the datacenter, or every
page when positions move (create, delete, reorder, sort). A deleted
datacenter's pages are included so the engines see the 404. BingoWeb
serves the key at /<key>.txt.
The key comes from the INDEXNOW Forgejo secret, written to .env as
INDEXNOW_KEY. With no key set this does nothing.
Assisted-by: Claude:opus-5
The datacenter pages carry only generated sentences, which is thin
content to rank on. Migration 015 adds about_ru/en/uk, edited in the
admin form and rendered as paragraphs below the facts.
There is deliberately no fallback to ru: Russian prose on the English
page would hurt more than an absent paragraph.
Assisted-by: Claude:opus-5
Everything lived on one URL per language, with each datacenter's
details and incidents inside a <dialog>. That page can rank for the
brand and a couple of head terms, but not for searches like
"Yandex Sasovo drone strike".
Add /:locale/dc/:slug for every datacenter, reserve included. Each page
has its own title (naming the latest incident when there is one), a
description built from the facts, canonical and hreflang links, the
photo as og:image, and JSON-LD Place + BreadcrumbList. It shows the
facts in a sentence or two, the incident list with sources, and
prev/next links by position. The slug never changes, so the URLs are
stable.
The card cells are now links to these pages. A plain click still opens
the dialog, while modified clicks open the page. The dialog and the
incident log link to the page too, and the sitemap lists every page in
all three languages. The layout takes title, description, alternates
and JSON-LD from the route, and the dialog and the page share the meta
and incident partials.
Assisted-by: Claude:opus-5
The edge already redirects http:// to https://, but without HSTS the
first request of every visit can still go out in the clear. Turn on
Rack::Protection's StrictTransport for the bingo app: one year, this
host only, no preload.
Assisted-by: Claude:opus-5
The "updated" date and the sitemap lastmod came from max(created_at),
so editing a datacenter, fixing an incident title or deleting an
incident left them stale. Google stops trusting lastmod once it is
seen to be wrong.
Migration 014 adds updated_at (backfilled from created_at). Saves set
it, renumbering bumps only the rows whose position actually moved, and
deleting an incident bumps its datacenter. The admin delete now goes
through the model so the hook runs. The sitemap gives lastmod as a
full timestamp and drops changefreq, which Google ignores.
Assisted-by: Claude:opus-5
Each Place carried its info_url as sameAs, but sameAs means "this is
the same entity", and the URL is an operator page or news article
about the datacenter. Use subjectOf with a CreativeWork instead.
Incidents were missing from the structured data entirely. Add them as
Events at the place (name, start date, source URL), and give the
ItemList a dateModified.
Assisted-by: Claude:opus-5
og:image pointed at the 1200px WebP dialog variant: no declared size,
a 16:9 frame that link previews crop unpredictably, and WebP isn't
rendered by every preview client. The alt text was just the site name.
Photo variants now include <slug>-<hex>-og.jpg, a centre crop to
1200x630, and the head declares its type and size. Migration 013 adds
photo_og so rows built before this keep using the WebP until "Rebuild
resized photos" is run. The alt text names the pictured datacenter and
city. resize and the new og_crop share one convert call.
Assisted-by: Claude:opus-5
The favicon was an emoji in a data: URI and /favicon.ico was a 404.
Google only shows a site icon in results when the favicon has a
crawlable URL, and the emoji looked different on every platform.
Add an SVG (a 5x5 card with a crossed-out diagonal), a 16/32/48 ICO
and a 180px apple-touch-icon under assets/bingo/, served by explicit
routes because public/ belongs to asxp.io.
Assisted-by: Claude:opus-5
/en/ answered 200 with the same page as /en. The canonical tag covered
it, but a 301 keeps crawlers and shared links on a single URL.
The All / Operational toggle links were rel="nofollow". The filtered
page is already noindex with a canonical back to the locale URL, so
nofollow on our own links only threw away internal link signals.
Assisted-by: Claude:opus-5
The h1 was just the brand ("Datacenter Bingo"), so the page's main
heading carried none of the terms people search for. Add a per-locale
tagline inside the h1 and shorten the lead paragraph so it no longer
repeats it.
Assisted-by: Claude:opus-5
The username field's attributes were emitted as one string through
<%= %>, which HTML-escapes the quotes, so the browser read the
placeholder as the literal "leave and dropped the rest. Write the
attributes in the template and test the rendered field.
Assisted-by: Claude:opus-5
Crypto options were limited to a hard-coded asset list, with one
address per coin in a side table. A payout address now carries
everything an invoice needs: ticker, name, QR URI scheme, amount
parameter, CoinGecko id, address and an active flag. Any coin can be
added, several addresses per ticker are fine, and known tickers fill the
QR and rate fields from the built-in templates.
The new-invoice form picks a payout address. The invoice snapshots the
ticker, address, name, scheme and amount parameter, and the PDF and QR
render from that copy, so editing, retiring or deleting a payout entry
never changes an issued invoice. "Fetch live" looks up the rate by the
payout address's CoinGecko id.
Migration 012 keeps the existing rows and backfills both the payout
metadata and the snapshots on existing crypto invoices.
Assisted-by: Claude:opus-5
Leaving the username empty when creating a user makes the account
invite-<hex>; the invitee picks the real username on the invite page
(taken names and the invite- prefix are refused). Without an email this
gives a plain invite link to share.
Assisted-by: Claude:opus-5
Add /admin/invoices/stats with a year filter: invoiced, received and
outstanding totals in GEL at each invoice's captured rate (the one
currency every invoice has), overdue count, average days to pay, money
received per month by payment date as a bar chart with the same figures
in a table, per-currency totals, top clients and crypto option usage.
Cancelled invoices are left out of every amount; hidden ones follow the
usual superuser-only visibility.
Assisted-by: Claude:opus-5
Invite and reset mails reused MAIL_FROM, whose display name is still
"IE Sergei Poljanski Contact Form" from the removed contact form. Keep
its verified address but use "asxp.io admin" as the name (overridable
with ADMIN_MAIL_NAME).
Assisted-by: Claude:opus-5
Replacing the paid button with the cancel/hide action block cut the old
markup at the first <% end %>, which belonged to the button's inner
if/else, leaving a stray </button></form><% end %> behind. show.erb no
longer compiled and every admin invoice page returned 500; nothing
rendered that page in tests.
Remove the leftovers, render the page in every state (pending, paid,
cancelled, hidden), and compile every view in a test so an unbalanced
template fails CI even when no test visits that page.
Assisted-by: Claude:opus-5
ADMIN_USER only seeds the first superuser on an empty table, so renaming
the secret afterwards had no effect and there was no other way to change
a login name. Superusers can now edit usernames in /admin/users,
including their own; sessions key on the user id and survive, duplicates
are refused, and the rename is audited.
Assisted-by: Claude:opus-5
Default crypto payout addresses lived in the CRYPTO_ADDRESSES and
LTC_ADDRESS Forgejo secrets, so changing one meant editing a secret and
redeploying. Keep them in a payout_addresses table edited at
/admin/invoices/payout-addresses (read to view, write to change, every
change audited), and prefill the new-invoice form from it.
The old env values are imported once on the first boot with an empty
table, so the current addresses carry over; after that the secrets can
be removed.
Assisted-by: Claude:opus-5
An invoice issued by mistake could only be left pending or marked paid.
Add cancel (with an optional reason) and reinstate. Cancelling renders a
third PDF variant with a diagonal CANCELLED stamp and the date/reason,
and it becomes the current one, so the client's link and download show
the invoice as void. Cancelled wins over paid in the status, and marking
a cancelled invoice paid is refused.
Superusers can also hide an invoice: it disappears from the admin list,
dashboard and invoice pages for every other user, while the client's
public link keeps working.
Assisted-by: Claude:opus-5
The invoice form's text inputs, textareas and label layout depended on
.contact-form rules that were deleted with the contact form, so they
fell back to white browser defaults with labels running inline. Give
every admin form (.invoice-form) one style: labels above fields, gray
inputs on the surface token, a focus state, single column on phones,
and the submit button styled like the rest.
Assisted-by: Claude:opus-5