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 0000000..2dfbcdd Binary files /dev/null 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 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'); + }); + }); +}