diff --git a/.github/workflows/test.js.yml b/.github/workflows/test.js.yml index d98fbd1..19e71bf 100644 --- a/.github/workflows/test.js.yml +++ b/.github/workflows/test.js.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - node-version: [16.x, 18.x] + node-version: [18.x, 19.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: diff --git a/app_template.yaml b/app_template.yaml index 2ed6ff7..c5ce7d2 100644 --- a/app_template.yaml +++ b/app_template.yaml @@ -1,4 +1,4 @@ -runtime: nodejs16 # or another supported version +runtime: nodejs18 # or another supported version instance_class: F2 diff --git a/package.json b/package.json index 2846839..bd8ed72 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@adraffy/ens-normalize": "^1.9.0", - "@ensdomains/ens-avatar": "^0.2.5", - "@ensdomains/ensjs": "^3.0.0-alpha.61", + "@ensdomains/ens-avatar": "^0.3.2", + "@ensdomains/ensjs": "^3.0.0-alpha.67", "@types/lodash": "^4.14.170", "btoa": "^1.2.1", "canvas": "^2.11.2", @@ -80,6 +80,6 @@ ] }, "volta": { - "node": "16.15.0" + "node": "18.15.0" } } diff --git a/src/assets/DejaVuSans-Bold.ttf b/src/assets/DejaVuSans-Bold.ttf deleted file mode 100644 index 6d65fa7..0000000 Binary files a/src/assets/DejaVuSans-Bold.ttf and /dev/null differ diff --git a/src/config.ts b/src/config.ts index 7c9c9df..bac8d16 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,7 @@ const INAMEWRAPPER = process.env.INAMEWRAPPER || '0xd82c42d8'; const IPFS_GATEWAY = process.env.IPFS_GATEWAY || 'https://ipfs.io'; const INFURA_API_KEY = process.env.INFURA_API_KEY || ''; +const OPENSEA_API_KEY = process.env.OPENSEA_API_KEY || ''; const NODE_PROVIDER = process.env.NODE_PROVIDER || 'geth'; const NODE_PROVIDER_URL = process.env.NODE_PROVIDER_URL || 'http://localhost:8545'; @@ -32,7 +33,7 @@ const ETH_REGISTRY_ABI = [ ]; // response timeout: 1 min -const RESPONSE_TIMEOUT = 10 * 1000; +const RESPONSE_TIMEOUT = 15 * 1000; export { ADDRESS_ETH_REGISTRAR, @@ -44,6 +45,7 @@ export { INAMEWRAPPER, IPFS_GATEWAY, INFURA_API_KEY, + OPENSEA_API_KEY, REDIS_URL, NODE_PROVIDER, NODE_PROVIDER_URL, diff --git a/src/controller/ensImage.ts b/src/controller/ensImage.ts index 738e0e6..8de06b9 100644 --- a/src/controller/ensImage.ts +++ b/src/controller/ensImage.ts @@ -81,10 +81,12 @@ export async function ensImage(req: Request, res: Response) { error instanceof NamehashMismatchError || error instanceof UnsupportedNetwork ) { - res.status(errCode).json({ - message: error.message, - }); - return; + if (!res.headersSent) { + res.status(errCode).json({ + message: error.message, + }); + return; + } } /* #swagger.responses[404] = { diff --git a/src/controller/ensMetadata.ts b/src/controller/ensMetadata.ts index bbcb823..a7d6c88 100644 --- a/src/controller/ensMetadata.ts +++ b/src/controller/ensMetadata.ts @@ -73,10 +73,12 @@ export async function ensMetadata(req: Request, res: Response) { error instanceof NamehashMismatchError || error instanceof UnsupportedNetwork ) { - res.status(errCode).json({ - message: error.message, - }); - return; + if (!res.headersSent) { + res.status(errCode).json({ + message: error.message, + }); + return; + } } try { diff --git a/src/index.ts b/src/index.ts index 06d268d..f710719 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,14 +37,14 @@ app.use( 'https://unpkg.com/redoc@latest/bundles/redoc.standalone.js' ], imgSrc: ['*', 'data:'], - styleSrc: ["'self'", "'unsafe-inline'"], - fontSrc: ["'self'", 'data:'], + styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], + fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'], connectSrc: ['*', 'data:'], objectSrc: ["'none'"], frameAncestors: ["'none'"], frameSrc: ["'none'"], childSrc: ["'none'"], - workerSrc: ["'none'"], + workerSrc: ['blob:'], baseUri: ["'none'"], formAction: ["'none'"], upgradeInsecureRequests: [], diff --git a/src/service/avatar.ts b/src/service/avatar.ts index 1e667fa..f1bbe39 100644 --- a/src/service/avatar.ts +++ b/src/service/avatar.ts @@ -8,7 +8,10 @@ import { RetrieveURIFailed, TextRecordNotFound, } from '../base'; -import { IPFS_GATEWAY } from '../config'; +import { + IPFS_GATEWAY, + OPENSEA_API_KEY +} from '../config'; import { abortableFetch } from '../utils/abortableFetch'; const window = new JSDOM('').window; @@ -46,7 +49,13 @@ export class AvatarMetadata { avtResolver: AvatarResolver; constructor(provider: ethers.providers.BaseProvider, uri: string) { this.defaultProvider = provider; - this.avtResolver = new AvatarResolver(provider, { ipfs: IPFS_GATEWAY }); + this.avtResolver = new AvatarResolver(provider, + { + ipfs: IPFS_GATEWAY, + apiKey: { opensea: OPENSEA_API_KEY }, + urlDenyList: [ 'metadata.ens.domains' ] + } + ); this.uri = uri; } diff --git a/src/service/metadata.test.ts b/src/service/metadata.test.ts new file mode 100644 index 0000000..e1d7731 --- /dev/null +++ b/src/service/metadata.test.ts @@ -0,0 +1,28 @@ +import avaTest, { ExecutionContext, TestFn } from 'ava'; +import { TestContext } from '../../mock/interface'; +import { Metadata } from './metadata'; +import { Version } from '../base'; + +const test = avaTest as TestFn; + +test('should compute metadata correctly', async (t: ExecutionContext) => { + const nickMetadataObj = { + name: 'nick.eth', + description: 'nick.eth, an ENS name.', + created_date: 1571924851000, + tokenId: '0x5d5727cb0fb76e4944eafb88ec9a3cf0b3c9025a4b2f947729137c5d7f84f68f', + version: Version.v1, + last_request_date: Date.now() + }; + const testMetadata = new Metadata(nickMetadataObj); + + t.is(testMetadata.name, nickMetadataObj.name); + t.is(testMetadata.description, nickMetadataObj.description); + t.is(testMetadata.attributes[0].value, nickMetadataObj.created_date * 1000); + t.is(testMetadata.version, Version.v1); + }); + +test('should return correct font size', async (t: ExecutionContext) => { + const textSize = Metadata._getFontSize('nick.eth'); + t.is(textSize, 32); +}); diff --git a/src/service/metadata.ts b/src/service/metadata.ts index 741e993..1d6bd2d 100644 --- a/src/service/metadata.ts +++ b/src/service/metadata.ts @@ -1,4 +1,9 @@ import {ens_normalize} from '@adraffy/ens-normalize'; +import { + CanvasRenderingContext2D, + createCanvas, + registerFont +} from 'canvas'; import { Version } from '../base'; import { CANVAS_FONT_PATH, @@ -9,19 +14,13 @@ import base64EncodeUnicode from '../utils/base64encode'; import { isASCII, findCharacterSet } from '../utils/characterSet'; import { getCodePointLength, getSegmentLength } from '../utils/charLength'; -// no ts declaration files -const { createCanvas, registerFont } = require('canvas'); - -try { - registerFont(CANVAS_FONT_PATH, { family: 'Satoshi' }); - registerFont(CANVAS_EMOJI_FONT_PATH, { family: 'Noto Color Emoji' }); -} catch(error) { - console.warn("Font registeration is failed."); - console.warn(error); +interface Attribute { + trait_type: string, + display_type: string, + value: any } - export interface MetadataInit { name : string; description? : string; @@ -36,7 +35,7 @@ export interface MetadataInit { export interface Metadata { name : string; description : string; - attributes : object[]; + attributes : Attribute[]; name_length? : number; segment_length? : number; image : string; @@ -51,20 +50,24 @@ export interface Metadata { export class Metadata { static MAX_CHAR = 60; + static ctx: CanvasRenderingContext2D; + constructor({ name, description, created_date, tokenId, version, - last_request_date + last_request_date, }: MetadataInit) { const label = this.getLabel(name); this.is_normalized = this._checkNormalized(name); this.name = this.formatName(name, tokenId); this.description = this.formatDescription(name, description); this.attributes = this.initializeAttributes(created_date, label); - this.url = this.is_normalized ? `https://app.ens.domains/name/${name}` : null; + this.url = this.is_normalized + ? `https://app.ens.domains/name/${name}` + : null; this.last_request_date = last_request_date; this.version = version; } @@ -84,19 +87,23 @@ export class Metadata { formatDescription(name: string, description?: string) { const baseDescription = description || `${this.name}, an ENS name.`; - const normalizedNote = !this.is_normalized ? ` (${name} is not in normalized form)` : ''; + const normalizedNote = !this.is_normalized + ? ` (${name} is not in normalized form)` + : ''; const asciiWarning = this.generateAsciiWarning(this.getLabel(name)); return `${baseDescription}${normalizedNote}${asciiWarning}`; } generateAsciiWarning(label: string) { if (!isASCII(label)) { - return ' ⚠️ ATTENTION: This name contains non-ASCII characters as shown above. ' + + return ( + ' ⚠️ ATTENTION: This name contains non-ASCII characters as shown above. ' + 'Please be aware that there are characters that look identical or very ' + 'similar to English letters, especially characters from Cyrillic and Greek. ' + 'Also, traditional Chinese characters can look identical or very similar to ' + 'simplified variants. For more information: ' + - 'https://en.wikipedia.org/wiki/IDN_homograph_attack'; + 'https://en.wikipedia.org/wiki/IDN_homograph_attack' + ); } return ''; } @@ -129,7 +136,7 @@ export class Metadata { ]; } - addAttribute(attribute: object) { + addAttribute(attribute: Attribute) { this.attributes.push(attribute); } @@ -152,7 +159,12 @@ export class Metadata { const { domain, subdomainText } = this.processSubdomain(name, isSubdomain); const { processedDomain, domainFontSize } = this.processDomain(domain); - const svg = this._generateByVersion(domainFontSize, subdomainText, isSubdomain, processedDomain); + const svg = this._generateByVersion( + domainFontSize, + subdomainText, + isSubdomain, + processedDomain + ); try { this.setImage('data:image/svg+xml;base64,' + base64EncodeUnicode(svg)); @@ -165,7 +177,7 @@ export class Metadata { processSubdomain(name: string, isSubdomain: boolean) { let subdomainText; let domain = name; - + if (isSubdomain && !name.includes('...')) { const labels = name.split('.'); let subdomain = labels.slice(0, labels.length - 2).join('.') + '.'; @@ -241,11 +253,20 @@ export class Metadata { } static _getFontSize(name: string): number { - const canvas = createCanvas(270, 270, 'svg'); - const ctx = canvas.getContext('2d'); - ctx.font = - '20px Satoshi, Noto Color Emoji, Apple Color Emoji, sans-serif'; - const fontMetrics = ctx.measureText(name); + if (!this.ctx) { + try { + registerFont(CANVAS_FONT_PATH, { family: 'Satoshi' }); + registerFont(CANVAS_EMOJI_FONT_PATH, { family: 'Noto Color Emoji' }); + } catch (error) { + console.warn('Font registration is failed.'); + console.warn(error); + } + const canvas = createCanvas(270, 270, 'svg'); + this.ctx = canvas.getContext('2d'); + this.ctx.font = + '20px Satoshi, Noto Color Emoji, Apple Color Emoji, sans-serif'; + } + const fontMetrics = this.ctx.measureText(name); const fontSize = Math.floor(20 * (200 / fontMetrics.width)); return fontSize < 34 ? fontSize : 32; } diff --git a/src/utils/blockRecursiveCalls.ts b/src/utils/blockRecursiveCalls.ts index 2fd65d7..370fb22 100644 --- a/src/utils/blockRecursiveCalls.ts +++ b/src/utils/blockRecursiveCalls.ts @@ -8,14 +8,19 @@ export function blockRecursiveCalls( const requestOrigin = req.get('origin') || req.get('referer'); if (requestOrigin) { - const parsedRequestOrigin = new URL(requestOrigin); + try { + const parsedRequestOrigin = new URL(requestOrigin); - if ( - parsedRequestOrigin.hostname === req.hostname && - parsedRequestOrigin.protocol.includes('http') - ) { - console.warn(`Recursive call detected`); - res.status(403).json({ message: 'Recursive calls are not allowed.' }); + if ( + parsedRequestOrigin.hostname === req.hostname && + parsedRequestOrigin.protocol.includes('http') + ) { + console.warn(`Recursive call detected`); + res.status(403).json({ message: 'Recursive calls are not allowed.' }); + return; + } + } catch (error) { + console.warn('Error parsing URL', error); } } next(); diff --git a/src/utils/rateLimiter.ts b/src/utils/rateLimiter.ts index 9d0ffe3..c6b4b86 100644 --- a/src/utils/rateLimiter.ts +++ b/src/utils/rateLimiter.ts @@ -18,8 +18,8 @@ if (REDIS_URL) { const opts = { storeClient: redisClient, - points: 100, // Number of total points - duration: 5, // Per second(s) + points: 10, // Number of total points + duration: 2, // Per second(s) execEvenly: false, // Do not delay actions evenly blockDuration: 0, // Do not block the caller if consumed more than points keyPrefix: 'ensrl', // Assign unique keys for each limiters with different purposes diff --git a/yarn.lock b/yarn.lock index faecedf..64dc6a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -454,10 +454,10 @@ dns-packet "^5.2.1" ethers "^5.0.30" -"@ensdomains/ens-avatar@^0.2.5": - version "0.2.5" - resolved "https://registry.yarnpkg.com/@ensdomains/ens-avatar/-/ens-avatar-0.2.5.tgz#f89e53c4e1fb706b27c504f5bbeb7851f3af13af" - integrity sha512-2Tko+KgXQvsYyX8BZ8zapLpzxMZ9oLEzUsyUFgWfM0O83SvURSEqHXW+GyEGkgNRg2ZPte5qnq0+FnthxXZQRA== +"@ensdomains/ens-avatar@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@ensdomains/ens-avatar/-/ens-avatar-0.3.2.tgz#f182085a8d865b6048ca3819b4be3c14e79a5c97" + integrity sha512-4PkDxfj4KUC0BoX9lEqonrgq3KJDz1mcznh8u4hapeLnEWgLqlPrHrA84ZxWhH61iE8E7qZSEUN+NHJp1W6QrA== dependencies: "@ethersproject/contracts" "^5.7.0" "@ethersproject/providers" "^5.7.0" @@ -470,10 +470,10 @@ multiformats "^9.6.2" url-join "^4.0.1" -"@ensdomains/ensjs@^3.0.0-alpha.61": - version "3.0.0-alpha.61" - resolved "https://registry.yarnpkg.com/@ensdomains/ensjs/-/ensjs-3.0.0-alpha.61.tgz#a9b7386633625acce996e528627b396e1b93349e" - integrity sha512-ThHj9c2wCsl4At6arwEwxmQwTJLCgeW1f9MHBrcM8OH/8BpZXfUuO1UTxHeZqy6gGPb58eWQpGMDW8uI7iGoNg== +"@ensdomains/ensjs@^3.0.0-alpha.67": + version "3.0.0-alpha.67" + resolved "https://registry.yarnpkg.com/@ensdomains/ensjs/-/ensjs-3.0.0-alpha.67.tgz#fa69ba63d39e7bdbb2291dd1e9329b6f3e3d8197" + integrity sha512-fV5KPIfxcWjkKnQ1iicndJRl8ADQDgoPVvEw02q6on6HTx5QNvUqc/+vccv3dPqIas7yTRShXpT87hweDxPE+g== dependencies: "@adraffy/ens-normalize" "1.9.0" "@ensdomains/address-encoder" "^0.2.18" @@ -1895,13 +1895,13 @@ caniuse-lite@^1.0.30001449: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001472.tgz#3f484885f2a2986c019dc416e65d9d62798cdd64" integrity sha512-xWC/0+hHHQgj3/vrKYY0AAzeIUgr7L9wlELIcAvZdDUHlhL/kNxMdnQLOSOQfP8R51ZzPhmHdyMkI0MMpmxCfg== -canvas@^2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.8.0.tgz#f99ca7f25e6e26686661ffa4fec1239bbef74461" - integrity sha512-gLTi17X8WY9Cf5GZ2Yns8T5lfBOcGgFehDFb+JQwDqdOoBOcECS9ZWMEAqMSVcMYwXD659J8NyzjRY/2aE+C2Q== +canvas@^2.11.2: + version "2.11.2" + resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.11.2.tgz#553d87b1e0228c7ac0fc72887c3adbac4abbd860" + integrity sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw== dependencies: "@mapbox/node-pre-gyp" "^1.0.0" - nan "^2.14.0" + nan "^2.17.0" simple-get "^3.0.3" cbor@^8.1.0: @@ -4639,11 +4639,16 @@ multihashes@^4.0.1: uint8arrays "^3.0.0" varint "^5.0.2" -nan@^2.12.1, nan@^2.14.0: +nan@^2.12.1: version "2.15.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== +nan@^2.17.0: + version "2.17.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" + integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== + nano-base32@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/nano-base32/-/nano-base32-1.0.1.tgz#ba548c879efcfb90da1c4d9e097db4a46c9255ef"