From 3ca105a80bc0771a7719f74b9bd689925892af6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammed=20Tanr=C4=B1kulu?= Date: Thu, 12 Sep 2024 16:55:09 +0200 Subject: [PATCH 1/5] add heeader/banner spec --- example/browser.js | 8 +- example/node.js | 14 +- index.html | 41 ++++-- src/index.ts | 46 +++++-- src/types.ts | 5 + test/resolver.test.ts | 294 ++++++++++++++++++++---------------------- 6 files changed, 225 insertions(+), 183 deletions(-) diff --git a/example/browser.js b/example/browser.js index 1f0a334..f986d29 100644 --- a/example/browser.js +++ b/example/browser.js @@ -64,10 +64,12 @@ function fadeImg() { this.style.opacity = '1'; } -function setImage(ens, avatarUri = notFoundImage, warn = false) { +function setImage(ens, avatarUri = notFoundImage, warn = false, headerUri) { const elem = document.getElementById('queryImage'); + const headerContainer = document.getElementById('headerContainer'); elem.setAttribute('src', avatarUri); elem.setAttribute('alt', ens); + headerContainer.style.backgroundImage = headerUri ? `url("${headerUri}")` : 'none'; const warnText = document.getElementById('warnText'); if (warn) { if (warnText) return; @@ -112,7 +114,9 @@ document.getElementById('queryInput').addEventListener('change', event => { .getMetadata(ens) .then(metadata => { const avatar = avtUtils.getImageURI({ metadata }); - setImage(ens, avatar); + avt.getHeader(ens).then(header => { + setImage(ens, avatar, false, header); + }); elem.style.filter = 'none'; }) .catch(error => { diff --git a/example/node.js b/example/node.js index b3c98d8..e43067f 100644 --- a/example/node.js +++ b/example/node.js @@ -15,7 +15,10 @@ const IPFS = 'https://cf-ipfs.com'; const provider = new StaticJsonRpcProvider( `https://mainnet.infura.io/v3/${process.env.INFURA_KEY}` ); -const avt = new AvatarResolver(provider, { ipfs: IPFS, apiKey: { opensea: process.env.OPENSEA_KEY }}); +const avt = new AvatarResolver(provider, { + ipfs: IPFS, + apiKey: { opensea: process.env.OPENSEA_KEY }, +}); avt .getMetadata(ensName) .then(metadata => { @@ -30,6 +33,13 @@ avt }, jsdomWindow: jsdom, }); - console.log(avatar); + console.log('avatar: ', avatar); + }) + .catch(console.log); + +avt + .getHeader(ensName) + .then(header => { + console.log('header: ', header); }) .catch(console.log); diff --git a/index.html b/index.html index 4efa5be..828e45d 100644 --- a/index.html +++ b/index.html @@ -9,20 +9,28 @@
-

Query ENS Avatar

-
- +
+

Query ENS Avatar

- -
- (type ENS name and hit the enter button) +
+ +
+ (type ENS name and hit the enter button) +
@@ -49,6 +57,12 @@

Query ENS Avatar

pointer-events: none; user-select: none; } + #headerContainer { + height: 100%; + background-position: 0 55px; + background-repeat: no-repeat; + background-size: contain; + } .hint { margin-top: 0.5rem; color: #aaa; @@ -76,6 +90,9 @@

Query ENS Avatar

} .queryImage { object-fit: contain; + border: 5px solid white; + border-radius: 5px; + outline: #d6d6d6 solid 2px; } .queryInput { width: 19rem; diff --git a/src/index.ts b/src/index.ts index 1b9ab55..42ed76d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,12 @@ import { isImageURI, parseNFT, } from './utils'; -import { AvatarRequestOpts, AvatarResolverOpts, Spec } from './types'; +import { + AvatarRequestOpts, + AvatarResolverOpts, + HeaderRequestOpts, + Spec, +} from './types'; export const specs: { [key: string]: new () => Spec } = Object.freeze({ erc721: ERC721, @@ -23,10 +28,14 @@ export const specs: { [key: string]: new () => Spec } = Object.freeze({ export interface UnsupportedNamespace {} export class UnsupportedNamespace extends BaseError {} +export interface UnsupportedMediaKey {} +export class UnsupportedMediaKey extends BaseError {} + export interface AvatarResolver { provider: JsonRpcProvider; options?: AvatarResolverOpts; getAvatar(ens: string, data: AvatarRequestOpts): Promise; + getHeader(ens: string, data: HeaderRequestOpts): Promise; getMetadata(ens: string): Promise; } @@ -42,7 +51,7 @@ export class AvatarResolver implements AvatarResolver { } } - async getMetadata(ens: string) { + async getMetadata(ens: string, key: string = 'avatar') { // retrieve registrar address and resolver object from ens name const [resolvedAddress, resolver] = await handleSettled([ this.provider.resolveName(ens), @@ -51,20 +60,18 @@ export class AvatarResolver implements AvatarResolver { if (!resolver) return null; // retrieve 'avatar' text recored from resolver - const avatarURI = await resolver.getText('avatar'); - if (!avatarURI) return null; + const mediaURI = await resolver.getText(key); + if (!mediaURI) return null; // test case-insensitive in case of uppercase records - if (!/eip155:/i.test(avatarURI)) { + if (!/eip155:/i.test(mediaURI)) { const uriSpec = new URI(); - const metadata = await uriSpec.getMetadata(avatarURI, this.options); + const metadata = await uriSpec.getMetadata(mediaURI, this.options); return { uri: ens, ...metadata }; } // parse retrieved avatar uri - const { chainID, namespace, contractAddress, tokenID } = parseNFT( - avatarURI - ); + const { chainID, namespace, contractAddress, tokenID } = parseNFT(mediaURI); // detect avatar spec by namespace const Spec = specs[namespace]; if (!Spec) @@ -95,7 +102,26 @@ export class AvatarResolver implements AvatarResolver { ens: string, data?: AvatarRequestOpts ): Promise { - const metadata = await this.getMetadata(ens); + return this._getMedia(ens, 'avatar', data); + } + + async getHeader( + ens: string, + data?: HeaderRequestOpts + ): Promise { + const mediaKey = data?.mediaKey || 'header'; + if (!['header', 'banner'].includes(mediaKey)) { + throw new UnsupportedMediaKey('Unsupported media key'); + } + return this._getMedia(ens, mediaKey, data); + } + + async _getMedia( + ens: string, + mediaKey: string = 'avatar', + data?: HeaderRequestOpts + ) { + const metadata = await this.getMetadata(ens, mediaKey); if (!metadata) return null; const imageURI = getImageURI({ metadata, diff --git a/src/types.ts b/src/types.ts index b74d764..6861859 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,11 @@ export interface AvatarRequestOpts { jsdomWindow?: any; } +export interface HeaderRequestOpts { + jsdomWindow?: any; + mediaKey?: 'header' | 'banner'; +} + export type Gateways = { ipfs?: string; arweave?: string; diff --git a/test/resolver.test.ts b/test/resolver.test.ts index 437a13a..ac754d6 100644 --- a/test/resolver.test.ts +++ b/test/resolver.test.ts @@ -14,14 +14,37 @@ const CORS_HEADERS = { 'access-control-allow-origin': 'http://localhost', }; -function nockInfuraBatch(body: any[], response: any) { + +interface ChainIdParams { + method: 'eth_chainId'; + params: []; + id: number; + jsonrpc: string, +} + +interface EthCallParams { + method: 'eth_call', + params: [{ to: string, data: string }, string], + id: number, + jsonrpc: string, +} + +interface JsonRpcResult { + jsonrpc: string; + id: number; + result: string; +} + +function nockInfuraBatch(body: Array, response: JsonRpcResult[]) { nock(INFURA_URL.origin) .persist(false) - .post(INFURA_URL.pathname, body) + .post(INFURA_URL.pathname, body as []) .reply(200, response); } -function mockInfuraChainId(id: number) { +let nonceCall = 1, + nonceJsonRpc = 1; +function mockInfuraChainId() { nock(INFURA_URL.origin) .post(INFURA_URL.pathname, { method: 'eth_chainId', @@ -33,35 +56,36 @@ function mockInfuraChainId(id: number) { 200, { jsonrpc: '2.0', - id: id, + id: nonceCall++, result: '0x1', }, CORS_HEADERS as any ); + nonceJsonRpc++; } -function ethCallParams(to: string, data: string, id: number) { +function ethCallParams(to: string, data: string): EthCallParams { return { method: 'eth_call', params: [{ to, data }, 'latest'], - id, + id: nonceCall++, jsonrpc: '2.0', }; } -function chainIdParams(id: number) { +function chainIdParams(): ChainIdParams { return { method: 'eth_chainId', params: [], - id, + id: nonceCall++, jsonrpc: '2.0', }; } -function jsonRPCresult(result: string, id: number) { +function jsonRpcResult(result: string): JsonRpcResult { return { jsonrpc: '2.0', - id, + id: nonceJsonRpc++, result, }; } @@ -74,31 +98,27 @@ describe('get avatar', () => { }, }); it('retrieves image uri with erc721 spec', async () => { - mockInfuraChainId(1); + mockInfuraChainId(); nockInfuraBatch( [ - chainIdParams(2), + chainIdParams(), ethCallParams( ENSRegistryWithFallback, - '0x0178b8bf80ee077a908dffcf32972ba13c2df16b42688e1de21bcf17d3469a8507895eae', - 3 + '0x0178b8bf80ee077a908dffcf32972ba13c2df16b42688e1de21bcf17d3469a8507895eae' ), ethCallParams( ENSRegistryWithFallback, - '0x0178b8bf80ee077a908dffcf32972ba13c2df16b42688e1de21bcf17d3469a8507895eae', - 4 + '0x0178b8bf80ee077a908dffcf32972ba13c2df16b42688e1de21bcf17d3469a8507895eae' ), ], [ - jsonRPCresult('0x1', 2), - jsonRPCresult( - '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41', - 3 + jsonRpcResult('0x1'), + jsonRpcResult( + '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41' ), - jsonRPCresult( - '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41', - 4 + jsonRpcResult( + '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41' ), ] ); @@ -106,93 +126,81 @@ describe('get avatar', () => { [ ethCallParams( PublicResolver.toLowerCase(), - '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000', - 5 + '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000' ), - chainIdParams(6), + chainIdParams(), ], [ - jsonRPCresult( - '0x0000000000000000000000000000000000000000000000000000000000000000', - 5 + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' ), - jsonRPCresult('0x1', 6), + jsonRpcResult('0x1'), ] ); nockInfuraBatch( [ ethCallParams( PublicResolver.toLowerCase(), - '0x3b3b57de80ee077a908dffcf32972ba13c2df16b42688e1de21bcf17d3469a8507895eae', - 7 + '0x3b3b57de80ee077a908dffcf32972ba13c2df16b42688e1de21bcf17d3469a8507895eae' ), - chainIdParams(8), + chainIdParams(), ], [ - jsonRPCresult( - '0x0000000000000000000000005a384227b65fa093dec03ec34e111db80a040615', - 7 + jsonRpcResult( + '0x0000000000000000000000005a384227b65fa093dec03ec34e111db80a040615' ), - jsonRPCresult('0x1', 8), + jsonRpcResult('0x1'), ] ); nockInfuraBatch( [ ethCallParams( PublicResolver.toLowerCase(), - '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000', - 9 + '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000' ), - chainIdParams(10), + chainIdParams(), ], [ - jsonRPCresult( - '0x0000000000000000000000000000000000000000000000000000000000000000', - 9 + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' ), - jsonRPCresult('0x1', 10), + jsonRpcResult('0x1'), ] ); nockInfuraBatch( [ ethCallParams( PublicResolver.toLowerCase(), - '0x59d1d43c80ee077a908dffcf32972ba13c2df16b42688e1de21bcf17d3469a8507895eae000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000066176617461720000000000000000000000000000000000000000000000000000', - 11 + '0x59d1d43c80ee077a908dffcf32972ba13c2df16b42688e1de21bcf17d3469a8507895eae000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000066176617461720000000000000000000000000000000000000000000000000000' ), - chainIdParams(12), + chainIdParams(), ], [ - jsonRPCresult( - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003f6569703135353a312f6572633732313a3078333133383564333532306263656439346637376161653130346234303639393464386632313638632f3934323100', - 11 + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003f6569703135353a312f6572633732313a3078333133383564333532306263656439346637376161653130346234303639393464386632313638632f3934323100' ), - jsonRPCresult('0x1', 12), + jsonRpcResult('0x1'), ] ); nockInfuraBatch( [ ethCallParams( '0x31385d3520bced94f77aae104b406994d8f2168c', - '0xc87b56dd00000000000000000000000000000000000000000000000000000000000024cd', - 13 + '0xc87b56dd00000000000000000000000000000000000000000000000000000000000024cd' ), - chainIdParams(14), + chainIdParams(), ethCallParams( '0x31385d3520bced94f77aae104b406994d8f2168c', - '0x6352211e00000000000000000000000000000000000000000000000000000000000024cd', - 15 + '0x6352211e00000000000000000000000000000000000000000000000000000000000024cd' ), ], [ - jsonRPCresult( - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002568747470733a2f2f6170692e6261737461726467616e70756e6b732e636c75622f39343231000000000000000000000000000000000000000000000000000000', - 13 + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002568747470733a2f2f6170692e6261737461726467616e70756e6b732e636c75622f39343231000000000000000000000000000000000000000000000000000000' ), - jsonRPCresult('0x1', 14), - jsonRPCresult( - '0x0000000000000000000000005a384227b65fa093dec03ec34e111db80a040615', - 15 + jsonRpcResult('0x1'), + jsonRpcResult( + '0x0000000000000000000000005a384227b65fa093dec03ec34e111db80a040615' ), ] ); @@ -231,31 +239,27 @@ describe('get avatar', () => { ); }); it('retrieves image uri with custom spec', async () => { - mockInfuraChainId(16); + mockInfuraChainId(); nockInfuraBatch( [ ethCallParams( ENSRegistryWithFallback.toString(), - '0x0178b8bfb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f', - 17 + '0x0178b8bfb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f' ), - chainIdParams(18), + chainIdParams(), ethCallParams( ENSRegistryWithFallback.toString(), - '0x0178b8bfb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f', - 19 + '0x0178b8bfb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f' ), ], [ - jsonRPCresult( - '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41', - 17 + jsonRpcResult( + '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41' ), - jsonRPCresult('0x1', 18), - jsonRPCresult( - '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41', - 19 + jsonRpcResult('0x1'), + jsonRpcResult( + '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41' ), ] ); @@ -263,68 +267,60 @@ describe('get avatar', () => { [ ethCallParams( PublicResolver.toLowerCase(), - '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000', - 20 + '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000' ), - chainIdParams(21), + chainIdParams(), ], [ - jsonRPCresult( - '0x0000000000000000000000000000000000000000000000000000000000000000', - 20 + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' ), - jsonRPCresult('0x1', 21), + jsonRpcResult('0x1'), ] ); nockInfuraBatch( [ ethCallParams( PublicResolver.toLowerCase(), - '0x3b3b57deb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f', - 22 + '0x3b3b57deb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f' ), - chainIdParams(23), + chainIdParams(), ], [ - jsonRPCresult( - '0x0000000000000000000000000d59d0f7dcc0fbf0a3305ce0261863aaf7ab685c', - 22 + jsonRpcResult( + '0x0000000000000000000000000d59d0f7dcc0fbf0a3305ce0261863aaf7ab685c' ), - jsonRPCresult('0x1', 23), + jsonRpcResult('0x1'), ] ); nockInfuraBatch( [ ethCallParams( PublicResolver.toLowerCase(), - '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000', - 24 + '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000' ), - chainIdParams(25), + chainIdParams(), ], [ - jsonRPCresult( - '0x0000000000000000000000000000000000000000000000000000000000000000', - 24 + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' ), - jsonRPCresult('0x1', 25), + jsonRpcResult('0x1'), ] ); nockInfuraBatch( [ ethCallParams( PublicResolver.toLowerCase(), - '0x59d1d43cb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000066176617461720000000000000000000000000000000000000000000000000000', - 26 + '0x59d1d43cb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000066176617461720000000000000000000000000000000000000000000000000000' ), - chainIdParams(27), + chainIdParams(), ], [ - jsonRPCresult( - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004368747470733a2f2f697066732e696f2f697066732f516d55536867666f5a5153484b3354517975546655707363385566654e6644384b77505576444255645a346e6d520000000000000000000000000000000000000000000000000000000000', - 26 + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004368747470733a2f2f697066732e696f2f697066732f516d55536867666f5a5153484b3354517975546655707363385566654e6644384b77505576444255645a346e6d520000000000000000000000000000000000000000000000000000000000' ), - jsonRPCresult('0x1', 27), + jsonRpcResult('0x1'), ] ); const MANIFEST_URI_TANRIKULU = new URL( @@ -349,31 +345,27 @@ describe('get avatar', () => { ); }); it('retrieves image uri with erc1155 spec', async () => { - mockInfuraChainId(28); + mockInfuraChainId(); nockInfuraBatch( [ ethCallParams( ENSRegistryWithFallback.toLowerCase(), - '0x0178b8bf05a67c0ee82964c4f7394cdd47fee7f4d9503a23c09c38341779ea012afe6e00', - 29 + '0x0178b8bf05a67c0ee82964c4f7394cdd47fee7f4d9503a23c09c38341779ea012afe6e00' ), - chainIdParams(30), + chainIdParams(), ethCallParams( ENSRegistryWithFallback.toLowerCase(), - '0x0178b8bf05a67c0ee82964c4f7394cdd47fee7f4d9503a23c09c38341779ea012afe6e00', - 31 + '0x0178b8bf05a67c0ee82964c4f7394cdd47fee7f4d9503a23c09c38341779ea012afe6e00' ), ], [ - jsonRPCresult( - '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41', - 29 + jsonRpcResult( + '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41' ), - jsonRPCresult('0x1', 30), - jsonRPCresult( - '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41', - 31 + jsonRpcResult('0x1'), + jsonRpcResult( + '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41' ), ] ); @@ -381,93 +373,81 @@ describe('get avatar', () => { [ ethCallParams( PublicResolver.toLowerCase(), - '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000', - 32 + '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000' ), - chainIdParams(33), + chainIdParams(), ], [ - jsonRPCresult( - '0x0000000000000000000000000000000000000000000000000000000000000000', - 32 + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' ), - jsonRPCresult('0x1', 33), + jsonRpcResult('0x1'), ] ); nockInfuraBatch( [ ethCallParams( PublicResolver.toLowerCase(), - '0x3b3b57de05a67c0ee82964c4f7394cdd47fee7f4d9503a23c09c38341779ea012afe6e00', - 34 + '0x3b3b57de05a67c0ee82964c4f7394cdd47fee7f4d9503a23c09c38341779ea012afe6e00' ), - chainIdParams(35), + chainIdParams(), ], [ - jsonRPCresult( - '0x000000000000000000000000b8c2c29ee19d8307cb7255e1cd9cbde883a267d5', - 34 + jsonRpcResult( + '0x000000000000000000000000b8c2c29ee19d8307cb7255e1cd9cbde883a267d5' ), - jsonRPCresult('0x1', 35), + jsonRpcResult('0x1'), ] ); nockInfuraBatch( [ ethCallParams( PublicResolver.toLowerCase(), - '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000', - 36 + '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000' ), - chainIdParams(37), + chainIdParams(), ], [ - jsonRPCresult( - '0x0000000000000000000000000000000000000000000000000000000000000000', - 36 + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' ), - jsonRPCresult('0x1', 37), + jsonRpcResult('0x1'), ] ); nockInfuraBatch( [ ethCallParams( PublicResolver.toLowerCase(), - '0x59d1d43c05a67c0ee82964c4f7394cdd47fee7f4d9503a23c09c38341779ea012afe6e00000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000066176617461720000000000000000000000000000000000000000000000000000', - 38 + '0x59d1d43c05a67c0ee82964c4f7394cdd47fee7f4d9503a23c09c38341779ea012afe6e00000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000066176617461720000000000000000000000000000000000000000000000000000' ), - chainIdParams(39), + chainIdParams(), ], [ - jsonRPCresult( - '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000886569703135353a312f657263313135353a3078343935663934373237363734396365363436663638616338633234383432303034356362376235652f38313132333136303235383733393237373337353035393337383938393135313533373332353830313033393133373034333334303438353132333830343930373937303038353531393337000000000000000000000000000000000000000000000000', - 38 + jsonRpcResult( + '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000886569703135353a312f657263313135353a3078343935663934373237363734396365363436663638616338633234383432303034356362376235652f38313132333136303235383733393237373337353035393337383938393135313533373332353830313033393133373034333334303438353132333830343930373937303038353531393337000000000000000000000000000000000000000000000000' ), - jsonRPCresult('0x1', 39), + jsonRpcResult('0x1'), ] ); nockInfuraBatch( [ ethCallParams( '0x495f947276749ce646f68ac8c248420045cb7b5e', - '0x0e89341c11ef687cfeb2e353670479f2dcc76af2bc6b3935000000000002c40000000001', - 40 + '0x0e89341c11ef687cfeb2e353670479f2dcc76af2bc6b3935000000000002c40000000001' ), - chainIdParams(41), + chainIdParams(), ethCallParams( '0x495f947276749ce646f68ac8c248420045cb7b5e', - '0x00fdd58e000000000000000000000000b8c2c29ee19d8307cb7255e1cd9cbde883a267d511ef687cfeb2e353670479f2dcc76af2bc6b3935000000000002c40000000001', - 42 + '0x00fdd58e000000000000000000000000b8c2c29ee19d8307cb7255e1cd9cbde883a267d511ef687cfeb2e353670479f2dcc76af2bc6b3935000000000002c40000000001' ), ], [ - jsonRPCresult( - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005868747470733a2f2f6170692e6f70656e7365612e696f2f6170692f76312f6d657461646174612f3078343935663934373237363734394365363436663638414338633234383432303034356362376235652f30787b69647d0000000000000000', - 40 + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005868747470733a2f2f6170692e6f70656e7365612e696f2f6170692f76312f6d657461646174612f3078343935663934373237363734394365363436663638414338633234383432303034356362376235652f30787b69647d0000000000000000' ), - jsonRPCresult('0x1', 41), - jsonRPCresult( - '0x0000000000000000000000000000000000000000000000000000000000000001', - 42 + jsonRpcResult('0x1'), + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000001' ), ] ); From 970b5b68c70d282bda79efe0a4d7412c49c012a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammed=20Tanr=C4=B1kulu?= Date: Thu, 12 Sep 2024 17:03:39 +0200 Subject: [PATCH 2/5] lint --- test/resolver.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/test/resolver.test.ts b/test/resolver.test.ts index ac754d6..2adc029 100644 --- a/test/resolver.test.ts +++ b/test/resolver.test.ts @@ -14,19 +14,18 @@ const CORS_HEADERS = { 'access-control-allow-origin': 'http://localhost', }; - interface ChainIdParams { method: 'eth_chainId'; params: []; id: number; - jsonrpc: string, + jsonrpc: string; } interface EthCallParams { - method: 'eth_call', - params: [{ to: string, data: string }, string], - id: number, - jsonrpc: string, + method: 'eth_call'; + params: [{ to: string; data: string }, string]; + id: number; + jsonrpc: string; } interface JsonRpcResult { @@ -35,7 +34,10 @@ interface JsonRpcResult { result: string; } -function nockInfuraBatch(body: Array, response: JsonRpcResult[]) { +function nockInfuraBatch( + body: Array, + response: JsonRpcResult[] +) { nock(INFURA_URL.origin) .persist(false) .post(INFURA_URL.pathname, body as []) @@ -61,7 +63,7 @@ function mockInfuraChainId() { }, CORS_HEADERS as any ); - nonceJsonRpc++; + nonceJsonRpc++; } function ethCallParams(to: string, data: string): EthCallParams { From 88164bc0aa7621bd594b47c7f9f27f90576ae046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammed=20Tanr=C4=B1kulu?= Date: Thu, 12 Sep 2024 17:13:10 +0200 Subject: [PATCH 3/5] resolve type errors --- test/resolver.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/resolver.test.ts b/test/resolver.test.ts index 2adc029..78f8744 100644 --- a/test/resolver.test.ts +++ b/test/resolver.test.ts @@ -1,5 +1,5 @@ import { JsonRpcProvider } from 'ethers'; -import nock from 'nock'; +import nock, { RequestBodyMatcher } from 'nock'; import { AvatarResolver } from '../src'; require('dotenv').config(); @@ -16,14 +16,14 @@ const CORS_HEADERS = { interface ChainIdParams { method: 'eth_chainId'; - params: []; + params: Array; id: number; jsonrpc: string; } interface EthCallParams { method: 'eth_call'; - params: [{ to: string; data: string }, string]; + params: Array<{ to: string; data: string } | string>; id: number; jsonrpc: string; } @@ -35,12 +35,12 @@ interface JsonRpcResult { } function nockInfuraBatch( - body: Array, + body: Array, response: JsonRpcResult[] ) { nock(INFURA_URL.origin) .persist(false) - .post(INFURA_URL.pathname, body as []) + .post(INFURA_URL.pathname, body as RequestBodyMatcher) .reply(200, response); } From 9a20c9f2b393f06eafbf657641107f0e0d0825dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammed=20Tanr=C4=B1kulu?= Date: Tue, 24 Sep 2024 01:40:24 +0200 Subject: [PATCH 4/5] increase coverage, add header test, allow octet-stream as a mimetype --- package.json | 4 +- src/utils/isImageURI.ts | 28 ++-- src/utils/resolveURI.ts | 64 +++++++- test/resolver.test.ts | 126 +++++++++++++++- test/utils.test.ts | 324 ++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + yarn.lock | 31 ++++ 7 files changed, 560 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 183d0ce..75269db 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "main": "dist/index.js", "module": "dist/index.esm.js", @@ -57,9 +57,11 @@ "@size-limit/preset-small-lib": "^11.0.1", "@types/dompurify": "^3.0.5", "@types/jsdom": "^21.1.6", + "@types/moxios": "^0.4.17", "@types/url-join": "^4.0.1", "dotenv": "^16.3.1", "esbuild": "^0.14.21", + "moxios": "^0.4.0", "nock": "^13.2.2", "rollup": "^4.9.1", "size-limit": "^11.0.1", diff --git a/src/utils/isImageURI.ts b/src/utils/isImageURI.ts index 1302129..98a8eca 100644 --- a/src/utils/isImageURI.ts +++ b/src/utils/isImageURI.ts @@ -4,6 +4,7 @@ import { Buffer } from 'buffer/'; import { fetch } from './fetch'; export const ALLOWED_IMAGE_MIMETYPES = [ + 'application/octet-stream', 'image/jpeg', 'image/png', 'image/gif', @@ -16,6 +17,14 @@ export const ALLOWED_IMAGE_MIMETYPES = [ 'image/jxl', ]; +export const IMAGE_SIGNATURES = { + 'FFD8FF': 'image/jpeg', + '89504E47': 'image/png', + '47494638': 'image/gif', + '424D': 'image/bmp', + 'FF0A': 'image/jxl', +}; + const MAX_FILE_SIZE = 300 * 1024 * 1024; // 300 MB function isURIEncoded(uri: string): boolean { @@ -56,19 +65,18 @@ async function isStreamAnImage(url: string): Promise { if (response.data instanceof ArrayBuffer) { magicNumbers = new DataView(response.data).getUint32(0).toString(16); } else { + if ( + !response.data || + typeof response.data === 'string' || + !('readUInt32BE' in response.data) + ) { + throw 'isStreamAnImage: unsupported data, instance is not BufferLike'; + } magicNumbers = response.data.readUInt32BE(0).toString(16); } - const imageSignatures = [ - 'ffd8ff', // JPEG - '89504e47', // PNG - '47494638', // GIF - '424d', // BMP - 'ff0a', // JPEG XL - ]; - - const isBinaryImage = imageSignatures.some(signature => - magicNumbers.startsWith(signature) + const isBinaryImage = Object.keys(IMAGE_SIGNATURES).some(signature => + magicNumbers.toUpperCase().startsWith(signature) ); // Check for SVG image diff --git a/src/utils/resolveURI.ts b/src/utils/resolveURI.ts index 5d5c9d2..0cec0ec 100644 --- a/src/utils/resolveURI.ts +++ b/src/utils/resolveURI.ts @@ -2,6 +2,7 @@ import urlJoin from 'url-join'; import { Gateways } from '../types'; import { isCID } from './isCID'; +import { IMAGE_SIGNATURES } from './isImageURI'; const IPFS_SUBPATH = '/ipfs/'; const IPNS_SUBPATH = '/ipns/'; @@ -9,6 +10,67 @@ const networkRegex = /(?ipfs:\/|ipns:\/|ar:\/)?(?\/)?(? const base64Regex = /^data:([a-zA-Z\-/+]*);base64,([^"].*)/; const dataURIRegex = /^data:([a-zA-Z\-/+]*)?(;[a-zA-Z0-9].*?)?(,)/; +function _getImageMimeType(uri: string) { + const base64Data = uri.replace(base64Regex, '$2'); + const buffer = Buffer.from(base64Data, 'base64'); + + if (buffer.length < 12) { + return null; // not enough data to determine the type + } + + // get the hex representation of the first 12 bytes + const hex = buffer.toString('hex', 0, 12).toUpperCase(); + + // check against magic number mapping + for (const [magicNumber, mimeType] of Object.entries({ + ...IMAGE_SIGNATURES, + '52494646': 'special_webp_check', + '3C737667': 'image/svg+xml', + })) { + if (hex.startsWith(magicNumber.toUpperCase())) { + if (mimeType === 'special_webp_check') { + return hex.slice(8, 12) === '5745' ? 'image/webp' : null; + } + return mimeType; + } + } + + return null; +} + +function _isValidBase64(uri: string) { + if (typeof uri !== 'string') { + return false; + } + + // check if the string matches the Base64 pattern + if (!base64Regex.test(uri)) { + return false; + } + + const [header, str] = uri.split('base64,'); + + const mimeType = _getImageMimeType(uri); + + if (!mimeType || !header.includes(mimeType)) { + return false; + } + + // length must be multiple of 4 + if (str.length % 4 !== 0) { + return false; + } + + try { + // try to encode/decode the string, to see if matches + const buffer = Buffer.from(str, 'base64'); + const encoded = buffer.toString('base64'); + return encoded === str; + } catch (e) { + return false; + } +} + function _replaceGateway(uri: string, source: string, target?: string) { if (uri.startsWith(source) && target) { try { @@ -28,7 +90,7 @@ export function resolveURI( customGateway?: string ): { uri: string; isOnChain: boolean; isEncoded: boolean } { // resolves uri based on its' protocol - const isEncoded = base64Regex.test(uri); + const isEncoded = _isValidBase64(uri); if (isEncoded || uri.startsWith('http')) { uri = _replaceGateway(uri, 'https://ipfs.io/', gateways?.ipfs); uri = _replaceGateway(uri, 'https://arweave.net/', gateways?.arweave); diff --git a/test/resolver.test.ts b/test/resolver.test.ts index 78f8744..ca5bdde 100644 --- a/test/resolver.test.ts +++ b/test/resolver.test.ts @@ -92,13 +92,14 @@ function jsonRpcResult(result: string): JsonRpcResult { }; } +const provider = new JsonRpcProvider(INFURA_URL.toString(), 'mainnet'); +const avt = new AvatarResolver(provider, { + apiKey: { + opensea: 'api-key', + }, +}); + describe('get avatar', () => { - const provider = new JsonRpcProvider(INFURA_URL.toString(), 'mainnet'); - const avt = new AvatarResolver(provider, { - apiKey: { - opensea: 'a2b184238ee8460d9d2f58b0d3177c23', - }, - }); it('retrieves image uri with erc721 spec', async () => { mockInfuraChainId(); @@ -518,3 +519,116 @@ describe('get avatar', () => { // expect(await avt.getAvatar({ ens: 'testname.eth' })).toMatch(/^(data:image\/svg\+xml;base64,).*$/); // }); }); + +describe('get banner/header', () => { + it('retrieves image uri with custom spec', async () => { + mockInfuraChainId(); + + nockInfuraBatch( + [ + ethCallParams( + ENSRegistryWithFallback.toString(), + '0x0178b8bfb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f' + ), + chainIdParams(), + ethCallParams( + ENSRegistryWithFallback.toString(), + '0x0178b8bfb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f' + ), + ], + [ + jsonRpcResult( + '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41' + ), + jsonRpcResult('0x1'), + jsonRpcResult( + '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41' + ), + ] + ); + nockInfuraBatch( + [ + ethCallParams( + PublicResolver.toLowerCase(), + '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000' + ), + chainIdParams(), + ], + [ + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' + ), + jsonRpcResult('0x1'), + ] + ); + nockInfuraBatch( + [ + ethCallParams( + PublicResolver.toLowerCase(), + '0x3b3b57deb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f' + ), + chainIdParams(), + ], + [ + jsonRpcResult( + '0x0000000000000000000000000d59d0f7dcc0fbf0a3305ce0261863aaf7ab685c' + ), + jsonRpcResult('0x1'), + ] + ); + nockInfuraBatch( + [ + ethCallParams( + PublicResolver.toLowerCase(), + '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000' + ), + chainIdParams(), + ], + [ + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' + ), + jsonRpcResult('0x1'), + ] + ); + + nockInfuraBatch( + [ + ethCallParams( + PublicResolver.toLowerCase(), + '0x59d1d43cb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000066865616465720000000000000000000000000000000000000000000000000000' + ), + chainIdParams(), + ], + [ + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004368747470733a2f2f697066732e696f2f697066732f516d55536867666f5a5153484b3354517975546655707363385566654e6644384b77505576444255645a346e6d520000000000000000000000000000000000000000000000000000000000' + ), // Encoded HEADER URI + jsonRpcResult('0x1'), + ] + ); + + + const HEADER_URI_TANRIKULU = new URL( + 'https://ipfs.io/ipfs/QmUShgfoZQSHK3TQyuTfUpsc8UfeNfD8KwPUvDBUdZ4nmR' + ); + + /* mock head call */ + nock(HEADER_URI_TANRIKULU.origin) + .head(HEADER_URI_TANRIKULU.pathname) + .reply(200, {}, { + ...CORS_HEADERS, + 'content-type': 'image/png', + } as any); + /* mock get call */ + nock(HEADER_URI_TANRIKULU.origin) + .get(HEADER_URI_TANRIKULU.pathname) + .reply(200, {}, { + ...CORS_HEADERS, + 'content-type': 'image/png', + } as any); + expect(await avt.getHeader('tanrikulu.eth')).toEqual( + 'https://ipfs.io/ipfs/QmUShgfoZQSHK3TQyuTfUpsc8UfeNfD8KwPUvDBUdZ4nmR' + ); + }); +}); diff --git a/test/utils.test.ts b/test/utils.test.ts index bbc79c5..7cd92b4 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,8 +1,13 @@ +import { JSDOM } from 'jsdom'; +import moxios from 'moxios'; +import { fetch } from '../src/utils'; import { CID } from 'multiformats/cid'; import { + ALLOWED_IMAGE_MIMETYPES, assert, BaseError, isCID, + isImageURI, parseNFT, resolveURI, getImageURI, @@ -24,6 +29,7 @@ describe('resolve ipfs', () => { 'ipns/QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB/1.json', 'ipns/ipns.com', '/ipns/github.com', + 'https://ipfs.io/ipfs/QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB', ]; const arweaveCases = [ @@ -53,6 +59,13 @@ describe('resolve ipfs', () => { } }); + it('resolve different ipfs uri cases with custom gateway', () => { + for (let uri of ipfsCases) { + const { uri: resolvedURI } = resolveURI(uri, { ipfs: 'https://custom-ipfs.io' }); + expect(resolvedURI).toMatch(/^https:\/\/custom-ipfs.io\/?/); + } + }); + it('resolve http and base64 cases', () => { for (let uri of httpOrDataCases) { const { uri: resolvedURI } = resolveURI(uri); @@ -195,3 +208,314 @@ describe('remove refresh meta tags', () => { expect(result).toBe(sanitizedBase64svg); }); }); + +describe('getImageURI', () => { + const jsdomWindow = new JSDOM().window; + + it('should throw an error when image is not available', () => { + expect(() => getImageURI({ metadata: {}, jsdomWindow })).toThrow( + 'Image is not available' + ); + }); + + it('should handle image_url', () => { + const result = getImageURI({ + metadata: { image_url: 'https://example.com/image.png' }, + jsdomWindow, + }); + expect(result).toBe('https://example.com/image.png'); + }); + + it('should handle image_data', () => { + const svgData = + ''; + const result = getImageURI({ + metadata: { image_data: svgData }, + jsdomWindow, + }); + expect(result).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it('should sanitize SVG content', () => { + const maliciousSVG = + ''; + const result = getImageURI({ + metadata: { image: maliciousSVG }, + jsdomWindow, + }); + expect(result).not.toContain('