diff --git a/CHANGELOG.md b/CHANGELOG.md index e608aff..324b2ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Consistent IPFS gateway URL normalization in `IPFSStorage.upload` (#504) + +- Issue #504: Factored gateway URL normalization out of `IPFSStorage.resolve` into a shared helper used by both `upload()` and `resolve()` so custom gateways without an `/ipfs` suffix return consistent gateway URLs. + ### MultiSigConfig integration (#442) - Issue #442: Established `InitMultiSigParams extends MultiSigConfig`, added `MultiSigEscrowClient.getConfig` to expose operation configuration, and added integration tests. diff --git a/src/storage/ipfs.ts b/src/storage/ipfs.ts index 0e178ab..a3ea347 100644 --- a/src/storage/ipfs.ts +++ b/src/storage/ipfs.ts @@ -60,6 +60,15 @@ export interface IPFSResolveOptions { timeoutMs?: number; } +function normalizeGatewayUrl(gateway: string): string { + const cleanGateway = gateway.replace(/\/+$/, ''); + return /\/ipfs(?:\/|$)/.test(cleanGateway) ? cleanGateway : `${cleanGateway}/ipfs`; +} + +function buildGatewayCidUrl(gateway: string, cid: string): string { + return `${normalizeGatewayUrl(gateway)}/${cid}`; +} + /** * IPFS storage helper — uploads files and resolves JSON metadata with automatic gateway fallback. */ @@ -130,7 +139,7 @@ export class IPFSStorage { return { ok: false, error: 'Upload succeeded but response did not include a valid CID' }; } logger.info('IPFS upload succeeded', { cid }); - return { ok: true, data: { cid, url: `${this.gatewayUrl}/${cid}` } }; + return { ok: true, data: { cid, url: buildGatewayCidUrl(this.gatewayUrl, cid) } }; } catch (err) { logger.error('IPFS upload failed', { error: toApiErrorMessage(err) }); return { ok: false, error: toApiErrorMessage(err) }; @@ -158,8 +167,7 @@ export class IPFSStorage { for (const gateway of this.gateways) { const cleanGateway = gateway.replace(/\/+$/, ''); - const baseUrl = cleanGateway.includes('/ipfs') ? cleanGateway : `${cleanGateway}/ipfs`; - const url = `${baseUrl}/${cid}`; + const url = buildGatewayCidUrl(cleanGateway, cid); try { logger.debug('Resolving IPFS CID from gateway', { gateway: cleanGateway, cid }); const response = await axios.get(url, { diff --git a/tests/storage.test.ts b/tests/storage.test.ts index bdef7a5..d283b59 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -164,4 +164,38 @@ describe('IPFSStorage.upload', () => { expect(result.error).toMatch(/Network error/); } }); + + it('normalizes custom gatewayUrl without /ipfs so upload() matches resolve() URL construction (#504)', async () => { + const axios = jest.requireActual('axios'); + const getSpy = jest.spyOn(axios, 'get').mockResolvedValue({ + status: 200, + headers: { 'content-type': 'application/json' }, + data: '{"ok":true}', + }); + const validCid = 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG'; + + try { + for (const [gatewayUrl, expectedPrefix] of [ + ['https://my-gateway.example', 'https://my-gateway.example/ipfs'], + ['https://my-gateway.example/', 'https://my-gateway.example/ipfs'], + ['https://my-gateway.example/ipfs/', 'https://my-gateway.example/ipfs'], + ['https://my-gateway.example/ipfs-proxy', 'https://my-gateway.example/ipfs-proxy/ipfs'], + ] as const) { + getSpy.mockClear(); + mockHttpPost.mockResolvedValueOnce({ data: { cid: validCid } }); + + const storage = new IPFSStorage({ gatewayUrl }); + const uploadResult = await storage.upload(Buffer.from('payload')); + await storage.resolve(validCid); + + expect(uploadResult.ok).toBe(true); + if (uploadResult.ok) { + expect(uploadResult.data.url).toBe(`${expectedPrefix}/${validCid}`); + expect(getSpy).toHaveBeenCalledWith(uploadResult.data.url, expect.any(Object)); + } + } + } finally { + getSpy.mockRestore(); + } + }); });