Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions cloudflare/ipns-resolver-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<cid>/<path>`), 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
Expand All @@ -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}`,
},
};

/**
Expand Down Expand Up @@ -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}`);
}
}
}
Expand Down
Binary file modified cloudflare/ipns-resolver-worker.test.mjs
Binary file not shown.
47 changes: 35 additions & 12 deletions lib/core/services/ipfs_gateway_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<String> retiredTemplates = <String>{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<String, String> presets = <String, String>{
'Filebase': filebaseTemplate,
'inbrowser.link': inbrowserTemplate,
};

/// Preset label for [template], or null when it is a custom value.
Expand All @@ -74,6 +96,7 @@ class IpfsGatewayHelper {
static const Map<String, String> _frontDoorKeys = <String, String>{
filebaseTemplate: 'filebase',
fxTemplate: 'fx',
inbrowserTemplate: 'inbrowser',
};

static String? frontDoorGatewayKey([String? template]) =>
Expand Down
56 changes: 38 additions & 18 deletions test/unit/core/services/ipfs_gateway_helper_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 <String>[
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);
Expand All @@ -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);
});

Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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)));
Expand Down
Loading