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 5998d7a..0008585 100644 Binary files a/cloudflare/ipns-resolver-worker.test.mjs and b/cloudflare/ipns-resolver-worker.test.mjs differ 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)));