Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add header/banner spec #50

Merged
merged 5 commits into from
Sep 26, 2024
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
8 changes: 6 additions & 2 deletions example/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand Down
14 changes: 12 additions & 2 deletions example/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -30,6 +33,13 @@ avt
},
jsdomWindow: jsdom,
});
console.log(avatar);
console.log('avatar: ', avatar);
})
.catch(console.log);

avt
.getHeader(ensName)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you actually need this? Looks like it's just for debugging purpose doing console.log

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's just a simple debug example for nodejs.

.then(header => {
console.log('header: ', header);
})
.catch(console.log);
41 changes: 29 additions & 12 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,28 @@
<div class="container">
<div id="avatars"></div>
<div class="queryContainer">
<h2>Query ENS Avatar</h2>
<div>
<img class="queryImage" id="queryImage" width="300" height="300" src="./example/ens.png" />
<div id="headerContainer">
<h2>Query ENS Avatar</h2>
<div>
<input
class="queryInput"
id="queryInput"
type="text"
placeholder="vitalik.eth"
autofocus
autocomplete="off"
<img
class="queryImage"
id="queryImage"
width="300"
height="300"
src="./example/ens.png"
/>
<div class="hint">
(type ENS name and hit the enter button)
<div>
<input
class="queryInput"
id="queryInput"
type="text"
placeholder="vitalik.eth"
autofocus
autocomplete="off"
/>
<div class="hint">
(type ENS name and hit the enter button)
</div>
</div>
</div>
</div>
Expand All @@ -49,6 +57,12 @@ <h2>Query ENS Avatar</h2>
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;
Expand Down Expand Up @@ -76,6 +90,9 @@ <h2>Query ENS Avatar</h2>
}
.queryImage {
object-fit: contain;
border: 5px solid white;
border-radius: 5px;
outline: #d6d6d6 solid 2px;
}
.queryInput {
width: 19rem;
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"main": "dist/index.js",
"module": "dist/index.esm.js",
Expand Down Expand Up @@ -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",
Expand Down
46 changes: 36 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string | null>;
getHeader(ens: string, data: HeaderRequestOpts): Promise<string | null>;
getMetadata(ens: string): Promise<any | null>;
}

Expand All @@ -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),
Expand All @@ -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)
Expand Down Expand Up @@ -95,7 +102,26 @@ export class AvatarResolver implements AvatarResolver {
ens: string,
data?: AvatarRequestOpts
): Promise<string | null> {
const metadata = await this.getMetadata(ens);
return this._getMedia(ens, 'avatar', data);
}

async getHeader(
ens: string,
data?: HeaderRequestOpts
): Promise<string | null> {
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,
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export interface AvatarRequestOpts {
jsdomWindow?: any;
}

export interface HeaderRequestOpts {
jsdomWindow?: any;
mediaKey?: 'header' | 'banner';
}

export type Gateways = {
ipfs?: string;
arweave?: string;
Expand Down
28 changes: 18 additions & 10 deletions src/utils/isImageURI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { fetch } from './fetch';

export const ALLOWED_IMAGE_MIMETYPES = [
'application/octet-stream',
'image/jpeg',
'image/png',
'image/gif',
Expand All @@ -16,6 +17,14 @@
'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 {
Expand Down Expand Up @@ -56,19 +65,18 @@
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';

Check warning on line 73 in src/utils/isImageURI.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node 18.x and ubuntu-latest

Expected an error object to be thrown

Check warning on line 73 in src/utils/isImageURI.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node 18.x and windows-latest

Expected an error object to be thrown

Check warning on line 73 in src/utils/isImageURI.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node 18.x and macOS-latest

Expected an error object to be thrown

Check warning on line 73 in src/utils/isImageURI.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node 20.x and ubuntu-latest

Expected an error object to be thrown

Check warning on line 73 in src/utils/isImageURI.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node 20.x and windows-latest

Expected an error object to be thrown

Check warning on line 73 in src/utils/isImageURI.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node 20.x and macOS-latest

Expected an error object to be thrown
}
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
Expand Down
64 changes: 63 additions & 1 deletion src/utils/resolveURI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,75 @@ 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/';
const networkRegex = /(?<protocol>ipfs:\/|ipns:\/|ar:\/)?(?<root>\/)?(?<subpath>ipfs\/|ipns\/)?(?<target>[\w\-.]+)(?<subtarget>\/.*)?/;
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 {
Expand All @@ -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);
Expand Down
Loading
Loading