From 5245531051ad2b2e27e3d3e0e869bddb1898f9ad Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Sat, 12 Sep 2026 05:38:33 -0400 Subject: [PATCH] fix(gateway): retire dweb.link before it is switched off on 2026-09-21 The IPFS Foundation is shutting dweb.link down for good on 2026-09-21 (gatewaychanges.ipfs.io). It is the app's default gateway. The HTTP 429s already showing up on live sites -- with Retry-After around half an hour -- are its announced escalating pauses, not load. Left alone, on the 21st: every bare fxfiles.top link stops resolving, and every new generation bakes dead asset URLs into immutable HTML. WHY CHANGING THE CONSTANT IS NOT ENOUGH `IpfsGatewayHelper.init()` WRITES the default into secure storage on first run, so everyone who has ever launched the app has the dweb template persisted. A new `defaultTemplate` would therefore reach new installs only, and every existing user would sail into the shutdown. `retiredTemplates` is the part that actually moves them: a stored value matching a retired template is replaced at startup. That deliberately overrides what looks like a choice -- for almost everyone it was just the old default, and honouring it would hand them a broken site. The migration decision is split into a pure `resolveStoredTemplate` so it can be tested without a storage backend. It is the single thing standing between an existing user and a dead gateway, so it is covered directly. THE SAME PROBLEM ON THE SHARED LINK Links copied since the gateway picker shipped carry an EXPLICIT `?gw=dweb` -- and an explicit key beats the default by design, which here would mean beating it into a dead host. So `dweb` is removed from the Worker's allowlist entirely, which makes those links fall back rather than fail. The "opinion" the explicit key encoded was manufactured by the default, not expressed by anyone. ALSO - `fx` (ipfs.cloud.fx.land) joins Filebase as a preset and an allowlisted key. Verified 2026-09-12 to serve these CIDs with correct content types, and unlike any third party it is ours -- so there is a live fallback if Filebase moves. - dweb is dropped from `presets`: offering a gateway that `init()` migrates away from on the next launch is a trap. - The IPNS fallback to `{name}.ipns.dweb.link` becomes a plain 502. It never resolved anyway (w3name does not publish to the DHT, measured 2026-05-30) and now points at a dead host; redirecting there turned our error into a more confusing one. - The subdomain-safety guard goes with it. Both remaining gateways are path-style, where a case-sensitive CIDv0 or an over-63-char CID is harmless. Noted in the source and README in case a subdomain gateway ever returns. Tests: 27 Dart (migration, presets, resolver keys, link decoration), 33 Node for the Worker, including one that asserts no redirect target on any code path is a dweb host. This is the first of three steps. It stops the bleeding but does NOT yet make a gateway change reach sites that are already generated -- their asset URLs are absolute and inside immutable HTML. That needs the fxfiles.top/a/ indirection, which is planned separately and whose core mechanic is already validated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwMWmCivEpYTmmzzmjSTAf --- cloudflare/README.md | 37 ++++-- cloudflare/ipns-resolver-worker.js | 116 +++++++++--------- cloudflare/ipns-resolver-worker.test.mjs | Bin 10110 -> 9583 bytes lib/core/services/ipfs_gateway_helper.dart | 90 ++++++++++---- lib/main.dart | 4 +- lib/web/screens/web_api_config_screen.dart | 2 +- lib/web/screens/web_settings_screen.dart | 4 +- .../services/ipfs_gateway_helper_test.dart | 90 ++++++++++++-- 8 files changed, 234 insertions(+), 109 deletions(-) diff --git a/cloudflare/README.md b/cloudflare/README.md index b135f9d..6356ae2 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -14,15 +14,27 @@ GET https://fxfiles.top/w//page.html -> 302 https://.ipfs.dweb.l An optional `?gw=` selects which gateway the redirect lands on: ``` -GET /w/?gw=dweb -> 302 https://.ipfs.dweb.link/ -GET /w/?gw=filebase -> 302 https://ipfs.filebase.io/ipfs/ +GET /w/?gw=filebase -> 302 https://ipfs.filebase.io/ipfs/ (default) +GET /w/?gw=fx -> 302 https://ipfs.cloud.fx.land/gateway/ ``` -It exists because **dweb.link returns HTTP 429 once a site sees real traffic** -(measured 2026-09-12: a freshly generated site 429'd on dweb.link and returned -200 from Filebase at the same moment). The app appends this automatically from -the gateway chosen in Settings, so a user who switches gets working links -without re-minting anything. +The app appends this automatically from the gateway chosen in Settings, so a +user who switches gets working links without re-minting anything. + +### dweb.link is retired — do not re-add it + +The IPFS Foundation **shut dweb.link down permanently on 2026-09-21** +(gatewaychanges.ipfs.io). The HTTP 429s seen beforehand, with a `Retry-After` of +around half an hour, were its announced escalating pauses — not load. + +`dweb` is therefore absent from `GATEWAYS`, and that is deliberate in a way +worth spelling out: links minted while dweb was the default carry an **explicit** +`?gw=dweb`, and an explicit key normally beats the default. But that "choice" +was manufactured by the default rather than made by anyone, so honouring it +would send those links to a dead host. Dropping the key makes them fall back to +the default instead. The app makes the matching move — `IpfsGatewayHelper` +lists the dweb template in `retiredTemplates`, which migrates any user still +holding it onto the current default at startup. `GATEWAYS` in the Worker is a **fixed allowlist**, and that is load-bearing: this is a link anyone can share, so accepting a caller-supplied destination @@ -93,9 +105,14 @@ If you deploy to a different host, set the secure-storage key - Redirect is **302** (never 301) with `Cache-Control: max-age=30`, so a regeneration propagates within ~30s while still allowing edge caching. -- The `dweb` gateway assumes CIDv1 (a CIDv0 `Qm…` cannot go in a subdomain — - hostnames are case-insensitive and base58 is not). `filebase` is path-style - and has no such constraint. The app mints CIDv1, so this is not a live issue. +- Both current gateways are **path-style**, so CID encoding is a non-issue. A + subdomain-style gateway would need the guard back: a CIDv0 (`Qm…`, base58 and + case-sensitive) or a CID over the 63-character DNS label limit silently + corrupts when used as a hostname, but is fine in a path. - The Worker rejects paths whose name isn't a plausible `k51…` IPNS name, and charset-checks the CID before interpolating it, so it can't be abused as an open redirector. +- When w3name is unreachable the Worker returns a plain **502**. It used to + redirect to `{name}.ipns.dweb.link`, which never resolved (w3name does not + publish to the DHT) and is now a dead host — redirecting there only turned our + error into a more confusing one. diff --git a/cloudflare/ipns-resolver-worker.js b/cloudflare/ipns-resolver-worker.js index add0e89..7368ffd 100644 --- a/cloudflare/ipns-resolver-worker.js +++ b/cloudflare/ipns-resolver-worker.js @@ -2,11 +2,11 @@ * FxFiles stable-link resolver — a STATELESS Cloudflare Worker that is the * fast, pretty front door over each website group's IPNS name. * - * GET https://fxfiles.top/w/[/][?gw=dweb|filebase] + * GET https://fxfiles.top/w/[/][?gw=filebase|fx] * -> resolve to its current CID via w3name's plain HTTP API * -> 302 to that gateway's URL for the CID, e.g. - * https://.ipfs.dweb.link/ (gw=dweb, default) - * https://ipfs.filebase.io/ipfs// (gw=filebase) + * https://ipfs.filebase.io/ipfs// (gw=filebase, default) + * https://ipfs.cloud.fx.land/gateway// (gw=fx) * * Why this design: * - The app never talks to Cloudflare and holds NO credential here. The IPNS @@ -14,15 +14,15 @@ * record and redirects. Anyone can redeploy it; losing it loses nothing. * - Resolving through w3name's HTTP API is fast (no DHT wait) and lands on the * immutable per-CID URL, which gateways cache aggressively. - * - If w3name is slow/unavailable, we fall back to the raw IPNS gateway URL. - * NOTE (measured 2026-05-30): that fallback only resolves if the record is - * ALSO published to the IPFS DHT — w3name does NOT do that, so today a bare - * {name}.ipns.dweb.link does NOT resolve on a plain gateway (it 500s). So - * name->CID resolution currently depends on this Worker reading w3name (both - * non-fx). The CONTENT (CID) IS fully public-reachable via IPFS gateways - * (verified 200). Net: the link survives fx being down, but not Cloudflare + - * w3name both being down. See README for the optional DHT-publish step that - * makes any gateway work. + * - When w3name is unreachable we return a plain 502 rather than redirecting + * anywhere. There used to be a fallback to {name}.ipns.dweb.link; it never + * actually resolved (w3name does not publish to the DHT, so a bare name 500s + * on a plain gateway — measured 2026-05-30) and that host is switched off for + * good on 2026-09-21 anyway. So name->CID resolution depends on this Worker + * reading w3name, both non-fx. The CONTENT (CID) stays fully public-reachable + * via any IPFS gateway. Net: a link survives fx being down, but not + * Cloudflare + w3name both being down. See README for the optional + * DHT-publish step that would make any gateway resolve the name directly. * * Abuse posture (it MUST stay publicly reachable so links + previews work): * - Not an open redirector: the destination host comes from a FIXED allowlist @@ -37,12 +37,11 @@ * (only active if `RW_LIMITER` is configured in wrangler.toml). Pair with a * dashboard WAF Rate Limiting Rule on `/w/*` for global enforcement. * - * Cloudflare's own IPFS gateway was decommissioned in Aug 2024 — irrelevant - * here; this Worker `fetch()`es the IPFS Foundation gateways (dweb.link/ipfs.io). + * Gateway churn is the norm, which is why the destination is a one-line change + * here rather than a property of published content: Cloudflare retired its IPFS + * gateway in Aug 2024, and the IPFS Foundation retires dweb.link on 2026-09-21. */ -const GATEWAY_HOST = 'ipfs.dweb.link'; -const IPNS_GATEWAY_HOST = 'ipns.dweb.link'; const W3NAME_ENDPOINT = 'https://name.web3.storage'; const REDIRECT_CACHE_SECONDS = 30; // keep short so regenerations propagate fast const MAX_NAME_LEN = 80; // base36 `k51…` libp2p-key names are ~62 chars @@ -52,20 +51,6 @@ const CID_RE = /^[A-Za-z0-9]{40,120}$/; // eslint-disable-next-line no-control-regex const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; -/** - * Can this CID be a DNS label, i.e. is the subdomain gateway shape usable? - * - * Two ways it cannot, both of which silently corrupt the CID rather than - * failing loudly: - * - CIDv0 (`Qm…`) is base58 and CASE-SENSITIVE, but hostnames are not — the - * resolver lowercases the label and the gateway receives a different CID. - * - A DNS label caps at 63 characters (RFC 1035). CIDv1-base32 over SHA-256 - * is 59, but a larger hash function would overrun it. - * Either way the answer is the same: use the gateway's path form, which - * preserves case and has no length limit. - */ -const SUBDOMAIN_SAFE_CID = /^[a-z0-9]{1,63}$/; - /** * Gateways this Worker may redirect to, selected with `?gw=`. * @@ -75,23 +60,28 @@ const SUBDOMAIN_SAFE_CID = /^[a-z0-9]{1,63}$/; * open redirector, which is exactly the property the checks below exist to * protect. Unknown or missing `gw` falls back to the default. * - * `cid` builds the immutable per-CID URL in whichever shape the gateway wants: - * subdomain — https://.ipfs.dweb.link/ - * path — https://ipfs.filebase.io/ipfs// + * `dweb` IS DELIBERATELY ABSENT. The IPFS Foundation switched dweb.link off for + * good on 2026-09-21. Links minted while it was the default carry an explicit + * `?gw=dweb`, and an explicit key would normally beat the default — but that + * "choice" was manufactured by the default rather than made by anyone, so + * honouring it would send those links to a dead host. Dropping the key makes + * them fall back here instead, which is the whole point. + * + * Both remaining gateways are PATH-style (`https://host/ipfs//`), so + * there is no subdomain-safety problem to handle. A subdomain gateway would need + * that guard back: a case-sensitive CIDv0 (`Qm…`) or a CID over the 63-character + * DNS label limit silently corrupts as a hostname, but is fine in a path. */ const GATEWAYS = { - dweb: { - cid: (cid, path) => - SUBDOMAIN_SAFE_CID.test(cid) - ? `https://${cid}.${GATEWAY_HOST}${path}` - : `https://dweb.link/ipfs/${cid}${path}`, - }, filebase: { - // dweb.link starts returning 429 once a site sees real traffic; Filebase - // served the same CID fine at the same moment (measured 2026-09-12). - // Path-style, so it needs no subdomain-safety dance. + // Served the same CID fine at the moment dweb.link was 429ing it + // (measured 2026-09-12). cid: (cid, path) => `https://ipfs.filebase.io/ipfs/${cid}${path}`, }, + fx: { + // Ours. Verified 2026-09-12 to serve these CIDs with correct content types. + cid: (cid, path) => `https://ipfs.cloud.fx.land/gateway/${cid}${path}`, + }, }; /** @@ -111,7 +101,7 @@ function joinPath(inner, subpath) { } /** Default when `?gw=` is absent — keeps every already-shared link working. */ -const DEFAULT_GATEWAY = 'dweb'; +const DEFAULT_GATEWAY = 'filebase'; export default { async fetch(request, env) { @@ -167,26 +157,24 @@ export default { forwarded.delete('gw'); const query = forwarded.toString() ? `?${forwarded}` : ''; - // Unknown keys fall back rather than erroring: a link with a typo should - // still resolve, just on the default gateway. + // Unknown keys fall back rather than erroring: a link with a typo — or a + // retired key like `gw=dweb` — should still resolve, just on the default. // // hasOwn, NOT a bare `GATEWAYS[gwKey] ||` — gwKey is caller-controlled and // a plain object literal inherits from Object.prototype, so `?gw=toString` // and `?gw=__proto__` would hand back a TRUTHY inherited value whose `.cid` - // is undefined. That throws inside the try below and drops the request on - // the IPNS fallback, which (see the note at the top) does not resolve. The - // typo would break the link instead of quietly using the default. + // is undefined. That throws inside the try below and the request ends as a + // 502, so the typo would break the link instead of using the default. const gateway = Object.hasOwn(GATEWAYS, gwKey ?? '') ? GATEWAYS[gwKey] : GATEWAYS[DEFAULT_GATEWAY]; - // The happy path below never uses an IPNS gateway: w3name resolves the - // name here and we redirect to the immutable /ipfs/, which every - // gateway serves. This fallback only runs when w3name is unreachable, and - // per the note at the top it does not resolve anyway (the record is not on - // the DHT). Left on dweb because it is the only host that would even try. - const ipnsFallback = - `https://${name}.${IPNS_GATEWAY_HOST}${subpath}${query}`; + // NOTE: there is no IPNS-gateway fallback any more. It pointed at + // ipns.dweb.link, which (a) never resolved anyway — w3name does not publish + // to the DHT, so a bare name 500s on a plain gateway — and (b) is being + // switched off entirely on 2026-09-21. Redirecting a visitor to a host that + // is guaranteed to fail just turns our error into someone else's confusing + // one, so when w3name is unreachable we now say so plainly instead. try { const res = await fetch(`${W3NAME_ENDPOINT}/name/${name}`, { @@ -219,10 +207,24 @@ export default { } } } catch (_) { - // fall through to the IPNS gateway fallback + // fall through to the plain error below } - return redirect(ipnsFallback); + // Reached when w3name is unreachable, returns a non-OK, or hands back a + // value that is not a usable `/ipfs/`. 502, because the failure is + // upstream of us and the visitor's link is probably fine. + return new Response( + 'Could not resolve this FxFiles link right now. The IPNS name service ' + + 'is unreachable — please try again shortly.', + { + status: 502, + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + 'Retry-After': '30', + }, + }, + ); }, }; diff --git a/cloudflare/ipns-resolver-worker.test.mjs b/cloudflare/ipns-resolver-worker.test.mjs index 2dfbcddb2c829eecf02ac0a82019104b98f3b56b..88e044587fff0e1bedf30a88c055e53b0b0c46db 100644 GIT binary patch delta 2286 zcmaJ@&2Jk;6j##JP2468a+Btx4KJ>nwNqy|P1UMR8bU)`2~-t@52+Gg9`BCVL)J6f zo!QtXvci>H#c<_{gg}TYcT~rt&5KTDEt{^p_rL3)QdcUJtB%9MnzY?O#GYMi*#Y66;}b=j zr0o=T`;qnUgSWCYibRy;uz&T)SA%=b;v(F_3fd6!ZZ)JDP;iN)K)EMD)hS5Qpn!m@ zKp+eV<8XO-r3{?nLVOfak44l~HY_^XTFhO=1P3A|jnvyID)ov8UxM8%q;G6`KPLLS zd`7=NI$THqE3_3N4)wbDzEDuu#bvfsM8#6SoH?aCqbD!$I0(vM5fcWD7+K~R>XHb{ zAaF;x#AINV*0LS_%h6NEYAm2tBK5@;>Go!^WCHM)HEkTXH(&auV5HTk_{Mc>|z z174F>?SS!ZV6wNr2clMk8urQ;0&h61EU#QyTE4cla?yr67%ZW|xZ$c<$Gw0l?2ybN zoavZHYa|X1IN^ZI2Fzt@4>qt)v*Q|&!^f~q+t3to;31V!g9HKIGF~=#)mR}WQIL1* zg1I!0OrsuZk!U=zSA_aSJzywLp2yh#GDwl!7<`85?P0l*2t&;K004Ir&46JWQM?!} zPsL2;%)mU#WBvZCfU0*3ip{RRFFJDb+m2tF# zk0iDUp|EV?D){x|&yty_laEMb#tkQ%Q|g%kit&lz8byuTq`Jl>_c#Coa2xJO&5E{$+YYx>*SZ%#iIMbe1u+`Xj{`hgWS5BMC2kB4m1E9G;?_-<%h2z4y(7?&cc{SP(F+}}+X6lWn zPDC@0T`|}Vf+tUrGzogSIrS86sUps_)3>d|o;9i)<#)gep|J#2B)%3;Yb}nM6XyzlQ1VIsgCw delta 2445 zcmc(hOKe+36o!?i#IDmcPbX;;H7D*PPVplTr8KFcD)d28wMj`*Di)RQ_|KB8#a{?AU5oZ*dv6*l10Ti*N-^RD!_(?CHs2jJpOae zckXXDpRYf9^9%9e5%Z+;^>B{4Zid-Awb0Q|kC<9$U_=)nu0^Zx#H{M z@~%a0%~bC<*AyBR+I;|7PnJrK$OmG3{2d=F0#ek~kEGKeq)2*PNw-X=*6yrtL8B*X zeSM1^hdH*)?S4B5_q+Z9o3cLi1_jw{5DZ`iP$KX+tG5OA#LC_6)s4-y?YBYZg1Ccr zyt{cT0ZN)LJ4WZA=qp{1UPFch%eb66s^n{6P>`6T;w9!WEj_Z}@!ItN5dyOkJHU+>$oajucBx zv*xqG3q25TO%b0q--b?_-v<}X*L~+En`37s;31Tm>+>bB(lo6&x#gLvWj>l1Y7Pqx z*d9LIpRZtl0=9e3q3s&TGP)MDEJHFP`QBvSD0gS5E zMwDiMy0XGOGN-`N;OtRnGPt&O+9QKJ6WoFok#^1a09DW=41~mi%7|NvWS;e zoh3Qx;V+>yXW3K(K#W=plkmw+i*I*UwpW3UCqVEr_t38jH<{Ox@#)b0)2&9>N8G-K z=>Sr%o*Rm^{+%-SPmG&4qG$Fn$juv9VRmMI0Yxh*$_*#XWf|@Q({OPf>;m&tDu{44 zNgB`3rMFgBl5-0eNz8@C1Z1eKDdCKh&tssdLjfw%ce5ZdaL*Sc9u+3Xuw~FLC5erG z3CEgyEg3{`Jb7_GNlt^gdx<1L`nBkUc{+6Es4GR@9E*-`zFY@7($+Y2?vv=VptR$< z`_k|C07>6K?wRC21zj*mwe!Q_BZm$HBLN6X%E`N-ij#yA_qje;pHQlso zLnj~oPr$gjwXu#I@^E~d+yk>03v~zkgs6#p{~kZjPK}t?ViR4#ld9Pmeau?LdGSsT zXC^E}#ZJ{apak0%BVgnQpYfFi{L zFvW?k|AL{&n&v9_Yar&Au_fH+vB2HE@zGUEejnNY-D6FRfBZ_Qv2|<1v2f?EEfE-X z)Gi|oDuBy|a+*%jE;+C*aD_5APLGk$FO(W#YjK6sPWwP2sp)VP$_~Ttr7FD~0aSw* Gq(1@s4I?oC diff --git a/lib/core/services/ipfs_gateway_helper.dart b/lib/core/services/ipfs_gateway_helper.dart index f3cf7d4..60cd2d9 100644 --- a/lib/core/services/ipfs_gateway_helper.dart +++ b/lib/core/services/ipfs_gateway_helper.dart @@ -12,25 +12,41 @@ import 'package:fula_files/core/services/secure_storage_service.dart'; class IpfsGatewayHelper { IpfsGatewayHelper._(); - /// Subdomain-style dweb.link template, used as the app-wide default. - static const String defaultTemplate = 'https://{cid}.ipfs.dweb.link/'; + /// Subdomain-style dweb.link template. The app-wide default until + /// 2026-09-12, now RETIRED: the IPFS Foundation is shutting this gateway + /// down for good on 2026-09-21 (gatewaychanges.ipfs.io), and the HTTP 429s + /// seen beforehand are its announced escalating pauses, not load. Kept as a + /// constant ONLY so [init] can recognise and migrate anyone still on it. + static const String dwebTemplate = 'https://{cid}.ipfs.dweb.link/'; + + /// Path-style Filebase gateway, and the app-wide default since dweb's + /// retirement — measured 2026-09-12, a site that returned 429 from dweb.link + /// returned 200 from Filebase for the same CID at the same moment. + static const String filebaseTemplate = 'https://ipfs.filebase.io/ipfs/'; - /// Pre-v0.4 default. Anyone still on this exact value is upgraded to - /// [defaultTemplate] on the next [init]. - static const String legacyDefault = 'https://ipfs.cloud.fx.land/gateway/'; + /// fx's own gateway. This was the pre-v0.4 default, and [init] used to + /// migrate people AWAY from it and onto dweb — that migration is gone, + /// because the destination is now the thing that is dying. It is offered as + /// a first-class preset again: it serves these CIDs with correct content + /// types (verified 2026-09-12) and, unlike any third party, it is ours. + static const String fxTemplate = 'https://ipfs.cloud.fx.land/gateway/'; - /// Path-style Filebase gateway. Offered as a preset because dweb.link - /// rate-limits (HTTP 429) once a site gets any real traffic — measured - /// 2026-09-12, a freshly generated site returned 429 from dweb.link and - /// 200 from Filebase at the same moment. - static const String filebaseTemplate = 'https://ipfs.filebase.io/ipfs/'; + static const String defaultTemplate = filebaseTemplate; + + /// Templates that are dead or dying. A stored value matching one of these is + /// replaced with [defaultTemplate] on the next [init] — deliberately + /// overriding what looks like a user's choice, because for most people the + /// "choice" was just the old default, and leaving it would hand them a + /// broken site. Match-and-replace is idempotent, so no migration flag. + static const Set retiredTemplates = {dwebTemplate}; /// The presets the settings picker offers, in display order. Anything /// else the user types is "Custom" — [buildUrl] accepts any template in - /// either of the two supported shapes. + /// either of the two supported shapes. dweb is deliberately ABSENT: offering + /// a gateway that [init] would migrate away from on next launch is a trap. static const Map presets = { - 'dweb.link': defaultTemplate, 'Filebase': filebaseTemplate, + 'fx.land': fxTemplate, }; /// Preset label for [template], or null when it is a custom value. @@ -51,8 +67,8 @@ class IpfsGatewayHelper { /// asset URLs written into the site (client-side, no such risk), while the /// stable link falls back to the resolver's default. static const Map _frontDoorKeys = { - defaultTemplate: 'dweb', filebaseTemplate: 'filebase', + fxTemplate: 'fx', }; static String? frontDoorGatewayKey([String? template]) => @@ -67,12 +83,18 @@ class IpfsGatewayHelper { /// exact staleness that made the setting look inert for asset URLs. /// /// A preset emits its key even when it matches the resolver's own default, - /// rather than leaving the link bare. Omitting it would read as "no - /// opinion", and the resolver is then free to send the link somewhere else - /// if its default ever moves — but a user who picked dweb.link in Settings - /// HAS an opinion, and it should survive that. Bare links stay reserved for - /// callers that genuinely have none (custom gateways, which the resolver - /// cannot honour anyway). + /// rather than leaving the link bare, so that an explicit choice survives a + /// later change of that default. Bare links stay reserved for callers that + /// genuinely have no opinion (custom gateways, which the resolver cannot + /// honour anyway). + /// + /// CAVEAT, learned the hard way when dweb.link was retired: freezing the + /// gateway into a copied link cuts both ways. Every link copied while dweb + /// was the default carries `?gw=dweb`, and that key had to be dropped from + /// the resolver's allowlist so those links would fall back instead of + /// pointing at a dead host. Decorating is right while the set of live + /// gateways is stable; once a per-site preference exists, prefer bare links + /// so they keep following the owner's current choice. static String decorateFrontDoorUrl(String frontDoorUrl, {String? template}) { final key = frontDoorGatewayKey(template); if (key == null || frontDoorUrl.isEmpty) return frontDoorUrl; @@ -87,21 +109,37 @@ class IpfsGatewayHelper { static String get cachedTemplate => _cachedTemplate; /// Run after [SecureStorageService.init] and before any consumer reads - /// the gateway. Performs the one-time replacement of the legacy default - /// — match-and-replace is naturally idempotent, so no migration flag. + /// the gateway. + /// + /// Note this WRITES on first run, which is why retiring a default is not + /// just a matter of changing the constant: every user who has ever launched + /// the app has the then-current default persisted, so a new [defaultTemplate] + /// would reach new installs only. [retiredTemplates] is what actually moves + /// existing users off a dead gateway. static Future init() async { final stored = await SecureStorageService.instance .read(SecureStorageKeys.ipfsGatewayUrl); - if (stored == null || stored.isEmpty || stored == legacyDefault) { + final resolved = resolveStoredTemplate(stored); + if (resolved != stored) { await SecureStorageService.instance.write( SecureStorageKeys.ipfsGatewayUrl, - defaultTemplate, + resolved, ); - _cachedTemplate = defaultTemplate; - } else { - _cachedTemplate = stored; } + _cachedTemplate = resolved; + } + + /// The template [init] should end up with, given what is currently stored. + /// + /// Split out as a pure function so the migration is testable without a + /// storage backend — it is the part that decides whether a user keeps + /// working after a gateway is retired, which is worth covering directly. + static String resolveStoredTemplate(String? stored) { + if (stored == null || stored.trim().isEmpty) return defaultTemplate; + final trimmed = stored.trim(); + if (retiredTemplates.contains(trimmed)) return defaultTemplate; + return trimmed; } /// Refresh the in-memory cache after the user saves a new value in diff --git a/lib/main.dart b/lib/main.dart index 53f23db..ee0f3a2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -185,8 +185,8 @@ Future _initializeApp() async { debugPrint('SecureStorageService initialization failed: $e'); } - // Migrate the legacy ipfs.cloud.fx.land gateway default to dweb.link and - // populate the sync cache that model getters/widgets read. + // Migrate anyone still on a retired gateway (dweb.link, switched off + // 2026-09-21) and populate the sync cache that model getters/widgets read. try { await IpfsGatewayHelper.init().timeout(const Duration(seconds: 3)); } catch (e) { diff --git a/lib/web/screens/web_api_config_screen.dart b/lib/web/screens/web_api_config_screen.dart index bc55b44..ae510ea 100644 --- a/lib/web/screens/web_api_config_screen.dart +++ b/lib/web/screens/web_api_config_screen.dart @@ -48,7 +48,7 @@ class _WebApiConfigScreenState extends State { 'https://ai.cloud.fx.land', 'https://ai.cloud.fx.land'), // NOTE: the IPFS gateway template is deliberately NOT here. It moved to // its own section in Settings, below Billing — it is a choice ordinary - // users make (dweb.link rate-limits, Filebase does not), not an endpoint + // users make (gateways get retired — dweb.link was), not an endpoint // override, and it needs a picker rather than a raw text field. Editing // it in two places would let this one store a near-miss of a preset that // silently degrades to "custom". diff --git a/lib/web/screens/web_settings_screen.dart b/lib/web/screens/web_settings_screen.dart index f6058ac..8b3a8d5 100644 --- a/lib/web/screens/web_settings_screen.dart +++ b/lib/web/screens/web_settings_screen.dart @@ -265,8 +265,8 @@ class _WebSettingsScreenState extends State { // Promoted OUT of More → API Configuration to the top level: this is the one // endpoint setting with a user-visible consequence — it decides which gateway // serves the images in a generated website and where a shared link resolves. - // dweb.link rate-limits (429) once a site gets traffic, so people need to - // reach this without hunting through an advanced editor. + // Gateways come and go — dweb.link was switched off entirely on 2026-09-21 — + // so people need to reach this without hunting through an advanced editor. // // This is now the ONLY editor for the key — the raw text field was removed // from More → API Configuration rather than left alongside, so a near-miss diff --git a/test/unit/core/services/ipfs_gateway_helper_test.dart b/test/unit/core/services/ipfs_gateway_helper_test.dart index 74df665..d7fc0f8 100644 --- a/test/unit/core/services/ipfs_gateway_helper_test.dart +++ b/test/unit/core/services/ipfs_gateway_helper_test.dart @@ -13,13 +13,22 @@ void main() { group('buildUrl', () { const cid = 'bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m'; + // The subdomain SHAPE is still supported for custom templates even though + // no preset uses it any more, so it stays covered. test('substitutes {cid} for subdomain-style templates', () { expect( - IpfsGatewayHelper.buildUrl(IpfsGatewayHelper.defaultTemplate, cid), + IpfsGatewayHelper.buildUrl(IpfsGatewayHelper.dwebTemplate, cid), 'https://$cid.ipfs.dweb.link/', ); }); + test('the default template is the path-style Filebase one', () { + expect( + IpfsGatewayHelper.buildUrl(IpfsGatewayHelper.defaultTemplate, cid), + 'https://ipfs.filebase.io/ipfs/$cid', + ); + }); + test('appends the cid for path-style templates', () { expect( IpfsGatewayHelper.buildUrl(IpfsGatewayHelper.filebaseTemplate, cid), @@ -55,14 +64,67 @@ void main() { }); }); - group('presetLabelFor', () { - test('names the two presets and nothing else', () { - expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.defaultTemplate), - 'dweb.link'); + // dweb.link is switched off for good on 2026-09-21, and `init` WRITES the + // default into storage on first run — so every existing user has the old + // default persisted and changing the constant alone would reach new installs + // only. This group covers the bit that actually moves people off it. + group('retirement migration', () { + test('the default is no longer dweb', () { + expect(IpfsGatewayHelper.defaultTemplate, + isNot(IpfsGatewayHelper.dwebTemplate)); + expect(IpfsGatewayHelper.defaultTemplate, + IpfsGatewayHelper.filebaseTemplate); + }); + + test('a stored dweb template is migrated to the default', () { expect( - IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.filebaseTemplate), + IpfsGatewayHelper.resolveStoredTemplate(IpfsGatewayHelper.dwebTemplate), + IpfsGatewayHelper.defaultTemplate, + ); + }); + + test('nothing else is disturbed', () { + for (final keep in [ + IpfsGatewayHelper.filebaseTemplate, + IpfsGatewayHelper.fxTemplate, + 'https://my-host/ipfs/', + ]) { + expect(IpfsGatewayHelper.resolveStoredTemplate(keep), keep); + } + }); + + test('absent or blank falls back to the default', () { + expect(IpfsGatewayHelper.resolveStoredTemplate(null), + IpfsGatewayHelper.defaultTemplate); + expect(IpfsGatewayHelper.resolveStoredTemplate(''), + IpfsGatewayHelper.defaultTemplate); + expect(IpfsGatewayHelper.resolveStoredTemplate(' '), + IpfsGatewayHelper.defaultTemplate); + }); + + test('is idempotent — re-running never churns the value', () { + final once = + IpfsGatewayHelper.resolveStoredTemplate(IpfsGatewayHelper.dwebTemplate); + expect(IpfsGatewayHelper.resolveStoredTemplate(once), once); + }); + + test('no retired template is offered as a preset', () { + for (final template in IpfsGatewayHelper.presets.values) { + expect(IpfsGatewayHelper.retiredTemplates, isNot(contains(template)), + reason: 'offering a gateway that init() migrates away is a trap'); + } + }); + }); + + group('presetLabelFor', () { + test('names the presets and nothing else', () { + expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.filebaseTemplate), 'Filebase'); + expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.fxTemplate), + 'fx.land'); expect(IpfsGatewayHelper.presetLabelFor('https://my-host/ipfs/'), isNull); + expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.dwebTemplate), + isNull); }); test('tolerates surrounding whitespace', () { @@ -82,10 +144,16 @@ void main() { group('frontDoorGatewayKey', () { test('maps the presets to the resolver keys', () { - expect(IpfsGatewayHelper.frontDoorGatewayKey( - IpfsGatewayHelper.defaultTemplate), 'dweb'); expect(IpfsGatewayHelper.frontDoorGatewayKey( IpfsGatewayHelper.filebaseTemplate), 'filebase'); + expect(IpfsGatewayHelper.frontDoorGatewayKey( + IpfsGatewayHelper.fxTemplate), 'fx'); + }); + + test('the retired dweb template has no key', () { + expect( + IpfsGatewayHelper.frontDoorGatewayKey(IpfsGatewayHelper.dwebTemplate), + isNull); }); test('a custom gateway has no key — the resolver allowlists, by design', @@ -102,7 +170,7 @@ void main() { // The worker's allowlist is the other half of this contract: a key here // that it does not know would silently fall back to its default. test('only ever emits keys the worker allowlists', () { - const workerKeys = {'dweb', 'filebase'}; + const workerKeys = {'filebase', 'fx'}; for (final template in IpfsGatewayHelper.presets.values) { expect(workerKeys, contains( IpfsGatewayHelper.frontDoorGatewayKey(template))); @@ -118,9 +186,9 @@ void main() { expect(IpfsGatewayHelper.decorateFrontDoorUrl(link), '$link?gw=filebase'); }); - test('appends the dweb key explicitly, so the link is self-describing', + test('appends the default key explicitly, so the link is self-describing', () { - expect(IpfsGatewayHelper.decorateFrontDoorUrl(link), '$link?gw=dweb'); + expect(IpfsGatewayHelper.decorateFrontDoorUrl(link), '$link?gw=filebase'); }); test('uses & when the link already carries a query', () {