From 849980042e8a3ddbc455d083af7d3a26a55d5bfb Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Mon, 12 Oct 2026 03:52:30 +0400 Subject: [PATCH] bingo: move the public site to the datacenterbingo repo 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 --- .env.example | 5 +- CLAUDE.md | 29 +-- assets/bingo/apple-touch-icon.png | Bin 6767 -> 0 bytes assets/bingo/favicon.ico | Bin 15086 -> 0 bytes assets/bingo/favicon.svg | 1 - bingo_web.rb | 238 ------------------------- config.ru | 20 +-- docker-compose.yml | 7 - lib/bingo.rb | 101 +---------- lib/bingo_admin.rb | 2 +- test/bingo_test.rb | 281 +----------------------------- test/test_helper.rb | 1 - views/bingo/_credits.erb | 2 - views/bingo/_dc_incidents.erb | 10 -- views/bingo/_dc_meta.erb | 9 - views/bingo/dc.erb | 35 ---- views/bingo/index.erb | 117 ------------- views/bingo/layout.erb | 163 ----------------- views/bingo/sitemap.erb | 16 -- 19 files changed, 32 insertions(+), 1005 deletions(-) delete mode 100644 assets/bingo/apple-touch-icon.png delete mode 100644 assets/bingo/favicon.ico delete mode 100644 assets/bingo/favicon.svg delete mode 100644 bingo_web.rb delete mode 100644 views/bingo/_credits.erb delete mode 100644 views/bingo/_dc_incidents.erb delete mode 100644 views/bingo/_dc_meta.erb delete mode 100644 views/bingo/dc.erb delete mode 100644 views/bingo/index.erb delete mode 100644 views/bingo/layout.erb delete mode 100644 views/bingo/sitemap.erb diff --git a/.env.example b/.env.example index 6921f07..c29282d 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,8 @@ MAIL_TO=ie@asxp.io # Legacy: LTC_ADDRESS still fills the LTC default when set. # LTC_ADDRESS=ltc1... -# Datacenter bingo hostname (same process, routed by Host). Unset = bingo off. +# Public bingo hostname (served by the datacenterbingo repo); used here for +# IndexNow pings and admin links only. # BINGO_HOST=bingo.localhost -# Optional second hostname serving the same card. -# BINGO_ALT_HOST=bingo2.localhost # IndexNow key (8-128 chars, A-Z a-z 0-9 -); unset = no pings, no key file. # INDEXNOW_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index 41896bf..4bc13b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,22 +121,23 @@ Admin pages use a shared dark palette extended in `public/style.css` (search for - Anyone with the UUID can fetch the PDF. UUIDs are 122-bit random so not enumerable, but they aren't access-controlled. If a stronger gate is ever needed (email confirmation, expiry on paid, etc.) it goes in the `/i/:uuid` and `/i/:uuid/pdf` handlers. - The MinIO bucket is **private**; only the app's scoped service account can put/get. Presigned URLs use `S3_PUBLIC_ENDPOINT=https://s3.asxp.io` so the signed host matches what the browser fetches. -## Datacenter bingo +## Datacenter bingo (admin side) -A second public site served from the **same process** on `BINGO_HOST` (`datacenterbingo.xyz`, `www.` redirects to it; optional `BINGO_ALT_HOST` serves the same card on a second hostname (unused; it also needs adding to the Traefik rule); non-secret config in `deploy.yaml` → `.env` → Traefik router `asxpio-bingo` via compose interpolation). `config.ru` dispatches by Host: `BINGO_HOST` → `BingoWeb` (`bingo_web.rb`), everything else → `AsxpioWeb`. Unset `BINGO_HOST` = bingo unreachable. +The public site (datacenterbingo.xyz) lives in its own repo and container: `git.czsk.it/asxpi/datacenterbingo`, mirrored to GitHub. It reads the tables and photos below through a read-only DB role (`bingo`) and MinIO user. What stays here is the admin, which writes, and the schema. -- 5×5 card of the 25 largest Russian datacenters; a cell is crossed out when an incident (with a public source) is recorded. Rows/columns/diagonals → BINGO banner. -- Locales `ru` / `en` / `uk` at `/ru`, `/en`, `/uk`; `/` redirects by `Accept-Language` (fallback `en`). Per-locale DB columns (`name_*`, `city_*`, `title_*`); `ru` is required, others fall back to it. UI strings in `Bingo::T` (`lib/bingo.rb`). -- Tables `bingo_datacenters`, `bingo_incidents` (`db/migrations/004_bingo.rb`) in the asxpio DB. `updated_at` is set on save; `renumber!` bumps only moved rows and deleting an incident bumps its datacenter (use `destroy`, not dataset `delete`, so the hook runs). First boot seeds from `db/bingo_seed.json` (verified 2026-10-10, sources in `info_url`) only if the table is empty — after that, edit via admin, not the JSON. -- Admin: `/admin/bingo` on asxp.io (`lib/bingo_admin.rb`, registered into `AsxpioWeb`, so HTTP Basic + CSRF apply). Record/delete incidents; add, edit, delete DCs (delete cascades to incidents); upload a photo or import it from a URL; bulk import missing / re-import all photos from `photo_source_url`; "Sort by MW". -- Status: operational | building | planned. `mw_type` (it | connected | total | design | mva, NULL = not stated) tags the MW figure in the dialog; mva swaps the unit. -- Views: the bare locale URL is the **operational** card (default, indexed); `?view=all` adds construction/planned projects in the same ranking with lines recomputed, and is noindex with canonical to the bare URL. The admin dashboard tile shows the operational card too. -- Provider filter: `bingo_datacenters.provider` (a `Bingo::PROVIDERS` slug; migration 016 backfilled it from `operator`, `Bingo.guess_provider` fills new rows, editable in admin). `?p[]=yandex&p[]=rtk` (or `?p=yandex,rtk`) builds the usual 5×5 card from only those providers' datacenters, first 25 by position; a smaller selection leaves empty cells. Filtered URLs are noindex with canonical to the bare page; language and view links keep the selection. -- Ordering: `position` is dense 1..N (`Bingo.place!` inserts and shifts, `renumber!` after deletes). The card shows positions 1–25; anything beyond is reserve. Slug is fixed at creation (it names photo objects). -- Photos: stored in the existing bucket under `bingo/-.` (no storage-repo change), proxied by `BingoWeb` at `/p/:file` with immutable caching (new upload = new key; old objects are kept). Type sniffed from magic bytes (JPEG/PNG/WebP), 8 MB cap, metadata stripped (GPS!). URL import is https-only, admin-only. On store, ImageMagick (`convert`, installed in the Dockerfile) derives `-480.webp` (card cells), `-1200.webp` (dialog, JSON-LD) and `-og.jpg` (1200×630 centre crop, og:image); `photo_variants`/`photo_og` record success (rows without `photo_og` use the 1200 WebP as og:image) and the page falls back to the original without them. "Rebuild resized photos" in admin re-derives from stored originals. -- SEO: `BingoWeb` has `static` off (public/ is asxp.io's), its own `/robots.txt`, `/sitemap.xml` (hreflang alternates, `lastmod` = max `updated_at`), and favicon/apple-touch-icon routes serving `assets/bingo/`. The h1 carries a per-locale `tagline`; `/en/` 301s to `/en`. Per-locale `seo_title`/`seo_desc` with the live score, OG/Twitter tags (og:image = latest hit's photo, alt = its name and city), JSON-LD WebSite + ItemList of Places (`info_url` as `subjectOf`, incidents as `event`). HSTS via Rack::Protection. Canonical/hreflang always use `BINGO_HOST`, so `BINGO_ALT_HOST` doesn't compete. -- Datacenter pages: `/:locale/dc/:slug` (`views/bingo/dc.erb`) for every row, reserve included. Title/description/facts are generated (`dc_facts`, `dc_title*` strings); optional `about_*` prose (admin form, no fallback across locales). Card cells are `` to these pages, and JS opens the dialog on a plain click. Dialog and page share `_dc_meta`/`_dc_incidents`. The layout takes `@title @desc @path_for @x_default @og @ld @noindex` from the route. The sitemap lists the card and all datacenter pages × locales. -- IndexNow (`Bingo::IndexNow`): admin incident/DC/sort actions ping api.indexnow.org in a thread with the changed URLs; key from the `INDEXNOW` secret → `INDEXNOW_KEY`, served at `/.txt`. Unset = no-op. +- **Schema is owned here.** After adding a bingo migration, refresh `test/schema.sql` in datacenterbingo and update its models. Keep `Bingo::PROVIDERS`, `Board` and the model methods in step with its `lib/bingo.rb`. UI strings (`T`) and views are only there. +- `BINGO_HOST` (deploy.yaml → `.env`) is used only for the IndexNow host and the admin's "view site" link; Traefik no longer routes it here. +- Tables `bingo_datacenters`, `bingo_incidents` (`db/migrations/004_bingo.rb`…). `updated_at` is set on save; `renumber!` bumps only moved rows, and deleting an incident bumps its datacenter (use `destroy`, not dataset `delete`, so the hook runs). First boot seeds from `db/bingo_seed.json` only if the table is empty; after that, edit via the admin. +- Admin: `/admin/bingo` (`lib/bingo_admin.rb`). Record and delete incidents; add, edit and delete DCs (delete cascades); upload a photo or import it from a URL; bulk import or re-import photos from `photo_source_url`; "Sort by MW". +- Status: operational | building | planned. `mw_type` (it | connected | total | design | mva, NULL = not stated) tags the MW figure; mva swaps the unit. The public default card is operational only, and so is the dashboard tile (`Board.load('operational')`). +- Provider: `bingo_datacenters.provider` holds a `Bingo::PROVIDERS` slug (backfilled by migration 016; new rows get it from `Bingo.guess_provider`; editable in the admin). The public site filters cards by it. +- Ordering: `position` is dense 1..N (`Bingo.place!` inserts and shifts; `renumber!` runs after deletes). Positions 1–25 are the card; the rest are reserve. Slug is fixed at creation because it names the photo objects. +- Photos: stored in the bucket as `bingo/-.`, with the type sniffed from magic bytes (JPEG/PNG/WebP). + - Limits: 8 MB cap, and metadata is stripped (GPS!). URL import is https-only. + - On store, ImageMagick derives `-480.webp`, `-1200.webp` and `-og.jpg` (1200×630). `photo_variants` / `photo_og` record whether that succeeded. + - "Rebuild resized photos" re-derives the variants from the stored originals. + - Served publicly by the bingo site at `/p/:file`; previewed here at `/admin/bingo/p/:file`. +- IndexNow (`Bingo::IndexNow`): admin actions ping api.indexnow.org with the changed URLs. The key comes from `INDEXNOW` → `INDEXNOW_KEY`; the bingo site has the same key and serves `/.txt`. Unset = no-op. - Photos are third-party (operator sites, Habr, Wikimedia Commons CC BY-SA) — keep `photo_credit` filled; renders/illustrations are marked as such. - No street addresses or coordinates on the page — city level only. diff --git a/assets/bingo/apple-touch-icon.png b/assets/bingo/apple-touch-icon.png deleted file mode 100644 index 4edcee5275de9c82071edc05507f4ea2e01e6477..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6767 zcmb_hWmpwayB+#SoC8QVf{1{Gq;yJo2tgX8J4G5nxQQaSkohJlVM-lHAc2lTKqiswHAUcJen>5gX`?;B5)c0>Lbecy>5&iaClVj`^7l z5Cr0-KhcMx;j}^9FfyTSPXP<ZLWo``l!qcpB1=VBa^e(?Z6F#W zlemZ^OU=+i+m++9ouBxMTK+^|sH*g~xSD74JxX{s6UrsVXCA)`G2y89V@L+jfQbjz%&tc)xyEMx_4`I7JOAq3^X(#6dk_f>R-2gaBs57b2a z+$3;V9Umg;9Aq}vYs z+ushxNGdK|ZS7?LX~f+AY=tw&w1YRTQWC4RhmnzJ-kXfT)y`l#2B{a8b`e1mR&(sO z-flR;G!Dg695;9O?Im}Y|{RZ!(R zc-h3-I*dO{_QF1U8lhF}zHuB@_x2sNJ$EX6_IlNPW9v`2PUEgMFO7#cp|#D6RYa*i zPWf2?oRb1Cd%DHFkT6E{9(NxqTUSz`sqB5ZOqn%hDP3~6nCyHx^?0P`@h%*xGpo9hxS2*$!p0%byUDIrKkBKGOqai(?ukfOppO~f&S7lv`Q3#QM2s&vl1#Xu z)wBlL_^fm$$-ap>I?iaA7Q~xTm&cSIqRnJeYoj2AqlDy!fj>5$2qO|x1ONw$8d^(K zpsrwQZVmvro+3oyfo19|a97t8lmXM&bC!@_^9Svf`*h$mru}O7)h`bijI1q~Oq!9I zwh^?J*J!MunL;5Y=9}6tcyKO&jmNG$xX?UlAcKBfPiG3i*eCU)!$1Sa`ws3Kh_>jy zK;$<{{^%hVj+RBK^E~{zHhK@{_l4-8bYkbDxJf24b~gjtK%SfJcL@3tvsH=@nI$d{ zd;<2-))x}^^phk5bU1j^)7~xp zM~yx^KhUO&l%b;{(gaK3ZoP4QZJ zi_#{x)+bJ!UsD!!qL4n0>5X_-G!yb*u1%ttRDwEy&8Ph?b(R`htlOG=aSqO>GcQp} zL2sXUjfGLoB)<+^Cwtezk25CyS;&g!6dP}2GRpo7TFGTmnO}We5pQogx)tJ^n&=eV zKjh?*yr+E?>~rTC;p+8}T(zYtY{IUTah*6}@a|nyTz*S8wc9DNp6_&5p@Wi1KhmJ* z3d!L(&tcFI$!#%W(nfukITe1g{&y^V{oBg}Grjv`Y~&{u%mDhGT7ASz;`*OF5)N*) z{OCF^^DoB)Uc)ys<`i7 z3a6ZCpLI$)if}BP=Qq!hOrGCR#FG;Ja|0Y!tA|*US;E-HOTBduA`IHdeXd~FCNz2jquK;R!>(7nQMc);U#qCm z*s<$hMNeeeDn?h>f3dR4U_@Ip@uvd>4;ZC~`^}kJkowr)86*~;x1fc%I}8Yo{$p{H zhy=G)_v0Hht=oBQrG;3vq)u5lS3Nnz_^?7T4e^I{S}yzVnR}B~i!omQL5*1vqw3W> zh=0ARj0wagjFs`~C~9`j5|~ZZYmZ$xtHVMaNMavEpWe6(B+N%9BdXVz9mb zM9eW!ztIN`3q~tZrt6ihNV>|~;U^AYL8;p(*EAikUTRw1tS1x|2>1}z5-+mUo#R2M z>s*m@F@@b_E1s;_)$RmcqCeGHzS_M6P+RJhLRf8w9bhilcvWw;G%=kgXlcx=WnTvi zJ`a&Dsv)P`-4*7V8R+jP(#yB0&{{p~(IDHa)-knu8N@nRSy01SP!^*vL?WH9_=VUX z-tfHId1(DClRi3!QAX8YS71-r#VLxEm?)@NJt(s#9S;S4{o6JDQyD+pnsQ8)`~l*b z>c@qJ&sB;h{imb1CIo0=v8(57JBrCUR`0!@(TV75c^Gq&b&Z8T*1F%E*JxcNCD||n z(GuC}RlVX_Do*z~ASA~4Dx6oq>+q)FPcsh-8^tK~;}0%!#FvdyQk4hyV zG`VJt<+k1Pb>;+?I7?E{0Ew7Z+eLpcJuY>xw{M-S_JyfAp3a6j>KfRS-kR-^jJ?P- za1f!nJ7XN)R|7la?V;>zInkA_I)8Yw-cqKt=3nHYH&;7icX{&A#M*!{Xtqcr-`(I3vUVV!^h5Lmtcu0EkUaU;9V-CGPX4^6?0k1i}-8 zR2qohxYPMD{lX@+^e_HDHnw^EnE0q9 zg~80RxEb0U`+3%Gs7EzbzhS=J5TDPh0MSSfyys-rK1lVW`;AAx8nw6@s)))!^rq{? zF6rnBGj@OkDUN(D*4Aj5o%YI$X2xMM@L`G7=pcvqSx0F-FkrmJyQAJBRJK51iCeLt z9}*7X51ZYeYZSY^oIj&fU6bRQtFfR(+Ie7kE(Q;k%)12kfHPz2se|J@NMX^8Ebl?d z@U*OKqxyxg>hrMvnavZEcBj&Qx4!WNr#_;%ZsQ8$DeF=tx3O4$FqLTNm-JjmA&-|b zYL!4`+lx3b<0Qx1UMj(V{=B1K6V(rB5&eMM&Aqj{QRwh$;P;`REyrS58Wneg%L{zH zus;`ADlHHA;fkBDsgudR)4o^NR{d5eJrj9a&M(F6-A<=aWasYrpsn}Oxl`As!uTTN z)31>cDIbH36+!mzBOx5E^FdY!EB+p5KatB!vBQmmS#LdTtBVj5)}`XnfpmoS}OLId(r zNrffh*DxP;MG+@^ek!*UT(@9~rCr1g+W1&Wb6?)`B{z5G3{DYd)!e>g_fy~d2y>9&hsb>RBE9`9sxuGs^eqaq_ba&WgA9Kt}Sd&~UNtVgvD7Rm?J}_wOY_Ft*FW zyvdJumzpE~)mYiO;vzI0|58QtcTaZ&rZ43TYzN!P7glw1TenrDR!6j&Ps6>-e(Q9{ zq6*dfs>P;p`*(FYGcWi%LrSaCMbN*r0QrQHEEZB!oi2KSG1iG2sNPaG$g){IlyV%) zsGwV;6BN6nCSa$Cd4?}CC@%L$XzLuL?A!YP=!`<|_uf8FkOv-OLJYFF>mYa9?~GA< z)a!Tn#!x)hKR~&=p=V`1JDZqx4!2BF_|B-BYio|hZ?a?mp3QT=cbicU~qei!L*9)GpEWzFsTWhG}PLO*;M<0!he1S@wd}w00>wV#tH7Ea0m~v5pJJ(2sM&B25uY{h*Y{K_KQPR1nT(rZq4cQ3^gE%m9h~ z-$U=uQkaEY6BuT>7XB zsW@n2NO4G-hpoJ94lw1_TUzKp(FM1n1n_PEg zl@|PE$~xD>5YHbC1w|yx9vzZ^LUDR$smbHtH(h=)1+Udo)GUl;y9yh?7a#9@UnX5d-Mzp%I<4Yxx}8M3zUU0`oe!<%F={2M=>j=K zQ&~7fZ8;5E8wI*Wsr;oR;NxKR0&0V`5S*bn5)m!KuEeos`eHmG3efg>^gg32#^rWn z>(-?q?wNdcbuKfuvpKPIFn71wGsWy`xV)<2`Jh!AB6;FH;eYc%RglufpV-1!X1ghC z;_!Sp^Z|~dC0y`NQUITHu+0%FS4^l#aTqOMpxc=AbGAWQ2_O5A$-{*$#1SPwh-N9w z2<|59C{Qs4+vN+EXb?I3=t^4$G@7xVQV}n~PG3eAJa7Z1kAx>Q*;S18U4P@kKCq4z0 zMv!y0WLClUwgG5aicg$!%^O!xVYNN9IH9h-N9!@JNXtY)*GhbW++MPR$ueysfn=_%FphfkgC6b}GAj_-XacJo7FgrdB-TwIW=i-lv zgUtW>K8DTRUsD}Xhd2i<0HvO)B)R62*>bjsl$3dB;lTx#p;?Mc!`a!bec4vPj4rHJ z=%WjVNfkXlL{6g-!PgqnXnW!HV1I_-*cXjg8F zstojKEYed%^?Zqq{Xiw&*zz1CF$*S9zZ+!w=Y3lfJ?d!E+D0fAjJmzyEcIVm{*kt- z^60iodClT+5B6b_v*6-v?E~Gk-R$q5dqak!>p@!?J#`g1nNyEg7o29(XF;t7nys)m ztlY%khM4|i)za{yQozkwy;fkIvnmJ^7++H(278i_wZ{zZQ}ZQ*P%iV573%=XGf{7! z?(iv#pDV{$CM5>s5Oho-s^|hmzWMl7vZx-TD6z1JvPVzw*YZ)?1cHsA)rj(;+T@wN z=AUm1n}EZ|MHc9&Fa@&UtLl=V10Q?$=A{6Z1O?a%dwZh{9U}y^;>m-PmzM;&up|x2 zYSnY!o}r8?muC<_!m1LCzJ-N4VX|j@71=xUd?x+H2jTjQ{K66hn0{E>UwnXy0Q7KUJirrY4qYHIHsc=ib5v2_!*BvBd^| zWA5M_d9_csLq~xdOh6=4VxsM;QhD>__D5kR^R7(M6orVQ8Y;xaH22i=E`Ug82+J}5 zKT8k-peLXDZwUgBf3L)Rs^a&O4EF-<8)QWS7>5r^Y`>XAyQFF}4>oEc`p}yl^~psr zJvwhE1VtEFu?H|KtO*1$a6c~s_vE;kVm1v}DwaR4U&^Mw$@6PBbcB3<5}xf@#kPPZ z&II5AFMipgrl-?MGU+}8_+?Z{DS4f@tWrwLwb-7GuFacqUHa0VB4Mm1|55+E<5 KBwZ?L=>I?V65{*- diff --git a/assets/bingo/favicon.ico b/assets/bingo/favicon.ico deleted file mode 100644 index a2e3a14ff17b1cb6bd405325a7a660d9dd297642..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 zcmeHNX>3(R6ut%nKPgKI0fY+0fN7zP#6(J4pk*yYr78u=POvDYP?1suT0mC8LPBf{ z%3>4&mqZdZ(TD|!VAOz16e31c77a^cL}TKQ@qE*s%e;9r@6OfNP5Y90XXczU_k1&R z=FFX$>v^|%?Yx8pk7$xtbi3!>>v>*MQhoU#@%IofEv=#4-}6Q%dfouiAraN6=M(Wf z(lh+pcj?k)Q|Hc|FHsVX=@RPDvt->Bm21U;H;xF}&9~2O-GAtHX$$nL?=Q;yhOJfN+sjvF%FVBlHuL8HE>vBv&$yZD_{SBzBwt3o1;r&|+sm_dOV z_KVB*`Zg;zz94y!sk-s1UtIcxsNS<*fId8Tx0t!;kjNT6Pb^;fvP=bqv!#5?j-676 zb=kh!C+H)jX+C4Dw&q*Bk0z zR(%q9_3_`X_^*cl3g%I|;*)y={8#mLWe#p8ESI@G4%DB&hIkN)g*cN$X*easC;3C* zvHuV=781sjlzwx->dzBLT|Dz6*dPe)RT#wUNwU+9&k1YSq<9}E)(+L!6y2Fa1^86OUqaK^NM{HymJ?S ziQuCgMt4x=dOzOq90m_IF&0EWM!sxSMT8w-F!5}Bn{Hax<|zB%!6qf&*G%knl8Ea%hzQO-^3?!X0 zeMW?@!Osc`r;GPaoQm)Zlx?~>g=Zti8fBZV+IHkkV{PKa8h`e0m71ObUYq^McS_qh zHP!gXBc_Il)%y6yV@`*O)w+F~pTghuWQ(b~UaYy&((E^P|HmVyhKbdB|J&wYn3x)7 zuIlz}{-?%L&f9bw!~fW(O8<-Yn|u6i@2O#8Y8YMJ|7`E6VPa|+UB$lK>%=3b{saGq zZTbG)O!iOG9$*prqB;5>`w!X!VgGf;_MRFhriRfao7jJKz#forVE=gr{m?{7-G2fC zejOUd;uK-H*$6oB%u{5e7`CKE`m6K2c9l3HXyPAi){$+TQ_vBqH;a;gPLWInhaa^i zhuTz2>1#@jac1G_53mgkwS;M85cI^v#QvQ+b^3txU7z2idW7vLVKj~c$ubG|^_GfV z!F3&2)v@T6BI8gb0@PYB%Pd`WP3$Xrx5Bc#=&lu&^IX|$vpHS z=S>vve|%nRl0#;CM^tr_223p#BY# zH$(sX=ZqMGAbdgcX29>y1>kpyKOcVfxpfW)#UI?u#Ed@_+YlpT#UCYxMquYTFgQIu zDmEilgAbj4$>#W%od)@Lki2YVaQq*fwaJK`<9m<| z$aUOYcB!kbHG1A<{+jlKj2+Yk1LM`HOI;naT5iMt|L*^c%q(MUVILVfEK_{>-H%qC z;?ku8_vx&!Q^-<>uC~r!#3dIDy5Fm1tU-L&rqaS$bL6}qHFlg){&-2L=-I26Tmum& z)w;-ohxIYBTW^ta-wOZUjql$;`Q0)IgNj$pW9`8^I=-RN6Znt=i!MVM-%$Wxa!P^7 zoOD!vbA#`G{6xHdDU+oRe{-Qf2W@o0pkh|@BXdVv#~!`_xov*~IqnLw@{d{l18p(g zC$OL4*)AAV%xXR!@sqzzQDZviAw!I1ye8^`LB*`*UFw=_G7s3=@WIKg-IEazB2iPO-*M#8$*uTl_?9cEO`(Cu{^^ z&@pq_W$m`@JrE;PhpvkK{y=LS-Z504Q}fsZxnN+tI(3=99Lr4<@Am)p{K^G`j+@Ia zbxpXL*M|REqwl}CuT8YI_doXLf&Nc>e09$1c^4mMf7dC_c5LTUIMcxS$*>3BHNIaW zE+Muqq4mME#^K!(@g4gI+a3t}8y5^ZZnYeAcYroD_WcFc3H^KvW$;z3dOqmv4tEE( zvnBXJ(D|Xu{RJ@E_88#DY6HgKG%uM4x$duO8Eb5y|HuFR-@hMih4_KpzrSdO_`f0i z&+|NJh4^vDKB~3ir~A7c+(F`A8*K=RKaktj2bcKca(@vb{u~2#{JM7SdKclpQQwBx zcLz;ze-U(tLO3JtuhE8tgoG?^1KQDo(+&Om0pQ24VH}RNRP4BO$2TVRXzl-mA9EV_ z+K8=<@y(Iz_`)_~m{Zrw*B0Dpc1M||J9*P M{dxhE`kDLvZ^v(SF8}}l diff --git a/assets/bingo/favicon.svg b/assets/bingo/favicon.svg deleted file mode 100644 index 39a06b1..0000000 --- a/assets/bingo/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/bingo_web.rb b/bingo_web.rb deleted file mode 100644 index 266f160..0000000 --- a/bingo_web.rb +++ /dev/null @@ -1,238 +0,0 @@ -# Public datacenter bingo, served on BINGO_HOST from the same process as -# AsxpioWeb (config.ru dispatches by Host). Read-only: no session, no CSRF. -# Content is managed from asxp.io/admin/bingo (lib/bingo_admin.rb). -class BingoWeb < Sinatra::Base - HOSTS = [ENV['BINGO_HOST'], ENV['BINGO_ALT_HOST']].compact.reject(&:empty?).freeze - - configure do - set :root, $root - set :views, File.join($root, 'views', 'bingo') - set :erb, layout: :layout, escape_html: true - # public/ belongs to asxp.io (its robots.txt, sitemap, keys, admin JS); - # the card needs none of it. - set :static, false - set :show_exceptions, $env == 'development' - # HSTS (1 year, this host only); http:// already 308s at the edge. - set :protection, use: %i[strict_transport] - set :host_authorization, - { permitted_hosts: $env == 'production' ? HOSTS : [] } - end - - helpers do - def t(key) = Bingo::T[@locale][key] - def kind_label(kind) = Bingo::T[@locale][:kinds][kind] || Bingo::T[@locale][:kinds]['other'] - def kind_icon(kind) = Bingo::ICONS[kind] || Bingo::ICONS['other'] - def photo_path(dc, width = nil) = dc.photo_file(width) && "/p/#{dc.photo_file(width)}" - # Path for a locale/view combination; the default view (operational) - # keeps a bare URL, the full list is ?view=all. - # Path for a locale/view/provider selection; the default (operational, - # every provider) keeps a bare URL. - def card_path(locale = @locale, view = @view, providers = @providers) - q = [] - q << 'view=all' if view == 'all' - Array(providers).each { |p| q << "p[]=#{Rack::Utils.escape(p)}" } - q.empty? ? "/#{locale}" : "/#{locale}?#{q.join('&')}" - end - - def selected_providers - Array(params[:p]).flat_map { |v| v.to_s.split(',') }.select { |p| Bingo::PROVIDERS.key?(p) }.uniq - end - - def dc_path(dc, locale = @locale) = "/#{locale}/dc/#{dc.slug}" - - def base_url = "https://#{ENV.fetch('BINGO_HOST', request.host)}" - - # WebSite + ItemList of the card's datacenters, for rich results. - def json_ld(board, desc) - url = "#{base_url}/#{@locale}" - JSON.generate( - '@context' => 'https://schema.org', - '@graph' => [ - { '@type' => 'WebSite', '@id' => "#{base_url}/#website", 'url' => "#{base_url}/", - 'name' => t(:title), 'inLanguage' => Bingo::LOCALES, - 'publisher' => { '@type' => 'Person', 'name' => 'asxpi', 'url' => 'https://asxp.io' } }, - { '@type' => 'ItemList', 'url' => url, 'name' => t(:seo_title), 'description' => desc, - 'inLanguage' => @locale, 'numberOfItems' => board.cells.size, - 'dateModified' => board.updated_on&.utc&.iso8601, - 'itemListElement' => board.cells.each_with_index.map do |dc, i| - { '@type' => 'ListItem', 'position' => i + 1, 'item' => ld_place(dc) } - end }.compact - ] - ).gsub(' 'PostalAddress', 'addressLocality' => dc.l(:city, @locale), 'addressCountry' => 'RU' } - url = "#{base_url}#{dc_path(dc)}" - place = { '@type' => 'Place', '@id' => "#{url}#place", 'url' => url, 'name' => dc.l(:name, @locale), 'address' => address } - place['image'] = "#{base_url}#{photo_path(dc, 1200)}" if dc.photo_key - place['subjectOf'] = { '@type' => 'CreativeWork', 'url' => dc.info_url } if dc.info_url - events = dc.incidents.map do |i| - { '@type' => 'Event', 'name' => "#{kind_label(i.kind)}: #{i.l(:title, @locale)}", - 'startDate' => i.occurred_on.iso8601, 'url' => i.url, - 'location' => { '@type' => 'Place', 'name' => place['name'], 'address' => address } }.compact - end - place['event'] = events if events.any? - place - end - - # Datacenter page: the Place plus a breadcrumb back to the card. - def dc_json_ld(dc) - JSON.generate( - '@context' => 'https://schema.org', - '@graph' => [ - ld_place(dc), - { '@type' => 'BreadcrumbList', 'itemListElement' => [ - { '@type' => 'ListItem', 'position' => 1, 'name' => t(:title), 'item' => "#{base_url}/#{@locale}" }, - { '@type' => 'ListItem', 'position' => 2, 'name' => dc.l(:name, @locale), 'item' => "#{base_url}#{dc_path(dc)}" } - ] } - ] - ).gsub(' t(:dc_building), 'planned' => t(:dc_planned) }[dc.status] || (dc.year && format(t(:dc_since), dc.year)), - format(t(:dc_rank), pos: dc.position)].compact - end - - def dc_latest(dc) - i = dc.incidents.first or return t(:no_incidents) - format(t(:dc_latest), date: fmt_date(i.occurred_on), title: i.l(:title, @locale)) - end - - # Cut at a word boundary to fit a search snippet. - def snippet(text, max = 160) - text.length <= max ? text : "#{text[0, max - 1][/\A.*(?=\s)/m] || text[0, max - 1]}…" - end - - # Preview image: the most recent hit with a photo, else the first photo. - def og_dc(board) - board.incidents.map(&:datacenter).find(&:photo_key) || board.cells.find(&:photo_key) - end - def fmt_date(d) = d.strftime('%d.%m.%Y') - - # "64 MW", "64 MW · design", "17 MVA" (MVA is a unit, not a tag). - def mw(dc, tag: true) - return if dc.mw.to_s.strip.empty? - return "#{dc.mw} #{t(:mva)}" if dc.mw_type == 'mva' - tag &&= t(:mw_types)[dc.mw_type] - ["#{dc.mw} #{t(:mw)}", tag || nil].compact.join(' · ') - end - - # "63 MW (design)": the dialog's " · design" tag reads badly in a sentence. - def mw_prose(dc) - tag = dc.mw_type != 'mva' && t(:mw_types)[dc.mw_type] - tag ? "#{mw(dc, tag: false)} (#{tag})" : mw(dc) - end - - # Badge for anything not yet in service. - def status_label(dc) - { 'building' => t(:building), 'planned' => t(:planned) }[dc.status] - end - - def preferred_locale - langs = request.env['HTTP_ACCEPT_LANGUAGE'].to_s.downcase.scan(/([a-z]{2})(?:-[a-z]+)?(?:;q=([\d.]+))?/) - langs.sort_by { |_, q| -(q || '1').to_f }.map(&:first).find { |l| Bingo::LOCALES.include?(l) } || 'en' - end - end - - before do - halt 503, 'Bingo not configured (DATABASE_URL missing).' unless defined?(Bingo::Datacenter) - end - - get '/' do - headers 'Vary' => 'Accept-Language' - redirect "/#{preferred_locale}", 302 - end - - get '/robots.txt' do - content_type 'text/plain' - "User-agent: *\nAllow: /\n\nSitemap: #{base_url}/sitemap.xml\n" - end - - # The card and every datacenter page (reserve included), each with its - # language alternates. - get '/sitemap.xml' do - content_type 'application/xml' - cache_control :public, max_age: 3600 - dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all - @entries = [[->(l) { "/#{l}" }, Bingo::Board.load.updated_on, '/']] + - dcs.map { |dc| [->(l) { dc_path(dc, l) }, [dc.updated_at, *dc.incidents.map(&:updated_at)].compact.max] } - erb :sitemap, layout: false - end - - # One URL per page: /en/ is a duplicate of /en. - get %r{/((?:ru|en|uk)(?:/dc/[a-z0-9-]+)?)/} do |path| - qs = request.query_string - redirect "/#{path}#{"?#{qs}" unless qs.empty?}", 301 - end - - get %r{/(ru|en|uk)} do |locale| - @locale = locale - @view = params[:view] == 'all' ? 'all' : Bingo::VIEWS.first - @providers = selected_providers - @board = Bingo::Board.load(@view, providers: @providers) - @title = t(:seo_title) - @desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size) - @noindex = !@board.operational_only? || @board.filtered? # variants of the bare page - @path_for = ->(l) { "/#{l}" } - @x_default = '/' - @og = og_dc(@board) - @ld = json_ld(@board, @desc) - cache_control :public, max_age: 60 - erb :index - end - - get %r{/(ru|en|uk)/dc/([a-z0-9-]+)} do |locale, slug| - @locale = locale - @dc = Bingo::Datacenter.where(slug: slug).eager(:incidents).first or halt 404 - @prev, @next = [-1, 1].map { |d| Bingo::Datacenter.first(position: @dc.position + d) } - name, city = @dc.l(:name, locale), @dc.l(:city, locale) - hit = @dc.incidents.first - @title = hit ? format(t(:dc_title_hit), name: name, city: city, kind: kind_label(hit.kind), date: fmt_date(hit.occurred_on)) - : format(t(:dc_title), name: name, city: city) - @desc = snippet([*dc_facts(@dc).first(2), dc_latest(@dc)].join(' ')) - @path_for = ->(l) { dc_path(@dc, l) } - @og = @dc if @dc.photo_key - @ld = dc_json_ld(@dc) - cache_control :public, max_age: 60 - erb :dc - end - - # IndexNow key file: proves to the engines that pings come from us. - get %r{/([A-Za-z0-9-]{8,128})\.txt} do |name| - halt 404 unless name == Bingo::IndexNow.key - content_type 'text/plain' - name - end - - # Icons from assets/bingo/ (public/ is asxp.io's). Search engines want - # a crawlable favicon URL; a data: URI doesn't count. - ICONS = { 'favicon.svg' => 'image/svg+xml', 'favicon.ico' => 'image/x-icon', - 'apple-touch-icon.png' => 'image/png' }.freeze - - get %r{/(favicon\.svg|favicon\.ico|apple-touch-icon\.png)} do |name| - cache_control :public, max_age: 86_400 - send_file File.join(settings.root, 'assets', 'bingo', name), type: ICONS[name] - end - - # Photos are immutable per key (a new upload gets a new key). - get '/p/:file' do - bytes, type = Bingo::Photo.read(params[:file]) - content_type type - cache_control :public, :immutable, max_age: 31_536_000 - bytes - rescue Bingo::Photo::Error, Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound - halt 404 - end - - not_found do - 'Not found' - end -end diff --git a/config.ru b/config.ru index 7698a33..b2e5a0d 100644 --- a/config.ru +++ b/config.ru @@ -1,20 +1,6 @@ require 'sinatra/base' require './asxpio.rb' -require './bingo_web.rb' -# One process, two sites: the bingo hosts get BingoWeb, everything else -# asxp.io. BINGO_HOST is canonical (www. redirects to it); BINGO_ALT_HOST -# serves the same card, e.g. while the canonical domain's DNS settles. -asxpio = AsxpioWeb.new -bingo = BingoWeb.new -bingo_host = ENV['BINGO_HOST'] -bingo_hosts = BingoWeb::HOSTS - -run lambda { |env| - req = Rack::Request.new(env) - if bingo_host && req.host == "www.#{bingo_host}" - [301, { 'location' => "https://#{bingo_host}#{req.fullpath}" }, []] - else - (bingo_hosts.include?(req.host) ? bingo : asxpio).call(env) - end -} +# The public bingo site moved to its own repo/container (datacenterbingo); +# only its admin (/admin/bingo) is still served here. +run AsxpioWeb.new diff --git a/docker-compose.yml b/docker-compose.yml index 9a22d6a..46e9c74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,13 +18,6 @@ services: - "traefik.http.routers.asxpio.tls.certresolver=letsencrypt" - "traefik.http.routers.asxpio.service=asxpio" - "traefik.http.services.asxpio.loadbalancer.server.port=3000" - # Datacenter bingo: same container, routed by Host in config.ru. - # BINGO_HOST is interpolated from /opt/asxpio/.env (written by deploy). - - "traefik.http.routers.asxpio-bingo.rule=Host(`${BINGO_HOST}`) || Host(`www.${BINGO_HOST}`)" - - "traefik.http.routers.asxpio-bingo.entrypoints=websecure" - - "traefik.http.routers.asxpio-bingo.tls=true" - - "traefik.http.routers.asxpio-bingo.tls.certresolver=letsencrypt" - - "traefik.http.routers.asxpio-bingo.service=asxpio" networks: traefik: diff --git a/lib/bingo.rb b/lib/bingo.rb index 0490c8d..7d60303 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -46,98 +46,8 @@ module Bingo [(0...SIZE).map { |i| i * SIZE + i }, (0...SIZE).map { |i| i * SIZE + SIZE - 1 - i }] ).freeze - T = { - 'ru' => { - seo_title: 'Бинго ЦОДов — атаки, пожары и аварии дата-центров России', - seo_desc: '%{hit} из %{total} крупнейших дата-центров России уже зачёркнуты. Бинго-карточка ЦОДов IXcellerate, Яндекса, Ростелекома, Росатома и других: атаки БПЛА, пожары и отключения с источниками.', - og_locale: 'ru_RU', - title: 'Бинго ЦОДов', accent: 'ЦОДов', lead: 'Бинго', - tagline: 'Атаки БПЛА, пожары и аварии в 25 крупнейших дата-центрах России', - desc: 'Рейтинг по мощности. Клетка зачёркивается после публично подтверждённого инцидента.', - crossed: 'Зачёркнуто', lines: 'линий', updated: 'обновлено', - log: 'Журнал инцидентов', empty: 'Пока всё работает. Пока.', - bingo: 'БИНГО!', bingo_n: 'БИНГО ×%d!', full: 'ПОЛНАЯ КАРТА. Интернета больше нет.', - planned: 'планируется', mva: 'МВА', - mw_types: { 'it' => 'ИТ', 'connected' => 'подв.', 'total' => 'общ.', 'design' => 'проект' }, - building: 'строится', mw: 'МВт', since: 'с %d г.', photo: 'Фото', source: 'Источник', - close: 'Закрыть', no_incidents: 'Инцидентов не зафиксировано.', - credits_ai: 'Информация собрана и проверена с помощью Claude (Anthropic).', - credits_hym: 'Спасибо %{link} (данные Cat Eyes OSINT) за часть списка: каждая запись перепроверена по сайтам операторов и прессе.', - footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.', - kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' }, - letters: %w[Б И Н Г О], - providers_label: 'Собрать карточку из провайдеров', providers_apply: 'Показать', providers_reset: 'Все провайдеры', - view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать', - dc_title: '%{name} (%{city}) — инциденты ЦОДа | Бинго ЦОДов', - dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бинго ЦОДов', - dc_intro: '%{name} — дата-центр в городе %{city}, оператор — %{operator}.', - dc_capacity: 'Мощность: %{mw}.', dc_building: 'Строится.', dc_planned: 'Планируется.', - dc_since: 'Работает с %d года.', - dc_rank: '№ %{pos} по мощности среди крупнейших дата-центров России.', - dc_latest: 'Последний инцидент (%{date}): %{title}', incidents: 'Инциденты', - back: '← К карточке', dc_page: 'Страница ЦОДа →' - }, - 'en' => { - seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters', - seo_desc: '%{hit} of Russia\'s %{total} largest datacenters are already crossed out. A bingo card of IXcellerate, Yandex, Rostelecom, Rosatom and other datacenters: drone strikes, fires and outages, each with a source.', - og_locale: 'en_US', - title: 'Datacenter Bingo', accent: 'Bingo', lead: 'Datacenter', - tagline: "Drone strikes, fires and outages at Russia's 25 largest datacenters", - desc: "Ranked by power. A cell is crossed out after a publicly reported incident.", - crossed: 'Crossed out', lines: 'lines', updated: 'updated', - log: 'Incident log', empty: 'Everything is up. For now.', - bingo: 'BINGO!', bingo_n: 'BINGO ×%d!', full: 'FULL CARD. There is no more internet.', - planned: 'planned', mva: 'MVA', - mw_types: { 'it' => 'IT', 'connected' => 'connected', 'total' => 'total', 'design' => 'design' }, - building: 'under construction', mw: 'MW', since: 'since %d', photo: 'Photo', source: 'Source', - close: 'Close', no_incidents: 'No incidents recorded.', - credits_ai: 'Information collected and verified with the help of Claude (Anthropic).', - credits_hym: 'Thanks to %{link} (data by Cat Eyes OSINT) for part of the list; every entry was re-verified against operator sites and the press.', - footer: 'Open-source data (2025–2026). A cell is crossed out only with a public source.', - kinds: { 'strike' => 'Drone strike', 'fire' => 'Fire', 'outage' => 'Outage', 'flood' => 'Flood', 'cooling' => 'Overheating', 'other' => 'Incident' }, - letters: %w[B I N G O], - providers_label: 'Build a card from providers', providers_apply: 'Show', providers_reset: 'All providers', - view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show', - dc_title: '%{name} (%{city}) — datacenter incidents | Datacenter Bingo', - dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Datacenter Bingo', - dc_intro: '%{name} is a datacenter in %{city} operated by %{operator}.', - dc_capacity: 'Capacity: %{mw}.', dc_building: 'Under construction.', dc_planned: 'Planned.', - dc_since: 'In service since %d.', - dc_rank: "No. %{pos} by power among Russia's largest datacenters.", - dc_latest: 'Latest reported incident (%{date}): %{title}', incidents: 'Incidents', - back: '← Back to the card', dc_page: 'Datacenter page →' - }, - 'uk' => { - seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії', - seo_desc: '%{hit} з %{total} найбільших дата-центрів Росії вже закреслено. Бінго-картка ЦОДів IXcellerate, Яндекса, Ростелекому, Росатому та інших: атаки БПЛА, пожежі й відключення з джерелами.', - og_locale: 'uk_UA', - title: 'Бінго ЦОДів', accent: 'ЦОДів', lead: 'Бінго', - tagline: 'Атаки БПЛА, пожежі та аварії у 25 найбільших дата-центрах Росії', - desc: 'Рейтинг за потужністю. Клітинка закреслюється після публічно підтвердженого інциденту.', - crossed: 'Закреслено', lines: 'ліній', updated: 'оновлено', - log: 'Журнал інцидентів', empty: 'Поки що все працює. Поки що.', - bingo: 'БІНГО!', bingo_n: 'БІНГО ×%d!', full: 'ПОВНА КАРТКА. Інтернету більше немає.', - planned: 'планується', mva: 'МВА', - mw_types: { 'it' => 'ІТ', 'connected' => 'підв.', 'total' => 'заг.', 'design' => 'проєкт' }, - building: 'будується', mw: 'МВт', since: 'з %d р.', photo: 'Фото', source: 'Джерело', - close: 'Закрити', no_incidents: 'Інцидентів не зафіксовано.', - credits_ai: 'Інформацію зібрано й перевірено за допомогою Claude (Anthropic).', - credits_hym: 'Дякуємо %{link} (дані Cat Eyes OSINT) за частину списку: кожен запис перевірено за сайтами операторів і пресою.', - footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.', - kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' }, - letters: %w[Б І Н Г О], - providers_label: 'Зібрати картку з провайдерів', providers_apply: 'Показати', providers_reset: 'Усі провайдери', - view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати', - dc_title: '%{name} (%{city}) — інциденти ЦОДу | Бінго ЦОДів', - dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бінго ЦОДів', - dc_intro: '%{name} — дата-центр у місті %{city}, оператор — %{operator}.', - dc_capacity: 'Потужність: %{mw}.', dc_building: 'Будується.', dc_planned: 'Планується.', - dc_since: 'Працює з %d року.', - dc_rank: '№ %{pos} за потужністю серед найбільших дата-центрів Росії.', - dc_latest: 'Останній інцидент (%{date}): %{title}', incidents: 'Інциденти', - back: '← До картки', dc_page: 'Сторінка ЦОДу →' - } - }.freeze + # UI strings (T) and the public views live in the datacenterbingo repo, + # which reads these tables; keep PROVIDERS, Board and the models in step. # Localized column with fallback to ru. module Localized @@ -250,8 +160,8 @@ module Bingo end end - # Photos live in the app's MinIO bucket under bingo/ and are proxied by the - # app, so the bucket stays private. + # Photos live in the app's MinIO bucket under bingo/ and are proxied by + # the bingo site (and /admin/bingo/p here), so the bucket stays private. module Photo PREFIX = 'bingo/'.freeze MAX_BYTES = 8 * 1024 * 1024 @@ -448,7 +358,8 @@ module Bingo # Tells IndexNow engines (Bing, Yandex, Seznam, ...) which bingo URLs # changed, so new incidents get recrawled within minutes. The key comes - # from INDEXNOW_KEY and BingoWeb serves it at /.txt; without the key + # from INDEXNOW_KEY; the bingo site (datacenterbingo repo, same key) + # serves it at /.txt. Without the key # or BINGO_HOST (dev, tests) this does nothing. module IndexNow ENDPOINT = URI('https://api.indexnow.org/indexnow') diff --git a/lib/bingo_admin.rb b/lib/bingo_admin.rb index 10e9989..41f629f 100644 --- a/lib/bingo_admin.rb +++ b/lib/bingo_admin.rb @@ -1,6 +1,6 @@ require 'uri' -# /admin/bingo/* on asxp.io: edit the card and record incidents for BingoWeb. +# /admin/bingo/* on asxp.io: edit the card and record incidents for the public bingo site (datacenterbingo repo). # Registered into AsxpioWeb, so AdminAuth (HTTP Basic) and CSRF apply. module BingoAdmin DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator provider mw mw_type status diff --git a/test/bingo_test.rb b/test/bingo_test.rb index 693b113..1857e06 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -8,9 +8,7 @@ class BingoTest < Minitest::Test # Minimal well-formed JPEG: SOI, start-of-scan, data, EOI. JPEG = ("\xFF\xD8\xFF\xDA".b + [8].pack('n') + "\1\2\3\4\5\6data\xFF\xD9".b).freeze - def app - @app || BingoWeb - end + def app = AsxpioWeb def setup skip 'TEST_DATABASE_URL not set' unless TestDb.available? @@ -28,60 +26,19 @@ class BingoTest < Minitest::Test title_ru: "Авария #{idx}", title_uk: "Аварія #{idx}") end - def test_root_redirects_by_accept_language - get '/', {}, 'HTTP_ACCEPT_LANGUAGE' => 'uk-UA,uk;q=0.9,en;q=0.8' - assert_equal 302, last_response.status - assert_match %r{/uk\z}, last_response.location - get '/', {}, 'HTTP_ACCEPT_LANGUAGE' => 'de-DE' - assert_match %r{/en\z}, last_response.location - end - def test_locales_render_with_ru_fallback - get '/en' - assert last_response.ok? - assert_includes last_response.body, 'DC 1' # localized - assert_includes last_response.body, 'ЦОД 2' # falls back to ru - assert_includes last_response.body, 'Moscow' - get '/uk' - assert_includes last_response.body, 'Бінго' - assert_includes last_response.body, 'asxp.io' - get '/xx' - assert_equal 404, last_response.status - end def test_board_detects_lines (0..4).each { |i| hit!(i) } # top row [6, 12, 18, 24].each { |i| hit!(i) } # + main diagonal (0 already hit) board = Bingo::Board.load assert_equal 2, board.wins.size - get '/ru' - assert_includes last_response.body, 'БИНГО ×2!' - assert_includes last_response.body, 'Авария 24' - get '/uk' - assert_includes last_response.body, 'Аварія 24' end - def test_no_banner_without_line - hit!(0) - get '/ru' - refute_includes last_response.body, 'class="banner"' - assert_includes last_response.body, 'Авария 0' - end - - def test_photo_store_and_serve - Aws.config[:s3] = { stub_responses: { get_object: { body: JPEG } } } - S3.instance_variable_set(:@client, nil) + def test_photo_store key = Bingo::Photo.store('dc-1', JPEG) assert_match %r{\Abingo/dc-1-[0-9a-f]{8}\.jpg\z}, key - @dcs[0].update(photo_key: key) - get "/p/#{@dcs[0].photo_file}" - assert last_response.ok? - assert_equal 'image/jpeg', last_response.content_type - get '/p/..%2Finvoices%2Fx.pdf' - assert_equal 404, last_response.status - ensure - Aws.config[:s3] = { stub_responses: true } - S3.instance_variable_set(:@client, nil) + assert_raises(Bingo::Photo::Error) { Bingo::Photo.read('..%2Finvoices%2Fx.pdf') } end def test_strips_jpeg_exif @@ -132,39 +89,7 @@ class BingoTest < Minitest::Test assert_equal 1, Bingo::Board.load('operational').wins.size end - def test_default_view_is_operational_and_all_is_a_noindex_variant - @dcs[0].update(status: 'building') - get '/ru' - assert last_response.ok? - refute_includes last_response.body, 'ЦОД 1<' # construction project hidden by default - assert_includes last_response.body, 'index, follow, max-image-preview' - assert_includes last_response.body, '' - get '/ru?view=all' - assert last_response.ok? - assert_includes last_response.body, 'ЦОД 1<' - assert_includes last_response.body, 'noindex, follow' - assert_includes last_response.body, 'href="/en?view=all"' # language links keep the view - assert_includes last_response.body, '' - assert_includes last_response.body, 'rel="canonical" href="https://example.org/ru"' - get '/ru?view=operational' # old links still land on the default - assert_includes last_response.body, 'index, follow, max-image-preview' - end - - def test_capacity_type_and_planned_status - @dcs[0].update(mw: '64', mw_type: 'design', status: 'planned') - @dcs[1].update(mw: '17', mw_type: 'mva') - get '/en?view=all' # planned sites only appear in the full list - body = last_response.body - assert_includes body, '64 MW · design' # dialog - assert_includes body, '#1 · 64 MW' # cell: no tag - assert_includes body, '17 MVA' - assert_includes body, '#2 · 17 MVA' # MVA replaces the unit in cells too - assert_includes body, '>planned<' - refute_includes Bingo::Board.load('operational').cells.map(&:slug), 'dc-1' - end - def test_admin_saves_capacity_type_and_planned - @app = AsxpioWeb token = admin_token("/admin/bingo/dc/#{@dcs[0].id}") post "/admin/bingo/dc/#{@dcs[0].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 1', status: 'planned', mw_type: 'connected'), admin_env @dcs[0].refresh @@ -186,18 +111,6 @@ class BingoTest < Minitest::Test hit!(0) board = Bingo::Board.load('operational', providers: %w[yandex rtk]) assert_equal %w[dc-1 dc-2 dc-3 dc-4], board.cells.map(&:slug) - get '/ru?p[]=yandex&p[]=rtk&p[]=bogus' - assert last_response.ok? - body = last_response.body - assert_equal 4, body.scan(/class="cell(?! empty)[ "]/).size # the four selected datacenters - assert_equal 21, body.scan('class="cell empty"').size - assert_includes body, 'noindex, follow' - assert_includes body, 'rel="canonical" href="https://example.org/ru"' - assert_includes body, 'href="/en?p[]=yandex&p[]=rtk"' # language links keep the selection - assert_includes body, 'name="p[]" value="yandex" checked' - refute_includes body, 'ЦОД 5<' # unselected provider - get '/ru?p=yandex,rtk' # comma form works too - assert_equal 21, last_response.body.scan('class="cell empty"').size end def test_filtered_card_still_scores_lines @@ -207,7 +120,6 @@ class BingoTest < Minitest::Test end def test_admin_saves_provider - @app = AsxpioWeb token = admin_token("/admin/bingo/dc/#{@dcs[0].id}") post "/admin/bingo/dc/#{@dcs[0].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 1', status: 'operational', provider: 'selectel'), admin_env assert_equal 'selectel', @dcs[0].refresh.provider @@ -215,32 +127,6 @@ class BingoTest < Minitest::Test assert_nil @dcs[0].refresh.provider end - def test_footer_credits_in_every_language - { 'ru' => 'с помощью Claude', 'en' => 'with the help of Claude', 'uk' => 'за допомогою Claude' }.each do |l, text| - get "/#{l}" - assert_includes last_response.body, text - assert_includes last_response.body, 'hackyourmom' - end - get "/en/dc/#{@dcs[0].slug}" - assert_includes last_response.body, 'hackyourmom' - end - - def test_robots_and_sitemap_are_the_bingos_own - get '/robots.txt' - assert last_response.ok? - assert_includes last_response.body, 'Sitemap: https://example.org/sitemap.xml' - refute_includes last_response.body, 'asxp.io' - get '/sitemap.xml' - assert last_response.ok? - %w[ru en uk].each { |l| assert_includes last_response.body, "https://example.org/#{l}" } - %w[ru en uk].each { |l| assert_includes last_response.body, "https://example.org/#{l}/dc/dc-25" } - assert_includes last_response.body, '' - assert_equal 3, last_response.body.scan('hreflang="x-default"').size # card URLs only - assert_includes last_response.body, 'hreflang="x-default"' - refute_includes last_response.body, 'changefreq' - assert_match %r{\n \d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ\n , old end - def test_asxpio_static_files_not_served - get '/style.css' - assert_equal 404, last_response.status - get '/pgp.pub' - assert_equal 404, last_response.status - end - - def test_trailing_slash_redirects - get '/en/', view: 'all' - assert_equal 301, last_response.status - assert_equal 'http://example.org/en?view=all', last_response.location - end - - def test_icons - { '/favicon.svg' => 'image/svg+xml', '/favicon.ico' => 'image/x-icon', - '/apple-touch-icon.png' => 'image/png' }.each do |path, type| - get path - assert last_response.ok?, path - assert_equal type, last_response.content_type - end - get '/en' - assert_includes last_response.body, '' - end - - def test_hsts - get '/en' - assert_equal 'max-age=31536000', last_response.headers['strict-transport-security'] - assert_equal 'nosniff', last_response.headers['x-content-type-options'] - end - - def test_dc_page - hit!(3) - get '/en/dc/dc-4' - assert last_response.ok? - body = last_response.body - assert_includes body, "ЦОД 4 (Moscow) — Outage, #{Date.today.strftime('%d.%m.%Y')} | Datacenter Bingo" - assert_includes body, '' - assert_includes body, '' - refute_includes body, 'x-default' - assert_includes body, '

ЦОД 4

' - assert_includes body, 'ЦОД 4 is a datacenter in Moscow operated by Op. Capacity: 4 MW.' - assert_includes body, 'No. 4 by power' - assert_includes body, 'href="/en/dc/dc-3" rel="prev"' - assert_includes body, 'href="/en/dc/dc-5" rel="next"' - assert_match(/(.*?)}m, 1]) - place = ld['@graph'].find { |n| n['@type'] == 'Place' } - assert_equal 'https://example.org/en/dc/dc-4', place['url'] - assert_equal 'Outage: Авария 3', place['event'][0]['name'] - crumbs = ld['@graph'].find { |n| n['@type'] == 'BreadcrumbList' } - assert_equal 'https://example.org/en', crumbs['itemListElement'][0]['item'] - - @dcs[3].update(mw_type: 'design') - get '/en/dc/dc-4' - assert_includes last_response.body, 'Capacity: 4 MW (design).' - get '/uk/dc/dc-1' - assert_includes last_response.body, 'ЦОД 1 (Москва) — інциденти ЦОДу | Бінго ЦОДів' - refute_includes last_response.body, 'rel="prev"' - end - def test_dc_about_text - @app = AsxpioWeb token = admin_token("/admin/bingo/dc/#{@dcs[1].id}") post "/admin/bingo/dc/#{@dcs[1].id}", dc_params(authenticity_token: token, about_en: "First para.\r\n\r\nSecond."), admin_env assert_equal "First para.\r\n\r\nSecond.", @dcs[1].refresh.about_en - public = Rack::MockRequest.new(BingoWeb) # rack-test's session is bound to AsxpioWeb now - assert_includes public.get('/en/dc/dc-2').body, '

First <b>para</b>.

Second.

' - refute_includes public.get('/ru/dc/dc-2').body, 'Second.' # no fallback across languages - end - - def test_reserve_dc_has_a_page - Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Тверь', operator: 'Op') - get '/ru/dc/dc-26' - assert last_response.ok? - assert_includes last_response.body, 'href="/ru/dc/dc-25" rel="prev"' - get '/sitemap.xml' - assert_includes last_response.body, 'https://example.org/ru/dc/dc-26' - end - - def test_dc_page_unknown_and_trailing_slash - get '/en/dc/nope' - assert_equal 404, last_response.status - get '/en/dc/dc-1/' - assert_equal 301, last_response.status - assert_equal 'http://example.org/en/dc/dc-1', last_response.location - end - - def test_card_links_to_dc_pages - hit!(0) - get '/en' - body = last_response.body - assert_includes body, 'Datacenter page →' - assert_includes body, '
DC 1' + assert_equal ['First para.', 'Second.'], @dcs[1].about('en') + assert_empty @dcs[1].about('ru') # no fallback across languages end def with_env(vars) @@ -358,18 +156,6 @@ class BingoTest < Minitest::Test old.each { |k, v| ENV[k] = v } end - def test_indexnow_key_file - get '/0123456789abcdef.txt' - assert_equal 404, last_response.status - with_env('INDEXNOW_KEY' => '0123456789abcdef') do - get '/0123456789abcdef.txt' - assert last_response.ok? - assert_equal '0123456789abcdef', last_response.body - get '/fedcba9876543210.txt' - assert_equal 404, last_response.status - end - end - def test_indexnow_ping assert_nil Bingo::IndexNow.ping([@dcs[0]]) # no key: nothing sent sent = [] @@ -385,7 +171,6 @@ class BingoTest < Minitest::Test end def test_admin_incident_pings_indexnow - @app = AsxpioWeb token = admin_token pinged = [] Bingo::IndexNow.stub(:ping, ->(dcs) { pinged << dcs.map(&:slug) }) do @@ -396,51 +181,6 @@ class BingoTest < Minitest::Test assert_equal [['dc-3'], ['dc-3']], pinged end - def test_root_varies_on_language - get '/' - assert_equal 'Accept-Language', last_response.headers['Vary'] - end - - def test_seo_head - hit!(3) - @dcs[3].update(photo_key: 'bingo/dc-4-0123abcd.jpg', photo_variants: true) - @dcs[0].update(info_url: 'https://example.com/dc-1') - get '/en' - body = last_response.body - assert_includes body, 'Datacenter Bingo — strikes' - assert_match %r{<h1>Datacenter <span>Bingo</span> <small>Drone strikes, fires and outages at Russia's 25 largest datacenters</small></h1>}, body - assert_includes body, '1 of Russia's 25 largest' - assert_includes body, '<meta property="og:image" content="https://example.org/p/dc-4-0123abcd-1200.webp">' - assert_includes body, 'src="/p/dc-4-0123abcd-480.webp"' - refute_includes body, 'og:image:width' - @dcs[3].update(photo_og: true) - get '/en' - body = last_response.body - assert_includes body, '<meta property="og:image" content="https://example.org/p/dc-4-0123abcd-og.jpg">' - assert_includes body, '<meta property="og:image:width" content="1200">' - assert_includes body, '<meta property="og:image:alt" content="ЦОД 4, Moscow">' - assert_includes body, 'og:locale:alternate" content="uk_UA"' - ld = JSON.parse(body[%r{<script type="application/ld\+json">(.*?)</script>}m, 1]) - list = ld['@graph'].find { |n| n['@type'] == 'ItemList' } - assert_equal 25, list['itemListElement'].size - assert_equal 'DC 1', list['itemListElement'][0]['item']['name'] - hit = list['itemListElement'][3]['item'] - assert_equal Date.today.iso8601, hit['event'][0]['startDate'] - assert_equal 'Outage: Авария 3', hit['event'][0]['name'] - refute list['itemListElement'][0]['item'].key?('event') - assert_equal({ '@type' => 'CreativeWork', 'url' => 'https://example.com/dc-1' }, list['itemListElement'][0]['item']['subjectOf']) - refute_includes body, 'sameAs' - assert list['dateModified'] - end - - def test_photo_falls_back_to_original_without_variants - @dcs[0].update(photo_key: 'bingo/dc-1-0123abcd.jpg', photo_variants: false) - assert_equal 'dc-1-0123abcd.jpg', @dcs[0].photo_file(480) - @dcs[0].update(photo_variants: true) - assert_equal 'dc-1-0123abcd-480.webp', @dcs[0].photo_file(480) - assert_equal 'dc-1-0123abcd.jpg', @dcs[0].photo_file - end - def test_resize_produces_small_webp skip 'ImageMagick not available' unless system(Bingo::Photo::CONVERT, '-version', out: File::NULL, err: File::NULL) png, = Open3.capture2(Bingo::Photo::CONVERT, '-size', '2000x1000', 'gradient:red-blue', 'png:-', binmode: true) @@ -477,16 +217,12 @@ class BingoTest < Minitest::Test assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '<html>') } end - # --- admin (served by AsxpioWeb) ------------------------------------------- - def test_admin_requires_login - @app = AsxpioWeb get '/admin/bingo' assert_equal 302, last_response.status end def test_admin_dashboard - @app = AsxpioWeb hit!(2) DB.connection[:invoices].delete inv = Invoice.build(client_name: 'ACME', client_email: 'b@example.com', currency: 'EUR', gel_rate: '3.05', @@ -510,7 +246,6 @@ class BingoTest < Minitest::Test end def test_admin_records_and_deletes_incident - @app = AsxpioWeb get '/admin/bingo', {}, admin_env assert last_response.ok? token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1] @@ -533,7 +268,6 @@ class BingoTest < Minitest::Test end def test_admin_edits_datacenter - @app = AsxpioWeb get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env assert last_response.ok? token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1] @@ -564,7 +298,6 @@ class BingoTest < Minitest::Test end def test_admin_adds_datacenter_at_position - @app = AsxpioWeb token = admin_token('/admin/bingo/dc/new') post '/admin/bingo/dc', dc_params(authenticity_token: token, slug: 'ix-mos7', position: '9'), admin_env assert_equal 302, last_response.status, last_response.body @@ -581,7 +314,6 @@ class BingoTest < Minitest::Test end def test_admin_rejects_bad_or_duplicate_slug - @app = AsxpioWeb token = admin_token('/admin/bingo/dc/new') post '/admin/bingo/dc', dc_params(authenticity_token: token, slug: 'Bad Slug'), admin_env assert_equal 422, last_response.status @@ -591,7 +323,6 @@ class BingoTest < Minitest::Test end def test_admin_moves_datacenter - @app = AsxpioWeb token = admin_token("/admin/bingo/dc/#{@dcs[24].id}") post "/admin/bingo/dc/#{@dcs[24].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 25', position: '1'), admin_env assert_equal 1, @dcs[24].refresh.position @@ -600,7 +331,6 @@ class BingoTest < Minitest::Test end def test_admin_deletes_datacenter_with_incidents - @app = AsxpioWeb hit!(4) token = admin_token("/admin/bingo/dc/#{@dcs[4].id}") post "/admin/bingo/dc/#{@dcs[4].id}/delete", { authenticity_token: token }, admin_env @@ -620,7 +350,6 @@ class BingoTest < Minitest::Test end def test_upload_photo_via_admin - @app = AsxpioWeb get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1] file = Rack::Test::UploadedFile.new(StringIO.new(JPEG), 'image/jpeg', original_filename: 'a.jpg') diff --git a/test/test_helper.rb b/test/test_helper.rb index 2157e93..88b8ace 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -29,7 +29,6 @@ else end require_relative '../asxpio' -require_relative '../bingo_web' require 'minitest/autorun' require 'rack/test' diff --git a/views/bingo/_credits.erb b/views/bingo/_credits.erb deleted file mode 100644 index 2571b4c..0000000 --- a/views/bingo/_credits.erb +++ /dev/null @@ -1,2 +0,0 @@ -<p class="credits"><%= t(:credits_ai) %> - <%== format(Rack::Utils.escape_html(t(:credits_hym)), link: '<a href="https://hackyourmom.com/en/karta-data-czentriv-na-rasii/" target="_blank" rel="noopener">hackyourmom</a>') %></p> diff --git a/views/bingo/_dc_incidents.erb b/views/bingo/_dc_incidents.erb deleted file mode 100644 index 63f78f7..0000000 --- a/views/bingo/_dc_incidents.erb +++ /dev/null @@ -1,10 +0,0 @@ -<% if dc.incidents.empty? %> - <p class="empty"><%= t(:no_incidents) %></p> -<% else %> - <ul> - <% dc.incidents.each do |i| %> - <li><%= kind_icon(i.kind) %> <time datetime="<%= i.occurred_on %>"><%= fmt_date(i.occurred_on) %></time> — - <% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener nofollow"><%= i.l(:title, @locale) %></a><% else %><%= i.l(:title, @locale) %><% end %></li> - <% end %> - </ul> -<% end %> diff --git a/views/bingo/_dc_meta.erb b/views/bingo/_dc_meta.erb deleted file mode 100644 index d71b859..0000000 --- a/views/bingo/_dc_meta.erb +++ /dev/null @@ -1,9 +0,0 @@ -<div class="meta"> - <%= [dc.operator, mw(dc), dc.l(:city, @locale), status_label(dc) || (dc.year && format(t(:since), dc.year))].compact.join(' · ') %> -</div> -<% if dc.photo_credit || dc.info_url %> - <div class="credit"> - <% if dc.photo_credit %><%= t(:photo) %>: <%= dc.photo_credit %><% end %> - <% if dc.info_url %> · <a href="<%= dc.info_url %>" target="_blank" rel="noopener nofollow"><%= t(:source) %></a><% end %> - </div> -<% end %> diff --git a/views/bingo/dc.erb b/views/bingo/dc.erb deleted file mode 100644 index b63d6ba..0000000 --- a/views/bingo/dc.erb +++ /dev/null @@ -1,35 +0,0 @@ -<% dc = @dc %> -<main> - <header> - <nav class="crumbs"><a href="/<%= @locale %>"><%= t(:title) %></a></nav> - <nav class="langs"> - <% Bingo::LOCALES.each do |l| %> - <a href="<%= @path_for.(l) %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a> - <% end %> - </nav> - </header> - - <article class="dc-page<%= ' hit' if dc.incidents.any? %>"> - <% if (photo = photo_path(dc, 1200)) %><img src="<%= photo %>" alt="<%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %>" decoding="async"><% end %> - <h1><%= dc.l(:name, @locale) %></h1> - <%== erb :_dc_meta, layout: false, locals: { dc: dc } %> - <p class="facts"><%= dc_facts(dc).join(' ') %></p> - <% dc.about(@locale).each do |para| %><p class="facts"><%= para %></p><% end %> - <section> - <h2><%= t(:incidents) %></h2> - <%== erb :_dc_incidents, layout: false, locals: { dc: dc } %> - </section> - </article> - - <nav class="pager"> - <% if @prev %><a href="<%= dc_path(@prev) %>" rel="prev">← #<%= @prev.position %> <%= @prev.l(:name, @locale) %></a><% else %><span></span><% end %> - <% if @next %><a href="<%= dc_path(@next) %>" rel="next">#<%= @next.position %> <%= @next.l(:name, @locale) %> →</a><% end %> - </nav> - <p><a href="/<%= @locale %>"><%= t(:back) %></a></p> - - <footer> - <p><%= t(:footer) %></p> - <%== erb :_credits, layout: false %> - <p>© <%= Date.today.year %> <a href="https://asxp.io">asxp.io</a></p> - </footer> -</main> diff --git a/views/bingo/index.erb b/views/bingo/index.erb deleted file mode 100644 index 099c7f4..0000000 --- a/views/bingo/index.erb +++ /dev/null @@ -1,117 +0,0 @@ -<% b = @board %> -<main> - <header> - <h1><%= t(:lead) %> <span><%= t(:accent) %></span> <small><%= t(:tagline) %></small></h1> - <div class="switches"> - <nav class="toggle" aria-label="<%= t(:view_label) %>"> - <% Bingo::VIEWS.each do |v| %> - <a href="<%= card_path(@locale, v) %>" <%== 'aria-current="page"' if v == @view %>><%= t(:"view_#{v}") %></a> - <% end %> - </nav> - <nav class="langs"> - <% Bingo::LOCALES.each do |l| %> - <a href="<%= card_path(l) %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a> - <% end %> - </nav> - </div> - </header> - - <p class="lead"><%= t(:desc) %></p> - <% provs = Bingo::Board.providers(@view) %> - <form class="providers" method="get" action="/<%= @locale %>"> - <% if @view == 'all' %><input type="hidden" name="view" value="all"><% end %> - <fieldset> - <legend><%= t(:providers_label) %></legend> - <% provs.each do |slug, total, crossed| %> - <label><input type="checkbox" name="p[]" value="<%= slug %>" <%= 'checked' if @providers.include?(slug) %>> <%= Bingo.provider_name(slug, @locale) %> <span><%= crossed %>/<%= total %></span></label> - <% end %> - </fieldset> - <div class="provider-actions"> - <button type="submit"><%= t(:providers_apply) %></button> - <% if @board.filtered? %><a href="<%= card_path(@locale, @view, []) %>"><%= t(:providers_reset) %></a><% end %> - </div> - </form> - <p class="meta"> - <%= t(:crossed) %> <b><%= b.hit.count(true) %></b>/<%= b.cells.size %> - · <%= t(:lines) %> <b><%= b.wins.size %></b> - <% if b.updated_on %>· <%= t(:updated) %> <b><%= fmt_date(b.updated_on) %></b><% end %> - </p> - - <% if b.wins.any? %> - <div class="banner"> - <%= b.wins.size == Bingo::LINES.size ? t(:full) : b.wins.size > 1 ? format(t(:bingo_n), b.wins.size) : t(:bingo) %> - </div> - <% end %> - - <div class="letters" aria-hidden="true"><% t(:letters).each do |ch| %><div><%= ch %></div><% end %></div> - <div class="grid"> - <% b.cells.each_with_index do |dc, idx| %> - <% photo = photo_path(dc, 480) %> - <a href="<%= dc_path(dc) %>" data-dlg="dlg-<%= dc.slug %>" - class="cell<%= ' photo' if photo %><%= ' hit' if b.hit[idx] %><%= ' win' if b.win_cells.include?(idx) %>" - aria-label="<%= dc.l(:name, @locale) %><%= " — #{kind_label(dc.incidents.first.kind)}" if b.hit[idx] %>"> - <% if photo %><img class="bg" src="<%= photo %>" alt="" width="480" height="480" decoding="async"<%== ' loading="lazy"' if idx >= 10 %>><% end %> - <div> - <div class="n">#<%= idx + 1 %><%= " · #{mw(dc, tag: false)}" if mw(dc) %></div> - <% if status_label(dc) %><div class="tag"><%= status_label(dc) %></div><% end %> - </div> - <div> - <div class="name"><%= dc.l(:name, @locale) %></div> - <div class="sub"><%= dc.l(:city, @locale) %></div> - </div> - <% if b.hit[idx] %><div class="stamp"><%= kind_icon(dc.incidents.first.kind) %></div><% end %> - </a> - <% end %> - <% (b.cells.size...Bingo::SIZE**2).each do %><div class="cell empty" aria-hidden="true"></div><% end %> - </div> - - <section> - <h2><%= t(:log) %></h2> - <ul class="log"> - <% if b.incidents.empty? %> - <li class="empty"><%= t(:empty) %></li> - <% end %> - <% b.incidents.each do |i| %> - <li> - <time datetime="<%= i.occurred_on %>"><%= fmt_date(i.occurred_on) %></time> - <span><%= kind_icon(i.kind) %></span> - <div> - <% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener nofollow"><%= i.l(:title, @locale) %></a><% else %><%= i.l(:title, @locale) %><% end %> - <div class="dc"><a href="<%= dc_path(i.datacenter) %>"><%= i.datacenter.l(:name, @locale) %></a> · <%= kind_label(i.kind) %></div> - </div> - </li> - <% end %> - </ul> - </section> - - <footer> - <p><%= t(:footer) %></p> - <%== erb :_credits, layout: false %> - <p>© <%= Date.today.year %> <a href="https://asxp.io">asxp.io</a></p> - </footer> -</main> - -<% b.cells.each do |dc| %> - <dialog id="dlg-<%= dc.slug %>"> - <% if (photo = photo_path(dc, 1200)) %><img src="<%= photo %>" alt="<%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %>" loading="lazy" decoding="async"><% end %> - <div class="body"> - <h3><%= dc.l(:name, @locale) %></h3> - <%== erb :_dc_meta, layout: false, locals: { dc: dc } %> - <%== erb :_dc_incidents, layout: false, locals: { dc: dc } %> - <div class="actions"> - <a href="<%= dc_path(dc) %>"><%= t(:dc_page) %></a> - <form method="dialog"><button><%= t(:close) %></button></form> - </div> - </div> - </dialog> -<% end %> - -<script> - // Cells are links to the datacenter pages; a plain click opens the dialog - // instead, modified clicks (new tab etc.) keep the link. - document.querySelectorAll('[data-dlg]').forEach(a => a.addEventListener('click', e => { - if (e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; - e.preventDefault(); - document.getElementById(a.dataset.dlg).showModal(); - })); -</script> diff --git a/views/bingo/layout.erb b/views/bingo/layout.erb deleted file mode 100644 index 29a7cca..0000000 --- a/views/bingo/layout.erb +++ /dev/null @@ -1,163 +0,0 @@ -<% base = base_url %> -<% og_img = @og && "#{base}/p/#{@og.og_file}" %> -<!doctype html> -<html lang="<%= @locale %>"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1"> -<title><%= @title %> - - - - -<% Bingo::LOCALES.each do |l| %> - -<% end %> -<% if @x_default %> - -<% end %> - - - - - - - -<% (Bingo::LOCALES - [@locale]).each do |l| %> - -<% end %> -<% if og_img %> - -<% if @og.photo_og %> - - - -<% end %> - - -<% end %> - - - - - - - - - - -<%== yield %> - - diff --git a/views/bingo/sitemap.erb b/views/bingo/sitemap.erb deleted file mode 100644 index 0c3013b..0000000 --- a/views/bingo/sitemap.erb +++ /dev/null @@ -1,16 +0,0 @@ - - -<% @entries.each do |path_for, lastmod, x_default| %> -<% Bingo::LOCALES.each do |l| %> - - <%= base_url %><%= path_for.(l) %> -<% if lastmod %> <%= lastmod.utc.iso8601 %> -<% end %> -<% Bingo::LOCALES.each do |alt| %> -<% end %> -<% if x_default %> -<% end %> - -<% end %> -<% end %> -