diff --git a/add-on/src/lib/ipfs-client/index.js b/add-on/src/lib/ipfs-client/index.js index b0fb7dfa1..4c2069bfa 100644 --- a/add-on/src/lib/ipfs-client/index.js +++ b/add-on/src/lib/ipfs-client/index.js @@ -41,7 +41,10 @@ export async function destroyIpfsClient (browser) { log('destroy ipfs client') if (!client) return try { - await client.destroy(browser) + // Only destroy if client has a destroy method (not SW Gateway) + if (client.destroy) { + await client.destroy(browser) + } await _reloadIpfsClientDependents(browser) // sync (API stopped working) } finally { client = null diff --git a/add-on/src/lib/ipfs-companion.js b/add-on/src/lib/ipfs-companion.js index 0ea146a6f..9899cf333 100644 --- a/add-on/src/lib/ipfs-companion.js +++ b/add-on/src/lib/ipfs-companion.js @@ -23,6 +23,7 @@ import { guiURLString, migrateOptions, optionDefaults, safeURL, storeMissingOpti import { cleanupRules, getExtraInfoSpec } from './redirect-handler/blockOrObserve.js' import createRuntimeChecks from './runtime-checks.js' import { initState, offlinePeerCount } from './state.js' +import { redirectToSwGateway, isFeatureDisabledForSwGateway } from './ipfs-request-sw-gateway.js' // this won't work in webworker context. Needs to be enabled manually // https://github.com/debug-js/debug/issues/916 @@ -169,6 +170,12 @@ export default async function init (inQuickImport = false) { } function onBeforeRequest (request) { + if (state && (state.isServiceWorkerGateway || state.ipfsNodeType === 'service_worker_gateway')) { + const swRedirect = redirectToSwGateway(state, request) + if (swRedirect) { + return swRedirect + } + } return modifyRequest.onBeforeRequest(request) } @@ -648,6 +655,14 @@ export default async function init (inQuickImport = false) { case 'ipfsNodeType': shouldRestartIpfsClient = true state[key] = change.newValue + state.isServiceWorkerGateway = (change.newValue === 'service_worker_gateway') + break + case 'serviceWorkerGatewayUrl': + state[key] = change.newValue + if (change.newValue) { + state.swGwURL = safeURL(change.newValue) + state.swGwURLString = state.swGwURL?.toString() + } break case 'ipfsNodeConfig': shouldRestartIpfsClient = true diff --git a/add-on/src/lib/ipfs-request-sw-gateway.js b/add-on/src/lib/ipfs-request-sw-gateway.js new file mode 100644 index 000000000..4fce85fb9 --- /dev/null +++ b/add-on/src/lib/ipfs-request-sw-gateway.js @@ -0,0 +1,72 @@ +'use strict' +/* eslint-env browser, webextensions */ + +import debug from 'debug' +const log = debug('ipfs-companion:sw-gateway') + +/** + * Handles Service Worker Gateway redirects + * @param {import('../types/companion.js').CompanionState} state + * @param {browser.WebRequest.OnBeforeRequestDetailsType | {url: string, type?: string}} request + * @returns {Object|undefined} + */ +export function redirectToSwGateway(state, request) { + // Only redirect if SW Gateway is active + if (!state.isServiceWorkerGateway || !state.active) { + return + } + + if (!request || !request.url) { + return + } + + try { + const url = new URL(request.url) + + // Skip if already redirected to SW gateway + if (url.hostname.includes('inbrowser.link') || + url.hostname.includes('inbrowser.dev')) { + return + } + + // Check for IPFS/IPNS paths + const match = url.pathname.match(/^\/(ipfs|ipns)\/([^\/]+)(\/.*)?$/) + if (!match) return + + const [, protocol, cid, path = ''] = match + const gatewayUrl = state.serviceWorkerGatewayUrl || 'https://inbrowser.link' + const gwUrl = new URL(gatewayUrl) + + // Use PATH format instead of subdomain to preserve CID case + // https://inbrowser.link/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/wiki/ + const redirectUrl = `${gwUrl.protocol}//${gwUrl.hostname}/${protocol}/${cid}${path}${url.search}${url.hash}` + + log(`Redirecting to SW Gateway: ${request.url} → ${redirectUrl}`) + return { redirectUrl } + } catch (error) { + log.error('Error in SW Gateway redirect:', error) + return + } +} + +/** + * Check if feature should be disabled for SW Gateway + * @param {string} feature + * @param {import('../types/companion.js').CompanionState} state + * @returns {boolean} + */ +export function isFeatureDisabledForSwGateway(feature, state) { + if (!state.isServiceWorkerGateway) return false + + const disabledFeatures = [ + 'quickImport', + 'ipfsProxy', + 'pinning', + 'mfsSupport', + 'ipfsNodeInfo', + 'swarmPeers', + 'webui' + ] + + return disabledFeatures.includes(feature) +} \ No newline at end of file diff --git a/add-on/src/lib/options.js b/add-on/src/lib/options.js index 61e1d8f63..37684da2a 100644 --- a/add-on/src/lib/options.js +++ b/add-on/src/lib/options.js @@ -3,6 +3,7 @@ import isFQDN from 'is-fqdn' import { isIPv4, isIPv6 } from 'is-ip' import { POSSIBLE_NODE_TYPES } from './state.js' +export const SERVICE_WORKER_GATEWAY_NODE = 'service_worker_gateway' /** * @type {Readonly} @@ -35,7 +36,9 @@ export const optionDefaults = Object.freeze({ importDir: '/ipfs-companion-imports/%Y-%M-%D_%h%m%s/', useLatestWebUI: false, dismissedUpdate: null, - openViaWebUI: true + openViaWebUI: true, + serviceWorkerGatewayUrl: 'https://inbrowser.link', + serviceWorkerGatewayFallbackUrl: 'https://inbrowser.dev' }) function buildDefaultIpfsNodeConfig () { @@ -98,6 +101,18 @@ export function guiURLString (url, opts) { return safeURL(url, opts).toString().replace(/\/$/, '') } +export function normalizeGatewayURL (value) { + try { + const u = new URL(value) + if (u.protocol !== 'https:' && (u.hostname.endsWith('inbrowser.link') || u.hostname.endsWith('inbrowser.dev'))) { + u.protocol = 'https:' + } + return guiURLString(u) + } catch { + // fall back to prod default if invalid + return optionDefaults.serviceWorkerGatewayUrl + } +} // ensure value is a valid URL.hostname (FQDN || ipv4 || ipv6 WITH brackets) export function isHostname (x) { if (isFQDN(x) || isIPv4(x)) { @@ -228,5 +243,27 @@ export async function migrateOptions (storage, debug) { } } + // v3.x: normalize/introduce Service Worker Gateway options + { + // Map any historical synonyms to the canonical value + const { ipfsNodeType } = await storage.get(['ipfsNodeType']) + if (ipfsNodeType === 'service-worker-gateway' || ipfsNodeType === 'sw-gateway') { + await storage.set({ ipfsNodeType: SERVICE_WORKER_GATEWAY_NODE }) + } + + // Ensureing SW gateway URLs exist and are normalized (drop trailing slash, enforce https on known hosts) + const { serviceWorkerGatewayUrl, serviceWorkerGatewayFallbackUrl } = + await storage.get(['serviceWorkerGatewayUrl', 'serviceWorkerGatewayFallbackUrl']) + + const desiredPrimary = serviceWorkerGatewayUrl || optionDefaults.serviceWorkerGatewayUrl + const desiredFallback = serviceWorkerGatewayFallbackUrl || optionDefaults.serviceWorkerGatewayFallbackUrl + + const normalized = { + serviceWorkerGatewayUrl: normalizeGatewayURL(desiredPrimary), + serviceWorkerGatewayFallbackUrl: normalizeGatewayURL(desiredFallback) + } + await storage.set(normalized) + } + // TODO: refactor this, so migrations only run once (like https://github.com/sindresorhus/electron-store#migrations) } diff --git a/add-on/src/lib/state.js b/add-on/src/lib/state.js index cad8ea3ca..25f525b9c 100644 --- a/add-on/src/lib/state.js +++ b/add-on/src/lib/state.js @@ -4,7 +4,8 @@ import { isHostname, safeURL } from './options.js' export const offlinePeerCount = -1 -export const POSSIBLE_NODE_TYPES = ['external'] +export const SERVICE_WORKER_GATEWAY_NODE = 'service_worker_gateway' +export const POSSIBLE_NODE_TYPES = ['external',SERVICE_WORKER_GATEWAY_NODE] /** * @@ -36,6 +37,18 @@ export function initState (options, overrides) { state.gwURLString = state.gwURL?.toString() delete state.customGatewayUrl state.dnslinkPolicy = String(options.dnslinkPolicy) === 'false' ? false : options.dnslinkPolicy + state.isServiceWorkerGateway = + options.ipfsNodeType === SERVICE_WORKER_GATEWAY_NODE + + // Normalize and expose current/fallback SW gateway endpoints (keep originals too) + if (options.serviceWorkerGatewayUrl) { + state.swGwURL = safeURL(options.serviceWorkerGatewayUrl) + state.swGwURLString = state.swGwURL?.toString() + } + if (options.serviceWorkerGatewayFallbackUrl) { + state.swGwFallbackURL = safeURL(options.serviceWorkerGatewayFallbackUrl) + state.swGwFallbackURLString = state.swGwFallbackURL?.toString() + } // attach helper functions state.activeIntegrations = (url) => { diff --git a/add-on/src/options/forms/gateways-form.js b/add-on/src/options/forms/gateways-form.js index d0055a52b..d2a8336dc 100644 --- a/add-on/src/options/forms/gateways-form.js +++ b/add-on/src/options/forms/gateways-form.js @@ -12,6 +12,73 @@ import { POSSIBLE_NODE_TYPES } from '../../lib/state.js' // https://github.com/ipfs-shipyard/ipfs-companion/issues/648 const secureContextUrl = /^https:\/\/|^http:\/\/localhost|^http:\/\/127.0.0.1|^http:\/\/\[::1\]/ +// Add this function before the export default +function renderServiceWorkerGatewayOptions({ + ipfsNodeType, + serviceWorkerGatewayUrl, + onOptionChange +}) { + const isSwGateway = ipfsNodeType === 'service_worker_gateway' + const onSwGatewayUrlChange = onOptionChange('serviceWorkerGatewayUrl', guiURLString) + + if (!isSwGateway) return null + + return html` +
+ + +
+ + ` +} + export default function gatewaysForm ({ ipfsNodeType, customGatewayUrl, @@ -21,7 +88,8 @@ export default function gatewaysForm ({ enabledOn, publicGatewayUrl, publicSubdomainGatewayUrl, - onOptionChange + onOptionChange, + serviceWorkerGatewayUrl }) { const onCustomGatewayUrlChange = onOptionChange('customGatewayUrl', (url) => guiURLString(url, { useLocalhostName: useSubdomains })) const onUseCustomGatewayChange = onOptionChange('useCustomGateway') @@ -38,6 +106,7 @@ export default function gatewaysForm ({

${browser.i18n.getMessage('option_header_gateways')}

+ ${renderServiceWorkerGatewayOptions({ ipfsNodeType, serviceWorkerGatewayUrl, onOptionChange })}
diff --git a/add-on/src/types/companion.d.ts b/add-on/src/types/companion.d.ts index b35e7028f..394b8eed6 100644 --- a/add-on/src/types/companion.d.ts +++ b/add-on/src/types/companion.d.ts @@ -1,7 +1,6 @@ export interface CompanionOptions { active: boolean - ipfsNodeType: string ipfsNodeConfig: string publicGatewayUrl: string publicSubdomainGatewayUrl: string @@ -27,7 +26,10 @@ export interface CompanionOptions { importDir: string useLatestWebUI: boolean dismissedUpdate: null | string - openViaWebUI: boolean + openViaWebUI: boolean, + ipfsNodeType: 'external' | 'service_worker_gateway' + serviceWorkerGatewayUrl: string + serviceWorkerGatewayFallbackUrl: string } export interface CompanionState extends Omit { @@ -43,7 +45,13 @@ export interface CompanionState extends Omit boolean localGwAvailable: boolean - webuiRootUrl: string + webuiRootUrl: string | null + nodeActive: boolean + isServiceWorkerGateway?: boolean + swGwURL?: URL + swGwURLString?: string + swGwFallbackURL?: URL + swGwFallbackURLString?: string } interface SwitchToggleArguments { diff --git a/package-lock.json b/package-lock.json index 763d1825f..6b670487b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@istanbuljs/esm-loader-hook": "0.2.0", "@types/chai": "4.3.20", "@types/debug": "4.1.12", + "@types/is-fqdn": "^2.0.0", "@types/mocha": "10.0.10", "@types/selenium-webdriver": "4.1.28", "@types/webextension-polyfill": "0.10.7", @@ -2926,6 +2927,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/is-fqdn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/is-fqdn/-/is-fqdn-2.0.0.tgz", + "integrity": "sha512-SOu3hvVxVptGQx9YpAI/OYW0jzjRW3B/2zxoMfBf3bvcEe4OzS6lWgKb1WPqsi60n0r3w4RjhJjSQyqfB3feSw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", diff --git a/package.json b/package.json index 244142388..44947d6fc 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "@istanbuljs/esm-loader-hook": "0.2.0", "@types/chai": "4.3.20", "@types/debug": "4.1.12", + "@types/is-fqdn": "^2.0.0", "@types/mocha": "10.0.10", "@types/selenium-webdriver": "4.1.28", "@types/webextension-polyfill": "0.10.7",