From 4931e6ee3ebc0a224d2cff9ad2128205728ba5af Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Wed, 16 Sep 2026 11:37:03 -0400 Subject: [PATCH] fix(website): site links always end in a slash so images load on Filebase Published sites reference their assets relatively (../). That resolves correctly only from the slashed page URL: https://ipfs.filebase.io/ipfs/ ../ -> / 404 https://ipfs.filebase.io/ipfs// ../ -> /ipfs/ 200 WebsiteGeneration.gatewayUrl -- behind every Open, Copy URL, list fallback, social caption, contact-form check and the native status card -- built the unslashed form for path-style templates. With dweb (subdomain, root already slashed) this never showed; making Filebase the default silently changed the link shape. The published fallback that should rescue an unslashed page is an inline script, and Filebase blocks inline scripts: it sends Content-Security-Policy: default-src 'self'; img-src * ...; style-src * ... with no script-src. Verified in a real browser on 2026-09-16 -- an inline script inserted into a Filebase page did not run and raised a script-src-elem violation; the reported site's image was complete with naturalWidth 0 and data-fx-try never set. So on the default gateway the slash is the only thing that makes a site's images load. Same site with the slash: image 1920px wide, served from Filebase, no script involved. The slash is applied in this getter only. publicGatewayUrlForCid also builds public FILE-share links, and asset URLs are files; both stay bare. A test pins that distinction. Subdomain templates already end in '/', so this is a no-op there. This does not reach links already copied without a slash, nor server-emitted URLs (the public directory falls back to a stored unslashed gateway_url); the server side is fixed separately. Tests: 8 new; 1089 Flutter passing; web release build; analyzer clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwMWmCivEpYTmmzzmjSTAf --- lib/core/models/website_generation.dart | 18 ++++- .../website_generation_gateway_url_test.dart | 79 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 test/unit/core/models/website_generation_gateway_url_test.dart diff --git a/lib/core/models/website_generation.dart b/lib/core/models/website_generation.dart index 932b911..eff7358 100644 --- a/lib/core/models/website_generation.dart +++ b/lib/core/models/website_generation.dart @@ -204,12 +204,28 @@ class WebsiteGeneration extends HiveObject { /// Public URL for the completed website built via [publicGatewayUrlForCid]. /// Prefers `resultCid`; falls back to extracting the CID from a legacy /// `resultGatewayUrl`. + /// + /// ALWAYS ends in `/`. A published site references its assets relatively + /// (`../`), and that resolves correctly only from the slashed form: + /// from `https://host/ipfs/` it lands on `/` and 404s, while + /// from `https://host/ipfs//` it lands on `/ipfs/`. The + /// published fallback that would otherwise rescue the image is an inline + /// script, and Filebase — the default gateway — blocks inline scripts with + /// `Content-Security-Policy: default-src 'self'` (measured 2026-09-16). So on + /// Filebase the slash is the ONLY thing that makes a site's images load. + /// + /// Every consumer of this getter is a link to the site PAGE (Open, Copy + /// URL, list fallback, social caption, contact-form check, native card), + /// which is why the slash lives here and not in [publicGatewayUrlForCid]: + /// that one also builds public FILE-share links, which must stay bare. + /// Subdomain-style templates already end in `/`, so this is a no-op there. String? get gatewayUrl { final cid = (resultCid != null && resultCid!.isNotEmpty) ? resultCid : _extractCidFromUrl(resultGatewayUrl); if (cid == null || cid.isEmpty) return null; - return publicGatewayUrlForCid(cid); + final url = publicGatewayUrlForCid(cid); + return url.endsWith('/') ? url : '$url/'; } /// Extract the trailing CID from a gateway-style URL such as diff --git a/test/unit/core/models/website_generation_gateway_url_test.dart b/test/unit/core/models/website_generation_gateway_url_test.dart new file mode 100644 index 0000000..f078b1f --- /dev/null +++ b/test/unit/core/models/website_generation_gateway_url_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fula_files/core/models/website_generation.dart'; +import 'package:fula_files/core/services/ipfs_gateway_helper.dart'; + +/// A published site references its assets relatively (`../`), which only +/// resolves from the SLASHED page URL. On Filebase (the default gateway) the +/// inline fallback that would rescue an unslashed page is blocked by CSP, so a +/// missing slash is a broken image. Every link to a site page goes through +/// [WebsiteGeneration.gatewayUrl]; these pin that it always carries the slash. +void main() { + const cid = 'bafkr4icktd4n2vmazsp7zv5qx5z5gcumnqr5il6yo7fjxubwtp2nizrikq'; + + WebsiteGeneration gen({String? resultCid, String? resultGatewayUrl}) => + WebsiteGeneration( + id: 'g1', + tagId: 't1', + tagName: 'Site', + prompt: 'p', + status: WebsiteGenStatus.completed, + resultCid: resultCid, + resultGatewayUrl: resultGatewayUrl, + createdAt: DateTime(2026, 9, 16), + updatedAt: DateTime(2026, 9, 16), + ); + + tearDown(() => + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.defaultTemplate)); + + test('path-style gateway (Filebase, the default) gets a trailing slash', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.filebaseTemplate); + expect(gen(resultCid: cid).gatewayUrl, + 'https://ipfs.filebase.io/ipfs/$cid/'); + }); + + test('the relative asset ref resolves onto the gateway from that URL', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.filebaseTemplate); + final page = Uri.parse(gen(resultCid: cid).gatewayUrl!); + const asset = 'bafkr4ia4svyucgp4yjjcnku2qc6nvhlur6tghwsdk3o5cx5ggoa2s6hzaa'; + expect(page.resolve('../$asset').toString(), + 'https://ipfs.filebase.io/ipfs/$asset'); + }); + + test('subdomain-style gateway is unchanged — it already ends in a slash', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.inbrowserTemplate); + expect(gen(resultCid: cid).gatewayUrl, + 'https://$cid.ipfs.inbrowser.link/'); + }); + + test('a custom path template without a trailing slash still gets one', () { + IpfsGatewayHelper.updateCache('https://my-host/ipfs'); + expect(gen(resultCid: cid).gatewayUrl, 'https://my-host/ipfs/$cid/'); + }); + + test('never doubles the slash', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.filebaseTemplate); + expect(gen(resultCid: cid).gatewayUrl!.endsWith('//'), isFalse); + }); + + test('a legacy slashed resultGatewayUrl still yields the right CID', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.filebaseTemplate); + expect( + gen(resultGatewayUrl: 'https://ipfs.cloud.fx.land/gateway/$cid/') + .gatewayUrl, + 'https://ipfs.filebase.io/ipfs/$cid/', + ); + }); + + test('no CID means no link', () { + expect(gen().gatewayUrl, isNull); + }); + + // File shares use the same template helper but are files, not pages, and + // must stay bare. The slash is deliberately confined to site links. + test('public FILE-share URLs are NOT slashed', () { + IpfsGatewayHelper.updateCache(IpfsGatewayHelper.filebaseTemplate); + expect(publicGatewayUrlForCid(cid), 'https://ipfs.filebase.io/ipfs/$cid'); + }); +}