From 1e4f29bbdd8b7ed428e9f8a1afb0ae76d901bfba Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Sat, 12 Sep 2026 09:58:41 -0400 Subject: [PATCH] feat(gateway): offer inbrowser.link, dweb's successor, alongside Filebase dweb.link is being switched off on 2026-09-21 and ALREADY redirects to `.ipfs.inbrowser.link`, so that is where the traffic ends up either way. The picker now offers that successor directly rather than a host about to disappear. Filebase remains the default. WHAT inbrowser.link ACTUALLY IS (measured 2026-09-12, not assumed) It is a SERVICE-WORKER gateway, and it behaves unlike every other option: * BROWSER-ONLY. A request without a browser User-Agent is refused with 403 (the body points at the self-hosting guide); with one it returns 200. So a person opening the link sees the site, while social-preview crawlers, indexers and any programmatic fetch are turned away. A link shared here renders but shows no preview card. * What it returns is an ~11KB bootstrap, not the content. A service worker fetches the real bytes client-side. * It is SUBDOMAIN-style, so a published site's relative asset references cannot reach its assets -- they are on another host. The injected fallback chain recovers them from an absolute gateway, so the site is fine, but choosing this gateway moves the PAGE only, not its images. Verified live: the image loaded with data-fx-try="1", i.e. via the chain. Social previews are unaffected regardless of what a user picks, because og:image is pinned to the SERVICE default gateway rather than the user's template -- a decision made in pinning-service#94 that pays off here. CONSEQUENT CHANGES * `dweb` returns to `retiredTemplates`, so anyone still holding it is moved to the default at startup. That is safe precisely BECAUSE it is no longer offered: a template that is both offered and migrated away from would silently revert on the next launch. The two sets must never intersect, and a test pins that. * `dweb` is removed from the Worker allowlist, so links pinned to `?gw=dweb` fall back instead of pointing at a host being switched off. * SUBDOMAIN_SAFE_CID is restored. `inbrowser` is the first subdomain-style entry in the allowlist, and the CID lands in the HOSTNAME there, where a case-sensitive CIDv0 (`Qm...`) or a CID past the 63-character DNS label limit is silently mangled into a DIFFERENT cid. Such a CID is now served from the default path-style gateway rather than a URL that cannot work. Also removes a raw NUL byte from the Worker test file. It was written as a literal control character instead of the escape \0 and shipped in 5245531, which made git classify the file as BINARY -- its diffs were unreviewable in both merged PRs and no reviewer could have caught anything by reading them. The test's behaviour is unchanged: a raw NUL is exactly what the control-character guard must reject, so it was testing the right thing for the wrong reason. Tests: 39 worker (4 new for the subdomain hazard and the retired key), 30 gateway-helper, full Flutter suite, analyzer clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwMWmCivEpYTmmzzmjSTAf --- cloudflare/ipns-resolver-worker.js | 36 +++++++---- cloudflare/ipns-resolver-worker.test.mjs | Bin 10977 -> 11998 bytes lib/core/services/ipfs_gateway_helper.dart | 47 +++++++++++---- .../services/ipfs_gateway_helper_test.dart | 56 ++++++++++++------ 4 files changed, 98 insertions(+), 41 deletions(-) diff --git a/cloudflare/ipns-resolver-worker.js b/cloudflare/ipns-resolver-worker.js index 7368ffd..a3b040a 100644 --- a/cloudflare/ipns-resolver-worker.js +++ b/cloudflare/ipns-resolver-worker.js @@ -60,18 +60,16 @@ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; * open redirector, which is exactly the property the checks below exist to * protect. Unknown or missing `gw` falls back to the default. * - * `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. + * `dweb` is gone: the IPFS Foundation retires dweb.link on 2026-09-21, and it + * already redirects to its successor `inbrowser`, which is listed instead. * - * 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. + * Note `inbrowser` is SUBDOMAIN-style, which is why SUBDOMAIN_SAFE_CID exists + * again — a case-sensitive CIDv0 (`Qm…`) or a CID past the 63-character DNS + * label limit corrupts silently as a hostname. Such a CID falls back to the + * default rather than being served a mangled one. */ +const SUBDOMAIN_SAFE_CID = /^[a-z0-9]{1,63}$/; + const GATEWAYS = { filebase: { // Served the same CID fine at the moment dweb.link was 429ing it @@ -82,6 +80,14 @@ const GATEWAYS = { // Ours. Verified 2026-09-12 to serve these CIDs with correct content types. cid: (cid, path) => `https://ipfs.cloud.fx.land/gateway/${cid}${path}`, }, + inbrowser: { + // dweb.link's successor: a service-worker gateway, and BROWSER-ONLY — a + // request without a browser User-Agent is refused with 403 (measured + // 2026-09-12), so a link sent here renders for a person but gets no + // preview card from a crawler. + subdomain: true, + cid: (cid, path) => `https://${cid}.ipfs.inbrowser.link${path}`, + }, }; /** @@ -202,7 +208,15 @@ export default { // header split is not something to leave to a runtime check. if (cid && CID_RE.test(cid) && !CONTROL_CHARS.test(inner)) { const path = joinPath(inner, subpath); - return redirect(`${gateway.cid(cid, path)}${query}`); + // A subdomain gateway puts the CID in the HOSTNAME, where a + // case-sensitive CIDv0 or an over-long CID is silently mangled + // into a different (wrong) CID. Serve those from the default + // path-style gateway rather than a URL that cannot work. + const usable = + gateway.subdomain && !SUBDOMAIN_SAFE_CID.test(cid) + ? GATEWAYS[DEFAULT_GATEWAY] + : gateway; + return redirect(`${usable.cid(cid, path)}${query}`); } } } diff --git a/cloudflare/ipns-resolver-worker.test.mjs b/cloudflare/ipns-resolver-worker.test.mjs index 5998d7a42e0bb3d56992115ffff86de933f489eb..0008585ea9035e98d270e07c5a526ef082f5447a 100644 GIT binary patch delta 1061 zcmaiz-D(p-6vt7~)@amX3tB`DQb;3d`XvP|Qu;|#Fa;aEQ6aOtv%5nkGht@7ODrW` z_yEFqEBFY7zJM>_g)boJy*IrVJu^*1Ed?+3V&~(W|M~xa^W(+$HwUk-Zk06XEDWjE zQdNM62uw;r3X01?B-@0EDQ&u(0u@GefeC+ra4vJsw5b+CDYSvwicK$-UQX{4hcSmm;AG|6m{*TC`gMje96eLx%~0 z=ZyqrAjVU!T0p`UD3pwMO^UZWk_p2^0>l78wfdfM8^*N^sN2vvGhvFAWE|V~g-!eE zPSGBWJhUHk?Y$H?`A|}l5qn+-+jc=0a4T|18o@RkVtC33_-uz{cFu~ zxdI!0cKdl`t{2E8)29{(hBPqAl)NPhy*TA)LJZAG7k$Ekl#DV=|B2VJT3Z8-4Cs@X zH@9~h_2p;l73WBfR!|6urfy^|F?GjFkCT9MjT;?j&B8o{DN(u#J5=p@zo1P-!qFEr z%?Q$_zM9N<1e$T=igzKVXdTL2t8HwoKVPpm073E@VFK9E#d4epL)<8ds(p;e5}@qe z86diaEf5!^_uP4135?3+icvcnJ=ykKaoir=er*3tJUj#0(ded~xpA|19?vBVq14XM z1R)IqFc=a|mDvAe*3t=~$J8T8a+j*9h50FGlhgiRy_cO}iI3o}JsjIQqw)9H>|DPz tDp761y&toEcrs13&-ToV`Y^IGI$+VmbNrePo7aC8FTS2RtdHAUe*v3FZtVa7 delta 561 zcmY+A(MlXK7=KlPvchJPe`dpElQqe7To&;m z=0ys=kJtz3JNRc{1+OM2bMpP?KX(`Z&i_B#qpEd znignpTw@fh^D|)#Yi;wyE1Jkm5wRnuPD|Qh>Wol&H+wS63!dzg)%N#IAfF?+UZerK zkEgtw&pGzvdmiRmqeJ}2krcaKAeer`lK-I)vWB)Hl1QyUEwrt=lUF~w`{&!;{oD2R zv`@G8;_QQU@T9aS$&rPxC^B-QuO@3E9OE%MGC0dXrCfmzUR*;qaI>AObHRN3I?iC$YDOZT^n5m8MY?23g86 rN;_u59FwGANYq@*A1#n2R`vzEOMb=B#Js-!z4`z3+Oyl!_ub|L1)aP1 diff --git a/lib/core/services/ipfs_gateway_helper.dart b/lib/core/services/ipfs_gateway_helper.dart index 0bd0d1c..4cf8e75 100644 --- a/lib/core/services/ipfs_gateway_helper.dart +++ b/lib/core/services/ipfs_gateway_helper.dart @@ -12,13 +12,30 @@ import 'package:fula_files/core/services/secure_storage_service.dart'; class IpfsGatewayHelper { IpfsGatewayHelper._(); - /// 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. + /// Subdomain-style dweb.link template — the app-wide default until + /// 2026-09-12, now retired: the IPFS Foundation shuts it down on 2026-09-21 + /// (gatewaychanges.ipfs.io), and the HTTP 429s seen beforehand were its + /// escalating pauses rather than load. Kept only so [retiredTemplates] can + /// recognise and migrate anyone still holding it. static const String dwebTemplate = 'https://{cid}.ipfs.dweb.link/'; + /// dweb.link's successor: a SERVICE-WORKER gateway. dweb.link already + /// redirects here, so this is where that traffic ends up either way. + /// + /// Three things make it different from every other option, all measured + /// 2026-09-12: + /// * It is BROWSER-ONLY. A request without a browser User-Agent gets 403 + /// (pointing at the self-hosting guide). So social-preview crawlers, + /// indexers and any programmatic fetch are refused — a link shared here + /// renders for a human but shows no preview card. + /// * The page it returns is an ~11KB bootstrap, not the content; a service + /// worker fetches the real bytes client-side. + /// * It is SUBDOMAIN-style, so a site's relative asset references cannot + /// reach the assets (they live on another host). The published fallback + /// chain recovers them from an absolute gateway instead — the site is + /// fine, but the gateway choice moves only the page, not its images. + static const String inbrowserTemplate = 'https://{cid}.ipfs.inbrowser.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. @@ -41,17 +58,22 @@ class IpfsGatewayHelper { /// 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. + /// overriding what looks like a user's choice, because for most people such + /// a "choice" is just an old default. Match-and-replace is idempotent, so no + /// migration flag. + /// + /// dweb is here and NOT in [presets] — that pairing is the rule. A template + /// that is both offered and migrated away from would silently revert on the + /// next launch, which is why the two sets must never intersect (pinned by a + /// test). Its successor [inbrowserTemplate] is what the picker offers now. 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. dweb is deliberately ABSENT: offering - /// a gateway that [init] would migrate away from on next launch is a trap. + /// 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. Filebase is first because it is the default. static const Map presets = { 'Filebase': filebaseTemplate, + 'inbrowser.link': inbrowserTemplate, }; /// Preset label for [template], or null when it is a custom value. @@ -74,6 +96,7 @@ class IpfsGatewayHelper { static const Map _frontDoorKeys = { filebaseTemplate: 'filebase', fxTemplate: 'fx', + inbrowserTemplate: 'inbrowser', }; static String? frontDoorGatewayKey([String? template]) => diff --git a/test/unit/core/services/ipfs_gateway_helper_test.dart b/test/unit/core/services/ipfs_gateway_helper_test.dart index 11c5a4c..2cf94a5 100644 --- a/test/unit/core/services/ipfs_gateway_helper_test.dart +++ b/test/unit/core/services/ipfs_gateway_helper_test.dart @@ -64,35 +64,44 @@ void main() { }); }); - // 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. + // `init` WRITES the default into storage on first run, so every existing user + // has the then-current default persisted — changing the constant alone would + // reach new installs only. `retiredTemplates` is the mechanism that actually + // moves people off a gateway; this group pins its behaviour. group('retirement migration', () { - test('the default is no longer dweb', () { + test('the default is filebase, not dweb', () { expect(IpfsGatewayHelper.defaultTemplate, isNot(IpfsGatewayHelper.dwebTemplate)); expect(IpfsGatewayHelper.defaultTemplate, IpfsGatewayHelper.filebaseTemplate); }); - test('a stored dweb template is migrated to the default', () { - expect( - IpfsGatewayHelper.resolveStoredTemplate(IpfsGatewayHelper.dwebTemplate), - IpfsGatewayHelper.defaultTemplate, - ); - }); - - test('nothing else is disturbed', () { + test('a gateway that is still valid is never overwritten', () { for (final keep in [ IpfsGatewayHelper.filebaseTemplate, IpfsGatewayHelper.fxTemplate, + IpfsGatewayHelper.inbrowserTemplate, 'https://my-host/ipfs/', ]) { expect(IpfsGatewayHelper.resolveStoredTemplate(keep), keep); } }); + test('every retired template IS migrated to the default', () { + expect(IpfsGatewayHelper.retiredTemplates, isNotEmpty); + for (final retired in IpfsGatewayHelper.retiredTemplates) { + expect(IpfsGatewayHelper.resolveStoredTemplate(retired), + IpfsGatewayHelper.defaultTemplate); + } + }); + + test('dweb specifically is migrated — it is switched off 2026-09-21', () { + expect( + IpfsGatewayHelper.resolveStoredTemplate(IpfsGatewayHelper.dwebTemplate), + IpfsGatewayHelper.defaultTemplate, + ); + }); + test('absent or blank falls back to the default', () { expect(IpfsGatewayHelper.resolveStoredTemplate(null), IpfsGatewayHelper.defaultTemplate); @@ -104,7 +113,7 @@ void main() { test('is idempotent — re-running never churns the value', () { final once = - IpfsGatewayHelper.resolveStoredTemplate(IpfsGatewayHelper.dwebTemplate); + IpfsGatewayHelper.resolveStoredTemplate(IpfsGatewayHelper.filebaseTemplate); expect(IpfsGatewayHelper.resolveStoredTemplate(once), once); }); @@ -120,9 +129,11 @@ void main() { test('names the presets and nothing else', () { expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.filebaseTemplate), 'Filebase'); + expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.inbrowserTemplate), + 'inbrowser.link'); expect(IpfsGatewayHelper.presetLabelFor('https://my-host/ipfs/'), isNull); - expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.dwebTemplate), - isNull); + expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.fxTemplate), + isNull, reason: 'fx serves an interstitial before HTML'); }); // fx serves an interstitial before HTML, so it is a poor thing to put in @@ -158,7 +169,16 @@ void main() { IpfsGatewayHelper.fxTemplate), 'fx'); }); - test('the retired dweb template has no key', () { + // Choosing inbrowser has to produce `?gw=inbrowser` on a copied link, or + // the choice would be silently ignored the moment the link is shared. + test('inbrowser maps to its resolver key too', () { + expect( + IpfsGatewayHelper.frontDoorGatewayKey( + IpfsGatewayHelper.inbrowserTemplate), + 'inbrowser'); + }); + + test('the retired dweb has no key — links pinned to it fall back', () { expect( IpfsGatewayHelper.frontDoorGatewayKey(IpfsGatewayHelper.dwebTemplate), isNull); @@ -178,7 +198,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 = {'filebase', 'fx'}; + const workerKeys = {'filebase', 'fx', 'inbrowser'}; for (final template in IpfsGatewayHelper.presets.values) { expect(workerKeys, contains( IpfsGatewayHelper.frontDoorGatewayKey(template)));