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
18 changes: 17 additions & 1 deletion lib/core/models/website_generation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// (`../<cid>`), and that resolves correctly only from the slashed form:
/// from `https://host/ipfs/<cid>` it lands on `/<asset>` and 404s, while
/// from `https://host/ipfs/<cid>/` it lands on `/ipfs/<asset>`. 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
Expand Down
79 changes: 79 additions & 0 deletions test/unit/core/models/website_generation_gateway_url_test.dart
Original file line number Diff line number Diff line change
@@ -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 (`../<cid>`), 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');
});
}
Loading