From 225bc911c4af74a3d059fcc557edf658ce7ca861 Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Sat, 12 Sep 2026 03:01:14 -0400 Subject: [PATCH] feat(gateway): make the IPFS gateway setting actually govern websites and links dweb.link now returns HTTP 429 for generated sites once they see any real traffic (measured 2026-09-12: a fresh site 429'd on dweb.link and returned 200 from Filebase for the same CID at the same moment). The app already had a gateway setting, but choosing a different one changed almost nothing: * assets kept the URL recorded at import time, so a later change never reached them; * the stable fxfiles.top link always resolved to dweb; * the setting itself was a raw text field buried in More -> API Configuration, alongside endpoint overrides no ordinary user should edit. Rather than add a per-generation gateway picker, this makes the existing global setting do what it says. Settings Promote the gateway to its own top-level section under Billing, as a picker (dweb.link / Filebase / Custom) rather than a URL field, and REMOVE the raw field from More -> API Configuration. Two editors for one key would let a near-miss typed in the advanced screen silently demote a preset user to "custom" -- and with it drop the ?gw= that makes their links follow the choice. Assets Build asset URLs from the CID with the CURRENT template instead of reusing the URL captured at import time. This was the bug that made the setting look inert. The stable link The Worker takes an optional ?gw= selecting which gateway it redirects to, and the app appends it when a link is read -- never when the pointer is minted. A pointer is written once and lives for the life of the website, so baking the gateway in would freeze it at whatever was configured that day: exactly the staleness being fixed for assets. The one deliberate exception is the URL embedded in a social-media caption, which freezes because the caption is a permanent public artifact. ?gw= is a FIXED ALLOWLIST. A user's custom template governs their own asset URLs but is not honoured on the shared link -- accepting a caller-supplied destination there would turn a link anyone can share into an open redirector. The public directory listing is likewise left undecorated: the server rejects any URL carrying a query string, and one submitter's preference has no business steering a stranger's browser. Worker hardening found while testing (all now covered by tests) * ?gw=__proto__ / toString / constructor returned a TRUTHY inherited value from the object literal, whose .cid is undefined -- the throw dropped the request onto the IPNS fallback, which does not resolve. A typo broke the link instead of using the default. Now Object.hasOwn. * The CID from w3name is interpolated into the AUTHORITY of the subdomain shape, where a hostile answer containing @ or a backslash could re-point the host. Now charset-checked. * A CIDv0 (base58, case-sensitive) or an over-63-char CID cannot be a DNS label; both silently corrupt in a subdomain. They now use the path form. * An IPNS record may point INTO a directory; that suffix was being dropped. * A control character in the record value could split the Location header. Tests: 19 Dart cases for the helper, 33 Node cases for the Worker (none existed before -- it fronts every shared link the app has minted). Notes for review: the two use_build_context_synchronously infos reported in lib/features/websites/screens/website_detail_screen.dart are PRE-EXISTING and merely shifted five lines by the import added here. Known limitation, not addressed here: a site's HTML embeds ABSOLUTE asset URLs at generation time, so switching the gateway makes the page load from the new gateway while images inside an ALREADY-GENERATED site still point at the old one. Only a fresh generation re-embeds them. Making asset references relative is the real fix and is a separate change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwMWmCivEpYTmmzzmjSTAf --- cloudflare/README.md | 36 +++- cloudflare/ipns-resolver-worker.js | 132 ++++++++++++-- cloudflare/ipns-resolver-worker.test.mjs | Bin 0 -> 10110 bytes lib/core/services/ipfs_gateway_helper.dart | 61 +++++++ .../screens/website_detail_screen.dart | 6 +- lib/web/screens/web_api_config_screen.dart | 11 +- lib/web/screens/web_settings_screen.dart | 146 ++++++++++++++++ .../screens/web_website_detail_screen.dart | 17 +- lib/web/screens/web_websites_screen.dart | 13 +- lib/web/services/web_website_service.dart | 23 ++- .../services/ipfs_gateway_helper_test.dart | 165 ++++++++++++++++++ 11 files changed, 579 insertions(+), 31 deletions(-) create mode 100644 cloudflare/ipns-resolver-worker.test.mjs create mode 100644 test/unit/core/services/ipfs_gateway_helper_test.dart diff --git a/cloudflare/README.md b/cloudflare/README.md index c7f0958..b135f9d 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -9,6 +9,31 @@ GET https://fxfiles.top/w/ -> 302 https://.ipfs.dweb.l GET https://fxfiles.top/w//page.html -> 302 https://.ipfs.dweb.link/page.html ``` +## Choosing a gateway (`?gw=`) + +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/ +``` + +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. + +`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 +host would turn it into an open redirector. The app therefore sends `?gw=` only +for a *preset*; a user's custom gateway template governs their own asset URLs +but is not honoured here, and such links fall back to `DEFAULT_GATEWAY`. An +unknown or absent key falls back the same way rather than erroring, so a typo +still resolves. Adding a gateway means adding an entry to `GATEWAYS` here **and** +to `_frontDoorKeys` in `lib/core/services/ipfs_gateway_helper.dart` — a key on +one side that the other does not know is silently ignored. + ## Resilience — what actually depends on what (measured 2026-05-30) - **No secrets, no state, no app credential.** The app never calls Cloudflare; @@ -68,8 +93,9 @@ 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. -- Assumes CIDv1 (subdomain gateway). The app's default gateway template is the - same `https://{cid}.ipfs.dweb.link/`. To use a different gateway, change - `GATEWAY_HOST` in `ipns-resolver-worker.js`. -- The Worker rejects paths whose name isn't a plausible `k51…` IPNS name, so it - can't be abused as an open redirector. +- 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. +- 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. diff --git a/cloudflare/ipns-resolver-worker.js b/cloudflare/ipns-resolver-worker.js index e1c48cb..add0e89 100644 --- a/cloudflare/ipns-resolver-worker.js +++ b/cloudflare/ipns-resolver-worker.js @@ -2,9 +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/[/] + * GET https://fxfiles.top/w/[/][?gw=dweb|filebase] * -> resolve to its current CID via w3name's plain HTTP API - * -> 302 to https://.ipfs.dweb.link/ + * -> 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) * * Why this design: * - The app never talks to Cloudflare and holds NO credential here. The IPNS @@ -23,8 +25,12 @@ * makes any gateway work. * * Abuse posture (it MUST stay publicly reachable so links + previews work): - * - Not an open redirector: only ever redirects to a derived dweb.link URL, - * and rejects anything that isn't a plausible `k51…` IPNS name. + * - Not an open redirector: the destination host comes from a FIXED allowlist + * (see GATEWAYS) keyed by `?gw=`, never from caller-supplied text, and the + * path is a CID this Worker resolved itself. A user's custom gateway + * template is honoured for their own asset URLs in the app, but is + * deliberately NOT accepted here. Anything that isn't a plausible `k51…` + * IPNS name is rejected outright. * - GET/HEAD only; implausible/oversized names get a cheap 400 before any * upstream call. * - Optional per-IP rate limit via the built-in Workers rate-limiting binding @@ -40,6 +46,72 @@ 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 +/** CIDv1 base32 (`bafy…`) and CIDv0 base58 (`Qm…`) are both alphanumeric. */ +const CID_RE = /^[A-Za-z0-9]{40,120}$/; +/** CR, LF and friends — anything that could split a header value. */ +// 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=`. + * + * A FIXED ALLOWLIST, deliberately — the app lets a user set any IPFS gateway + * template they like for their own asset URLs, but that value must never reach + * here. Honouring arbitrary input would turn a link anyone can share into an + * 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// + */ +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. + cid: (cid, path) => `https://ipfs.filebase.io/ipfs/${cid}${path}`, + }, +}; + +/** + * Join the path an IPNS record points INTO with the one the visitor asked for. + * + * A record's value is usually a bare `/ipfs/`, which is all this app ever + * publishes — but the format allows `/ipfs//site/index.html`, and dropping + * that suffix would silently serve the wrong page for any name that uses one. + */ +function joinPath(inner, subpath) { + if (!inner) return subpath; + // A bare `/` from the visitor means "whatever the record points at", so hand + // back the record's own path untouched — appending a slash would ask the + // gateway for `/site/index.html/`, which is not the same resource as the + // file. A directory needs no slash either; gateways redirect to add it. + return subpath === '/' ? inner : `${inner}${subpath}`; +} + +/** Default when `?gw=` is absent — keeps every already-shared link working. */ +const DEFAULT_GATEWAY = 'dweb'; export default { async fetch(request, env) { @@ -87,8 +159,34 @@ export default { } const subpath = match[2] || '/'; + + // `gw` is ours, not the gateway's — strip it before forwarding so the + // upstream never sees a stray query param it does not understand. + const forwarded = new URLSearchParams(url.search); + const gwKey = forwarded.get('gw'); + 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. + // + // 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. + 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}${url.search}`; + `https://${name}.${IPNS_GATEWAY_HOST}${subpath}${query}`; try { const res = await fetch(`${W3NAME_ENDPOINT}/name/${name}`, { @@ -98,11 +196,25 @@ export default { const data = await res.json(); const value = data && data.value; // e.g. "/ipfs/" if (typeof value === 'string' && value.startsWith('/ipfs/')) { - const cid = value.slice('/ipfs/'.length).split('/')[0]; - if (cid) { - return redirect( - `https://${cid}.${GATEWAY_HOST}${subpath}${url.search}`, - ); + const rest = value.slice('/ipfs/'.length); + const slash = rest.indexOf('/'); + const cid = slash === -1 ? rest : rest.slice(0, slash); + const inner = slash === -1 ? '' : rest.slice(slash); + // Charset-check the CID before it is interpolated. For the + // subdomain shape it lands in the AUTHORITY (`https://.ipfs…`), + // where a `@`, a backslash or a dot would re-point the host — so a + // hostile or compromised w3name answer must not be able to put one + // there. Real CIDs are base32 (`bafy…`) or base58 (`Qm…`): both are + // alphanumeric, so this rejects nothing legitimate. + // `inner` is raw text from the record and ends up inside a header + // value. The CID above already terminates the authority, so it + // cannot move the host — but a control character could split the + // Location header. The Workers `Headers` class would throw on that + // (caught below, so the link would break rather than leak), and a + // 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}`); } } } diff --git a/cloudflare/ipns-resolver-worker.test.mjs b/cloudflare/ipns-resolver-worker.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..2dfbcddb2c829eecf02ac0a82019104b98f3b56b GIT binary patch literal 10110 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 Q{el)>qFR>?Ni327587hc&j0`b literal 0 HcmV?d00001 diff --git a/lib/core/services/ipfs_gateway_helper.dart b/lib/core/services/ipfs_gateway_helper.dart index 4e0f106..f3cf7d4 100644 --- a/lib/core/services/ipfs_gateway_helper.dart +++ b/lib/core/services/ipfs_gateway_helper.dart @@ -19,6 +19,67 @@ class IpfsGatewayHelper { /// [defaultTemplate] on the next [init]. static const String legacyDefault = '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/'; + + /// 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. + static const Map presets = { + 'dweb.link': defaultTemplate, + 'Filebase': filebaseTemplate, + }; + + /// Preset label for [template], or null when it is a custom value. + static String? presetLabelFor(String template) { + final t = template.trim(); + for (final entry in presets.entries) { + if (entry.value == t) return entry.key; + } + return null; + } + + /// `?gw=` key the fxfiles.top resolver understands for the active template, + /// or null when the link should just use the resolver's default. + /// + /// The resolver only accepts a fixed allowlist — it is a link anyone can + /// share, so honouring an arbitrary template there would make it an open + /// redirector. A CUSTOM gateway therefore returns null: it still governs the + /// 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', + }; + + static String? frontDoorGatewayKey([String? template]) => + _frontDoorKeys[(template ?? _cachedTemplate).trim()]; + + /// Decorate a stored `https://fxfiles.top/w/` link with the active + /// gateway. + /// + /// Applied when the link is READ, never baked in when it is minted: the + /// pointer is written once and lives for the life of the website, so baking + /// it would freeze the gateway at whatever was configured that day — the + /// 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). + static String decorateFrontDoorUrl(String frontDoorUrl, {String? template}) { + final key = frontDoorGatewayKey(template); + if (key == null || frontDoorUrl.isEmpty) return frontDoorUrl; + final sep = frontDoorUrl.contains('?') ? '&' : '?'; + return '$frontDoorUrl${sep}gw=$key'; + } + static String _cachedTemplate = defaultTemplate; /// Synchronous read of the active template. Populated by [init] at app diff --git a/lib/features/websites/screens/website_detail_screen.dart b/lib/features/websites/screens/website_detail_screen.dart index 7f8baa2..ed03f59 100644 --- a/lib/features/websites/screens/website_detail_screen.dart +++ b/lib/features/websites/screens/website_detail_screen.dart @@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:fula_files/core/models/contact_form_config.dart'; import 'package:fula_files/core/models/file_tag.dart'; import 'package:fula_files/core/models/website_generation.dart'; +import 'package:fula_files/core/services/ipfs_gateway_helper.dart'; import 'package:fula_files/core/services/ipns_pointer_service.dart'; import 'package:fula_files/core/services/website_prompt_builder.dart'; import 'package:fula_files/core/services/website_service.dart'; @@ -171,7 +172,10 @@ class _WebsiteDetailScreenState extends ConsumerState { ); } - final link = pointer.frontDoorUrl; + // Decorated on READ so the shown/copied link lands on the gateway the + // user has selected in Settings today, not the one selected on the day + // the pointer was minted. + final link = IpfsGatewayHelper.decorateFrontDoorUrl(pointer.frontDoorUrl); return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Container( diff --git a/lib/web/screens/web_api_config_screen.dart b/lib/web/screens/web_api_config_screen.dart index 24683a2..bc55b44 100644 --- a/lib/web/screens/web_api_config_screen.dart +++ b/lib/web/screens/web_api_config_screen.dart @@ -6,7 +6,6 @@ import 'package:go_router/go_router.dart'; import 'package:fula_files/core/services/auth_core.dart'; import 'package:fula_files/core/services/fula_api_service.dart'; -import 'package:fula_files/core/services/ipfs_gateway_helper.dart'; import 'package:fula_files/core/services/secure_storage_service.dart'; /// One editable API-configuration field: a SecureStorage key + its @@ -47,8 +46,12 @@ class _WebApiConfigScreenState extends State { 'https://cloud.fx.land'), _ConfigField('AI endpoint URL', SecureStorageKeys.aiEndpointUrl, 'https://ai.cloud.fx.land', 'https://ai.cloud.fx.land'), - _ConfigField('IPFS gateway template', SecureStorageKeys.ipfsGatewayUrl, - IpfsGatewayHelper.defaultTemplate, 'https://{cid}.ipfs.dweb.link/'), + // 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 + // 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". _ConfigField('IPFS upload endpoint URL', SecureStorageKeys.ipfsEndpointUrl, 'https://ipfs.cloud.fx.land', 'https://ipfs.cloud.fx.land'), _ConfigField('EVM RPC URL (cold-start)', SecureStorageKeys.baseRpcUrl, @@ -100,8 +103,6 @@ class _WebApiConfigScreenState extends State { if (kekB64 == null || kekB64.isEmpty) return false; final kek = Uint8List.fromList(base64Decode(kekB64)); final init = await AuthCore.initializeFulaFromStorage(kek: kek); - // Refresh the IPFS-gateway template cache so reads use the new value. - await IpfsGatewayHelper.init(); return init.configured; } diff --git a/lib/web/screens/web_settings_screen.dart b/lib/web/screens/web_settings_screen.dart index 6d20fc8..f6058ac 100644 --- a/lib/web/screens/web_settings_screen.dart +++ b/lib/web/screens/web_settings_screen.dart @@ -10,6 +10,7 @@ import 'package:fula_files/core/models/billing/storage_info.dart'; import 'package:fula_files/core/services/auth_core.dart'; import 'package:fula_files/core/services/billing_api_service.dart'; import 'package:fula_files/core/services/fula_api_service.dart'; +import 'package:fula_files/core/services/ipfs_gateway_helper.dart'; import 'package:fula_files/core/services/nft_wallet_service.dart'; import 'package:fula_files/core/services/secure_storage_service.dart'; import 'package:fula_files/core/services/share_link_builder.dart'; @@ -29,6 +30,9 @@ const String kWebAppVersion = 'v1.11.16.0'; /// their billing/storage — sit at the top, and everything else lives behind a /// collapsed "More" tile so the page reads as a short list rather than a wall /// of sections. +/// Dropdown sentinel for "not one of the presets". +const String _kCustomGateway = '__custom__'; + class WebSettingsScreen extends StatefulWidget { const WebSettingsScreen({super.key}); @@ -51,6 +55,21 @@ class _WebSettingsScreenState extends State { bool _revealKey = false; + /// The IPFS gateway template currently in effect. Seeded from the cache + /// (populated at startup by [IpfsGatewayHelper.init]) so the row renders + /// the right value on first paint without awaiting storage. + String _gatewayTemplate = IpfsGatewayHelper.cachedTemplate; + bool _gatewayCustomMode = false; + bool _savingGateway = false; + final TextEditingController _customGatewayController = + TextEditingController(); + + @override + void dispose() { + _customGatewayController.dispose(); + super.dispose(); + } + Future _resolveShareId() async { final pk = await FulaApiService.instance.getPublicKey(); return encodeFulaShareId(pk); @@ -121,6 +140,8 @@ class _WebSettingsScreenState extends State { const Divider(height: 1), _billingSection(context), const Divider(height: 1), + _ipfsGatewaySection(context), + const Divider(height: 1), _moreSection(context), const SizedBox(height: 24), ], @@ -240,6 +261,131 @@ class _WebSettingsScreenState extends State { : credits.toStringAsFixed(2); } + // ── IPFS gateway ────────────────────────────────────────────────────────── + // 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. + // + // 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 + // typed there can't silently demote a preset user to "custom" (and with it, + // lose the `?gw=` that makes their shared links follow this choice). + Widget _ipfsGatewaySection(BuildContext context) { + final preset = IpfsGatewayHelper.presetLabelFor(_gatewayTemplate); + // Custom mode is explicit state, not inferred from the string: picking + // "Custom…" while the saved value happens to be a preset must still open + // the editor. + final showCustom = _gatewayCustomMode || preset == null; + return _Section( + label: 'IPFS GATEWAY', + children: [ + ListTile( + leading: const Icon(Icons.hub_outlined), + title: const Text('Gateway for images & links'), + subtitle: Text( + preset != null + ? '$preset — serves the images in your generated websites' + : 'Custom — $_gatewayTemplate', + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: DropdownButtonFormField( + initialValue: showCustom ? _kCustomGateway : preset, + decoration: const InputDecoration( + labelText: 'Gateway', + border: OutlineInputBorder(), + isDense: true, + ), + items: [ + for (final name in IpfsGatewayHelper.presets.keys) + DropdownMenuItem(value: name, child: Text(name)), + const DropdownMenuItem( + value: _kCustomGateway, child: Text('Custom…')), + ], + onChanged: _savingGateway ? null : _onGatewayPicked, + ), + ), + if (showCustom) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: TextField( + controller: _customGatewayController, + decoration: const InputDecoration( + labelText: 'Custom gateway template', + helperText: + 'https://{cid}.ipfs.example.com/ or https://example.com/ipfs/', + helperMaxLines: 2, + border: OutlineInputBorder(), + isDense: true, + ), + onSubmitted: _applyGateway, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: _savingGateway + ? null + : () => _applyGateway(_customGatewayController.text), + child: const Text('Save gateway'), + ), + ), + ), + ], + ], + ); + } + + void _onGatewayPicked(String? choice) { + if (choice == null) return; + if (choice == _kCustomGateway) { + // Seed the editor with what is already in effect rather than an empty + // box, and leave the saved value untouched until the user hits Save. + _customGatewayController.text = _gatewayTemplate; + setState(() => _gatewayCustomMode = true); + return; + } + setState(() => _gatewayCustomMode = false); + _applyGateway(IpfsGatewayHelper.presets[choice]!); + } + + Future _applyGateway(String template) async { + final value = template.trim(); + if (value.isEmpty) return; + setState(() => _savingGateway = true); + try { + await SecureStorageService.instance + .write(SecureStorageKeys.ipfsGatewayUrl, value); + IpfsGatewayHelper.updateCache(value); + if (!mounted) return; + setState(() { + _gatewayTemplate = value; + // A saved custom value that matches a preset should collapse the + // editor and show as that preset. + if (IpfsGatewayHelper.presetLabelFor(value) != null) { + _gatewayCustomMode = false; + } + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gateway set to ' + '${IpfsGatewayHelper.presetLabelFor(value) ?? value}')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not save gateway: $e')), + ); + } finally { + if (mounted) setState(() => _savingGateway = false); + } + } + // More -------------------------------------------------------------------- // Everything that isn't profile or billing, collapsed. Each child is one of // the original _Section widgets, unchanged -- expanding restores exactly the diff --git a/lib/web/screens/web_website_detail_screen.dart b/lib/web/screens/web_website_detail_screen.dart index 2fa2d35..433dead 100644 --- a/lib/web/screens/web_website_detail_screen.dart +++ b/lib/web/screens/web_website_detail_screen.dart @@ -12,6 +12,7 @@ import 'package:fula_files/core/models/file_tag.dart'; import 'package:fula_files/core/models/social_post_record.dart'; import 'package:fula_files/core/models/website_generation.dart'; import 'package:fula_files/core/models/website_group_pointer.dart'; +import 'package:fula_files/core/services/ipfs_gateway_helper.dart'; import 'package:fula_files/core/services/website_prompt_builder.dart'; import 'package:fula_files/shared/widgets/ipfs_public_disclaimer_dialog.dart'; import 'package:fula_files/shared/widgets/step_row.dart'; @@ -514,9 +515,18 @@ class _WebWebsiteDetailScreenState extends State { final pointer = _pointer ?? WebIpnsService.instance.pointerFor(widget.tagId); try { + final frontDoor = pointer?.frontDoorUrl; await WebSocialPostService.instance.startGeneration( generation: gen, - frontDoorUrl: pointer?.frontDoorUrl, + // A caption is a permanent public artifact, so this one DOES + // freeze the gateway at post time — deliberately. The point of + // choosing a gateway is that links reach it; a post carrying the + // default anyway would defeat that. A stale key still resolves: + // the resolver falls back rather than erroring on one it does not + // know. + frontDoorUrl: frontDoor == null + ? null + : IpfsGatewayHelper.decorateFrontDoorUrl(frontDoor), ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( @@ -744,7 +754,10 @@ class _WebWebsiteDetailScreenState extends State { ); } - final link = pointer.frontDoorUrl; + // Decorated on READ so the shown/copied link lands on the gateway the + // user has selected in Settings today, not the one selected on the day + // the pointer was minted. + final link = IpfsGatewayHelper.decorateFrontDoorUrl(pointer.frontDoorUrl); return Container( margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(14), diff --git a/lib/web/screens/web_websites_screen.dart b/lib/web/screens/web_websites_screen.dart index a62b3e4..d48939d 100644 --- a/lib/web/screens/web_websites_screen.dart +++ b/lib/web/screens/web_websites_screen.dart @@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:fula_files/app/theme/app_colors.dart'; import 'package:fula_files/core/models/website_generation.dart'; import 'package:fula_files/core/models/website_group_pointer.dart'; +import 'package:fula_files/core/services/ipfs_gateway_helper.dart'; import 'package:fula_files/web/services/web_features.dart'; import 'package:fula_files/web/services/web_tag_service.dart'; import 'package:fula_files/web/services/web_website_service.dart'; @@ -165,13 +166,17 @@ class _WebWebsitesScreenState extends State { return null; } - /// Stable front door first; otherwise the latest generation's - /// dweb-gateway URL (g.gatewayUrl re-derives from the CID — never the - /// raw legacy resultGatewayUrl). + /// Stable front door first; otherwise the latest generation's gateway + /// URL (g.gatewayUrl re-derives from the CID — never the raw legacy + /// resultGatewayUrl). + /// + /// Both branches follow the gateway chosen in Settings: the fallback + /// because it rebuilds from the CID, the front door because the + /// resolver is told which gateway to land on. String? _liveUrl(String tagId) { final p = _pointers[tagId]; if (p != null && p.published && p.frontDoorUrl.isNotEmpty) { - return p.frontDoorUrl; + return IpfsGatewayHelper.decorateFrontDoorUrl(p.frontDoorUrl); } return _latestFor(tagId)?.gatewayUrl; } diff --git a/lib/web/services/web_website_service.dart b/lib/web/services/web_website_service.dart index a72cac4..7a5465b 100644 --- a/lib/web/services/web_website_service.dart +++ b/lib/web/services/web_website_service.dart @@ -472,6 +472,14 @@ class WebWebsiteService extends ChangeNotifier { /// The SERVER cannot work it out — the pointer lives in this user's /// encrypted manifest and is published to w3name from the browser — /// so the client has to hand it over. + /// + /// Deliberately NOT run through [IpfsGatewayHelper.decorateFrontDoorUrl], + /// unlike the links this app shows its own user. Two reasons, either one + /// sufficient: the server's `isAllowedListingUrl` rejects any URL with a + /// query string outright (it is how the directory keeps a submitted link + /// from being dressed up as something else), and the directory is read by + /// everyone — one submitter's gateway preference has no business deciding + /// which gateway a stranger's browser is sent to. String? _frontDoorUrlFor(String tagId) { final url = WebIpnsService.instance.pointersByTag[tagId]?.frontDoorUrl; return (url != null && url.isNotEmpty) ? url : null; @@ -937,10 +945,17 @@ class WebWebsiteService extends ChangeNotifier { } asset.cid = cid; - final recordedUrl = picked[i].gatewayUrl; - asset.gatewayUrl = (recordedUrl != null && recordedUrl.isNotEmpty) - ? recordedUrl - : IpfsGatewayHelper.buildUrlForCid(cid); + // Build from the CID with the CURRENT gateway template rather than + // reusing the URL recorded at import time. + // + // The recorded URL is stamped by WebWebsiteAssetUploader the moment the + // file is uploaded, so preferring it meant the IPFS-gateway setting only + // reached assets imported AFTER a change — an asset imported last week + // kept pointing at last week's gateway forever, and the setting looked + // like it did nothing. The CID is the stable identity; the gateway is a + // rendering choice, so it is applied here, at the moment the URL is + // baked into the generated site. + asset.gatewayUrl = IpfsGatewayHelper.buildUrlForCid(cid); asset.uploaded = true; uploadedCount++; generation.uploadedAssets = uploadedCount; diff --git a/test/unit/core/services/ipfs_gateway_helper_test.dart b/test/unit/core/services/ipfs_gateway_helper_test.dart new file mode 100644 index 0000000..74df665 --- /dev/null +++ b/test/unit/core/services/ipfs_gateway_helper_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fula_files/core/services/ipfs_gateway_helper.dart'; + +/// Covers everything in [IpfsGatewayHelper] that does NOT touch storage. +/// `init()` is the only member that reads SecureStorage; the template cache +/// is driven here through `updateCache`, exactly as the settings screen does +/// after a save. +void main() { + // Each group leaves the cache on the default so ordering cannot matter. + tearDown(() => IpfsGatewayHelper.updateCache(IpfsGatewayHelper.defaultTemplate)); + + group('buildUrl', () { + const cid = 'bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m'; + + test('substitutes {cid} for subdomain-style templates', () { + expect( + IpfsGatewayHelper.buildUrl(IpfsGatewayHelper.defaultTemplate, cid), + 'https://$cid.ipfs.dweb.link/', + ); + }); + + test('appends the cid for path-style templates', () { + expect( + IpfsGatewayHelper.buildUrl(IpfsGatewayHelper.filebaseTemplate, cid), + 'https://ipfs.filebase.io/ipfs/$cid', + ); + }); + + test('adds the missing separator on a path template without one', () { + expect( + IpfsGatewayHelper.buildUrl('https://my-host/ipfs', cid), + 'https://my-host/ipfs/$cid', + ); + }); + + test('buildUrlForCid follows the cached template', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.filebaseTemplate); + expect( + IpfsGatewayHelper.buildUrlForCid(cid), + 'https://ipfs.filebase.io/ipfs/$cid', + ); + }); + }); + + group('updateCache', () { + test('trims, and falls back to the default on empty', () { + IpfsGatewayHelper.updateCache(' ${IpfsGatewayHelper.filebaseTemplate} '); + expect(IpfsGatewayHelper.cachedTemplate, + IpfsGatewayHelper.filebaseTemplate); + + IpfsGatewayHelper.updateCache(' '); + expect(IpfsGatewayHelper.cachedTemplate, + IpfsGatewayHelper.defaultTemplate); + }); + }); + + group('presetLabelFor', () { + test('names the two presets and nothing else', () { + expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.defaultTemplate), + 'dweb.link'); + expect( + IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.filebaseTemplate), + 'Filebase'); + expect(IpfsGatewayHelper.presetLabelFor('https://my-host/ipfs/'), isNull); + }); + + test('tolerates surrounding whitespace', () { + expect( + IpfsGatewayHelper.presetLabelFor( + ' ${IpfsGatewayHelper.filebaseTemplate} '), + 'Filebase', + ); + }); + + test('every preset value round-trips back to its own label', () { + IpfsGatewayHelper.presets.forEach((label, template) { + expect(IpfsGatewayHelper.presetLabelFor(template), label); + }); + }); + }); + + group('frontDoorGatewayKey', () { + test('maps the presets to the resolver keys', () { + expect(IpfsGatewayHelper.frontDoorGatewayKey( + IpfsGatewayHelper.defaultTemplate), 'dweb'); + expect(IpfsGatewayHelper.frontDoorGatewayKey( + IpfsGatewayHelper.filebaseTemplate), 'filebase'); + }); + + test('a custom gateway has no key — the resolver allowlists, by design', + () { + expect(IpfsGatewayHelper.frontDoorGatewayKey('https://my-host/ipfs/'), + isNull); + }); + + test('reads the cache when no template is passed', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.filebaseTemplate); + expect(IpfsGatewayHelper.frontDoorGatewayKey(), 'filebase'); + }); + + // 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'}; + for (final template in IpfsGatewayHelper.presets.values) { + expect(workerKeys, contains( + IpfsGatewayHelper.frontDoorGatewayKey(template))); + } + }); + }); + + group('decorateFrontDoorUrl', () { + const link = 'https://fxfiles.top/w/k51qzi5uqu5dlvj2baxnqndepeb86cbk3ng7n3i'; + + test('appends ?gw= for a preset gateway', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.filebaseTemplate); + expect(IpfsGatewayHelper.decorateFrontDoorUrl(link), '$link?gw=filebase'); + }); + + test('appends the dweb key explicitly, so the link is self-describing', + () { + expect(IpfsGatewayHelper.decorateFrontDoorUrl(link), '$link?gw=dweb'); + }); + + test('uses & when the link already carries a query', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.filebaseTemplate); + expect( + IpfsGatewayHelper.decorateFrontDoorUrl('$link?utm=x'), + '$link?utm=x&gw=filebase', + ); + }); + + test('leaves the link untouched for a custom gateway', () { + IpfsGatewayHelper.updateCache('https://my-host/ipfs/'); + expect(IpfsGatewayHelper.decorateFrontDoorUrl(link), link); + }); + + test('leaves an empty link empty rather than emitting a bare query', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.filebaseTemplate); + expect(IpfsGatewayHelper.decorateFrontDoorUrl(''), ''); + }); + + test('honours an explicit template over the cache', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.defaultTemplate); + expect( + IpfsGatewayHelper.decorateFrontDoorUrl(link, + template: IpfsGatewayHelper.filebaseTemplate), + '$link?gw=filebase', + ); + }); + + test('is not applied twice by accident', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.filebaseTemplate); + final once = IpfsGatewayHelper.decorateFrontDoorUrl(link); + // Decorating an already-decorated link appends a second key. The + // resolver reads the FIRST `gw`, so this stays correct — but it is + // ugly, and a sign a caller decorated a value that was already + // decorated. Pinned so the shape is a deliberate choice, not a + // surprise. + expect(IpfsGatewayHelper.decorateFrontDoorUrl(once), + '$link?gw=filebase&gw=filebase'); + }); + }); +}