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: 31 additions & 5 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,31 @@ GET https://fxfiles.top/w/<ipnsName> -> 302 https://<cid>.ipfs.dweb.l
GET https://fxfiles.top/w/<ipnsName>/page.html -> 302 https://<cid>.ipfs.dweb.link/page.html
```

## Choosing a gateway (`?gw=`)

An optional `?gw=` selects which gateway the redirect lands on:

```
GET /w/<ipnsName>?gw=dweb -> 302 https://<cid>.ipfs.dweb.link/
GET /w/<ipnsName>?gw=filebase -> 302 https://ipfs.filebase.io/ipfs/<cid>
```

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;
Expand Down Expand Up @@ -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.
132 changes: 122 additions & 10 deletions cloudflare/ipns-resolver-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ipnsName>[/<subpath>]
* GET https://fxfiles.top/w/<ipnsName>[/<subpath>][?gw=dweb|filebase]
* -> resolve <ipnsName> to its current CID via w3name's plain HTTP API
* -> 302 to https://<cid>.ipfs.dweb.link/<subpath>
* -> 302 to that gateway's URL for the CID, e.g.
* https://<cid>.ipfs.dweb.link/<subpath> (gw=dweb, default)
* https://ipfs.filebase.io/ipfs/<cid>/<subpath> (gw=filebase)
*
* Why this design:
* - The app never talks to Cloudflare and holds NO credential here. The IPNS
Expand All @@ -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
Expand All @@ -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=<key>`.
*
* 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://<cid>.ipfs.dweb.link/<path>
* path — https://ipfs.filebase.io/ipfs/<cid>/<path>
*/
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/<cid>`, which is all this app ever
* publishes — but the format allows `/ipfs/<cid>/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) {
Expand Down Expand Up @@ -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/<cid>, 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}`, {
Expand All @@ -98,11 +196,25 @@ export default {
const data = await res.json();
const value = data && data.value; // e.g. "/ipfs/<cid>"
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://<cid>.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}`);
}
}
}
Expand Down
Binary file added cloudflare/ipns-resolver-worker.test.mjs
Binary file not shown.
61 changes: 61 additions & 0 deletions lib/core/services/ipfs_gateway_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> presets = <String, String>{
'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<String, String> _frontDoorKeys = <String, String>{
defaultTemplate: 'dweb',
filebaseTemplate: 'filebase',
};

static String? frontDoorGatewayKey([String? template]) =>
_frontDoorKeys[(template ?? _cachedTemplate).trim()];

/// Decorate a stored `https://fxfiles.top/w/<name>` 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
Expand Down
6 changes: 5 additions & 1 deletion lib/features/websites/screens/website_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -171,7 +172,10 @@ class _WebsiteDetailScreenState extends ConsumerState<WebsiteDetailScreen> {
);
}

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(
Expand Down
11 changes: 6 additions & 5 deletions lib/web/screens/web_api_config_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,8 +46,12 @@ class _WebApiConfigScreenState extends State<WebApiConfigScreen> {
'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,
Expand Down Expand Up @@ -100,8 +103,6 @@ class _WebApiConfigScreenState extends State<WebApiConfigScreen> {
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;
}

Expand Down
Loading
Loading