Skip to content

Commit 3767c7f

Browse files
authored
Merge pull request #320 from hirosystems/develop
release to master
2 parents 2f30312 + d04012f commit 3767c7f

File tree

7 files changed

+134
-63
lines changed

7 files changed

+134
-63
lines changed

package-lock.json

Lines changed: 5 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,6 @@
7676
"postgres": "^3.3.1",
7777
"sharp": "^0.33.3",
7878
"stacks-encoding-native-js": "^1.0.0",
79-
"undici": "^5.12.0"
79+
"undici": "^5.29.0"
8080
}
8181
}

src/env.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,26 @@ const schema = Type.Object({
130130
METADATA_FETCH_MAX_REDIRECTIONS: Type.Number({ default: 5 }),
131131

132132
/**
133-
* Base URL for a public gateway which will provide access to all IPFS resources. Defaults to
134-
* `https://cloudflare-ipfs.com`.
133+
* Base URL for a public gateway which will provide access to all IPFS resources when metadata
134+
* URLs use the `ipfs:` or `ipns:` protocol schemes. Defaults to `https://cloudflare-ipfs.com`.
135135
*/
136136
PUBLIC_GATEWAY_IPFS: Type.String({ default: 'https://cloudflare-ipfs.com' }),
137137
/**
138-
* Base URL for a public gateway which will provide access to all Arweave resources. Defaults to
138+
* Extra header key to add to the request when fetching metadata from the configured IPFS gateway,
139+
* for example if authentication is required. Must be in the form 'Header: Value'.
140+
*/
141+
PUBLIC_GATEWAY_IPFS_EXTRA_HEADER: Type.Optional(Type.String()),
142+
/**
143+
* List of public IPFS gateways that will be replaced with the value of `PUBLIC_GATEWAY_IPFS`
144+
* whenever a metadata URL has these gateways hard coded in `http:` or `https:` URLs.
145+
*/
146+
PUBLIC_GATEWAY_IPFS_REPLACED: Type.String({
147+
default: 'ipfs.io,dweb.link,gateway.pinata.cloud,cloudflare-ipfs.com,infura-ipfs.io',
148+
}),
149+
150+
/**
151+
* Base URL for a public gateway which will provide access to all Arweave resources when metadata
152+
* URLs use the `ar:` protocol scheme. Defaults to
139153
* `https://arweave.net`.
140154
*/
141155
PUBLIC_GATEWAY_ARWEAVE: Type.String({ default: 'https://arweave.net' }),

src/token-processor/images/image-cache.ts

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ENV } from '../../env';
2-
import { parseDataUrl, getFetchableDecentralizedStorageUrl } from '../util/metadata-helpers';
2+
import { parseDataUrl, getFetchableMetadataUrl } from '../util/metadata-helpers';
33
import { logger } from '@hirosystems/api-toolkit';
44
import { PgStore } from '../../pg/pg-store';
55
import { Readable } from 'node:stream';
@@ -16,7 +16,6 @@ import {
1616
} from '../util/errors';
1717
import { pipeline } from 'node:stream/promises';
1818
import { Storage } from '@google-cloud/storage';
19-
import { RetryableJobError } from '../queue/errors';
2019

2120
/** Saves an image provided via a `data:` uri string to disk for processing. */
2221
function convertDataImage(uri: string, tmpPath: string): string {
@@ -33,10 +32,15 @@ function convertDataImage(uri: string, tmpPath: string): string {
3332
return filePath;
3433
}
3534

36-
async function downloadImage(imgUrl: string, tmpPath: string): Promise<string> {
35+
async function downloadImage(
36+
imgUrl: string,
37+
tmpPath: string,
38+
headers?: Record<string, string>
39+
): Promise<string> {
3740
return new Promise((resolve, reject) => {
3841
const filePath = `${tmpPath}/image`;
3942
fetch(imgUrl, {
43+
headers,
4044
dispatcher: new Agent({
4145
headersTimeout: ENV.METADATA_FETCH_TIMEOUT_MS,
4246
bodyTimeout: ENV.METADATA_FETCH_TIMEOUT_MS,
@@ -109,22 +113,23 @@ async function transformImage(filePath: string, resize: boolean = false): Promis
109113
* For a list of configuration options, see `env.ts`.
110114
*/
111115
export async function processImageCache(
112-
imgUrl: string,
116+
rawImgUrl: string,
113117
contractPrincipal: string,
114118
tokenNumber: bigint
115119
): Promise<string[]> {
116-
logger.info(`ImageCache processing token ${contractPrincipal} (${tokenNumber}) at ${imgUrl}`);
120+
logger.info(`ImageCache processing token ${contractPrincipal} (${tokenNumber}) at ${rawImgUrl}`);
117121
try {
118122
const gcs = new Storage();
119123
const gcsBucket = ENV.IMAGE_CACHE_GCS_BUCKET_NAME as string;
120124

121125
const tmpPath = `tmp/${contractPrincipal}_${tokenNumber}`;
122126
fs.mkdirSync(tmpPath, { recursive: true });
123127
let original: string;
124-
if (imgUrl.startsWith('data:')) {
125-
original = convertDataImage(imgUrl, tmpPath);
128+
if (rawImgUrl.startsWith('data:')) {
129+
original = convertDataImage(rawImgUrl, tmpPath);
126130
} else {
127-
original = await downloadImage(imgUrl, tmpPath);
131+
const { url: httpUrl, fetchHeaders } = getFetchableMetadataUrl(rawImgUrl);
132+
original = await downloadImage(httpUrl.toString(), tmpPath, fetchHeaders);
128133
}
129134

130135
const image1 = await transformImage(original);
@@ -152,10 +157,10 @@ export async function processImageCache(
152157
typeError.cause instanceof errors.BodyTimeoutError ||
153158
typeError.cause instanceof errors.ConnectTimeoutError
154159
) {
155-
throw new ImageTimeoutError(new URL(imgUrl));
160+
throw new ImageTimeoutError(new URL(rawImgUrl));
156161
}
157162
if (typeError.cause instanceof errors.ResponseExceededMaxSizeError) {
158-
throw new ImageSizeExceededError(`ImageCache image too large: ${imgUrl}`);
163+
throw new ImageSizeExceededError(`ImageCache image too large: ${rawImgUrl}`);
159164
}
160165
if ((typeError.cause as any).toString().includes('ECONNRESET')) {
161166
throw new ImageHttpError(`ImageCache server connection interrupted`, typeError);
@@ -165,17 +170,6 @@ export async function processImageCache(
165170
}
166171
}
167172

168-
/**
169-
* Converts a raw image URI from metadata into a fetchable URL.
170-
* @param uri - Original image URI
171-
* @returns Normalized URL string
172-
*/
173-
export function normalizeImageUri(uri: string): string {
174-
if (uri.startsWith('data:')) return uri;
175-
const fetchableUrl = getFetchableDecentralizedStorageUrl(uri);
176-
return fetchableUrl.toString();
177-
}
178-
179173
export async function reprocessTokenImageCache(
180174
db: PgStore,
181175
contractPrincipal: string,
@@ -186,7 +180,7 @@ export async function reprocessTokenImageCache(
186180
for (const token of imageUris) {
187181
try {
188182
const [cached, thumbnail] = await processImageCache(
189-
getFetchableDecentralizedStorageUrl(token.image).toString(),
183+
getFetchableMetadataUrl(token.image).toString(),
190184
contractPrincipal,
191185
BigInt(token.token_number)
192186
);

src/token-processor/queue/job/process-token-job.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { StacksNodeRpcClient } from '../../stacks-node/stacks-node-rpc-client';
1212
import { SmartContractClarityError, TooManyRequestsHttpError } from '../../util/errors';
1313
import {
1414
fetchAllMetadataLocalesFromBaseUri,
15-
getFetchableDecentralizedStorageUrl,
15+
getFetchableMetadataUrl,
1616
getTokenSpecificUri,
1717
} from '../../util/metadata-helpers';
1818
import { RetryableJobError } from '../errors';
@@ -194,7 +194,7 @@ export class ProcessTokenJob extends Job {
194194
return;
195195
}
196196
// Before we return the uri, check if its fetchable hostname is not already rate limited.
197-
const fetchable = getFetchableDecentralizedStorageUrl(uri);
197+
const fetchable = getFetchableMetadataUrl(uri).url;
198198
const rateLimitedHost = await this.db.getRateLimitedHost({ hostname: fetchable.hostname });
199199
if (rateLimitedHost) {
200200
const retryAfter = Date.parse(rateLimitedHost.retry_after);

src/token-processor/util/metadata-helpers.ts

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
UndiciCauseTypeError,
2020
} from './errors';
2121
import { RetryableJobError } from '../queue/errors';
22-
import { normalizeImageUri, processImageCache } from '../images/image-cache';
22+
import { processImageCache } from '../images/image-cache';
2323
import {
2424
RawMetadataLocale,
2525
RawMetadataLocalizationCType,
@@ -40,6 +40,22 @@ const METADATA_FETCH_HTTP_AGENT = new Agent({
4040
},
4141
});
4242

43+
/**
44+
* A metadata URL that was analyzed and normalized into a fetchable URL. Specifies the URL, the
45+
* gateway type, and any extra headers that may be required to fetch the metadata.
46+
*/
47+
export type FetchableMetadataUrl = {
48+
url: URL;
49+
gateway: 'ipfs' | 'arweave' | null;
50+
fetchHeaders?: Record<string, string>;
51+
};
52+
53+
/**
54+
* List of public IPFS gateways that will be replaced with the value of `ENV.PUBLIC_GATEWAY_IPFS`
55+
* whenever a metadata URL has these gateways hard coded in `http:` or `https:` URLs.
56+
*/
57+
const PUBLIC_GATEWAY_IPFS_REPLACED = ENV.PUBLIC_GATEWAY_IPFS_REPLACED.split(',');
58+
4359
/**
4460
* Fetches all the localized metadata JSONs for a token. First, it downloads the default metadata
4561
* JSON and parses it looking for other localizations. If those are found, each of them is then
@@ -172,9 +188,8 @@ async function parseMetadataForInsertion(
172188
let cachedImage: string | undefined;
173189
let cachedThumbnailImage: string | undefined;
174190
if (image && typeof image === 'string' && ENV.IMAGE_CACHE_PROCESSOR_ENABLED) {
175-
const normalizedUrl = normalizeImageUri(image);
176191
[cachedImage, cachedThumbnailImage] = await processImageCache(
177-
normalizedUrl,
192+
image,
178193
contract.principal,
179194
token.token_number
180195
);
@@ -243,14 +258,16 @@ async function parseMetadataForInsertion(
243258
export async function fetchMetadata(
244259
httpUrl: URL,
245260
contract_principal: string,
246-
token_number: bigint
261+
token_number: bigint,
262+
headers?: Record<string, string>
247263
): Promise<string | undefined> {
248264
const url = httpUrl.toString();
249265
try {
250266
logger.info(`MetadataFetch for ${contract_principal}#${token_number} from ${url}`);
251267
const result = await request(url, {
252268
method: 'GET',
253269
throwOnError: true,
270+
headers,
254271
dispatcher:
255272
// Disable during tests so we can inject a global mock agent.
256273
process.env.NODE_ENV === 'test' ? undefined : METADATA_FETCH_HTTP_AGENT,
@@ -304,10 +321,13 @@ export async function getMetadataFromUri(
304321
return parseJsonMetadata(token_uri, content);
305322
}
306323

307-
// Support HTTP/S URLs otherwise
308-
const httpUrl = getFetchableDecentralizedStorageUrl(token_uri);
324+
// Support HTTP/S URLs otherwise.
325+
// Transform the URL to use a public gateway if necessary.
326+
const { url: httpUrl, fetchHeaders } = getFetchableMetadataUrl(token_uri);
309327
const urlStr = httpUrl.toString();
310-
const content = await fetchMetadata(httpUrl, contract_principal, token_number);
328+
329+
// Fetch the metadata.
330+
const content = await fetchMetadata(httpUrl, contract_principal, token_number, fetchHeaders);
311331
return parseJsonMetadata(urlStr, content);
312332
}
313333

@@ -332,31 +352,55 @@ function parseJsonMetadata(url: string, content?: string): RawMetadata {
332352

333353
/**
334354
* Helper method for creating http/s url for supported protocols.
335-
* * URLs with `http` or `https` protocols are returned as-is.
355+
* * URLs with `http` or `https` protocols are returned as-is. But if they are public IPFS gateways,
356+
* they are replaced with `ENV.PUBLIC_GATEWAY_IPFS`.
336357
* * URLs with `ipfs` or `ipns` protocols are returned with as an `https` url using a public IPFS
337358
* gateway.
338359
* * URLs with `ar` protocols are returned as `https` using a public Arweave gateway.
339360
* @param uri - URL to convert
340361
* @returns Fetchable URL
341362
*/
342-
export function getFetchableDecentralizedStorageUrl(uri: string): URL {
363+
export function getFetchableMetadataUrl(uri: string): FetchableMetadataUrl {
343364
try {
344365
const parsedUri = new URL(uri);
345-
if (parsedUri.protocol === 'http:' || parsedUri.protocol === 'https:') return parsedUri;
346-
if (parsedUri.protocol === 'ipfs:') {
366+
const result: FetchableMetadataUrl = {
367+
url: parsedUri,
368+
gateway: null,
369+
fetchHeaders: undefined,
370+
};
371+
if (parsedUri.protocol === 'http:' || parsedUri.protocol === 'https:') {
372+
// If this is a known public IPFS gateway, replace it with `ENV.PUBLIC_GATEWAY_IPFS`.
373+
if (PUBLIC_GATEWAY_IPFS_REPLACED.includes(parsedUri.hostname)) {
374+
result.url = new URL(`${ENV.PUBLIC_GATEWAY_IPFS}${parsedUri.pathname}`);
375+
result.gateway = 'ipfs';
376+
} else {
377+
result.url = parsedUri;
378+
}
379+
} else if (parsedUri.protocol === 'ipfs:') {
347380
const host = parsedUri.host === 'ipfs' ? 'ipfs' : `ipfs/${parsedUri.host}`;
348-
return new URL(`${ENV.PUBLIC_GATEWAY_IPFS}/${host}${parsedUri.pathname}`);
349-
}
350-
if (parsedUri.protocol === 'ipns:') {
351-
return new URL(`${ENV.PUBLIC_GATEWAY_IPFS}/${parsedUri.host}${parsedUri.pathname}`);
381+
result.url = new URL(`${ENV.PUBLIC_GATEWAY_IPFS}/${host}${parsedUri.pathname}`);
382+
result.gateway = 'ipfs';
383+
} else if (parsedUri.protocol === 'ipns:') {
384+
result.url = new URL(`${ENV.PUBLIC_GATEWAY_IPFS}/${parsedUri.host}${parsedUri.pathname}`);
385+
result.gateway = 'ipfs';
386+
} else if (parsedUri.protocol === 'ar:') {
387+
result.url = new URL(`${ENV.PUBLIC_GATEWAY_ARWEAVE}/${parsedUri.host}${parsedUri.pathname}`);
388+
result.gateway = 'arweave';
389+
} else {
390+
throw new MetadataParseError(`Unsupported uri protocol: ${uri}`);
352391
}
353-
if (parsedUri.protocol === 'ar:') {
354-
return new URL(`${ENV.PUBLIC_GATEWAY_ARWEAVE}/${parsedUri.host}${parsedUri.pathname}`);
392+
393+
if (result.gateway === 'ipfs' && ENV.PUBLIC_GATEWAY_IPFS_EXTRA_HEADER) {
394+
const [key, value] = ENV.PUBLIC_GATEWAY_IPFS_EXTRA_HEADER.split(':');
395+
result.fetchHeaders = {
396+
[key.trim()]: value.trim(),
397+
};
355398
}
399+
400+
return result;
356401
} catch (error) {
357402
throw new MetadataParseError(`Invalid uri: ${uri}`);
358403
}
359-
throw new MetadataParseError(`Unsupported uri protocol: ${uri}`);
360404
}
361405

362406
export function parseDataUrl(

tests/token-queue/metadata-helpers.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { MockAgent, setGlobalDispatcher } from 'undici';
22
import { ENV } from '../../src/env';
33
import { MetadataHttpError, MetadataParseError } from '../../src/token-processor/util/errors';
44
import {
5-
getFetchableDecentralizedStorageUrl,
5+
getFetchableMetadataUrl,
66
getMetadataFromUri,
77
getTokenSpecificUri,
88
fetchMetadata,
@@ -209,19 +209,46 @@ describe('Metadata Helpers', () => {
209209
test('get fetchable URLs', () => {
210210
ENV.PUBLIC_GATEWAY_IPFS = 'https://cloudflare-ipfs.com';
211211
ENV.PUBLIC_GATEWAY_ARWEAVE = 'https://arweave.net';
212+
ENV.PUBLIC_GATEWAY_IPFS_EXTRA_HEADER = 'Authorization: Bearer 1234567890';
213+
212214
const arweave = 'ar://II4z2ziYyqG7-kWDa98lWGfjxRdYOx9Zdld9P_I_kzE/9731.json';
213-
expect(getFetchableDecentralizedStorageUrl(arweave).toString()).toBe(
215+
const fetch1 = getFetchableMetadataUrl(arweave);
216+
expect(fetch1.url.toString()).toBe(
214217
'https://arweave.net/II4z2ziYyqG7-kWDa98lWGfjxRdYOx9Zdld9P_I_kzE/9731.json'
215218
);
219+
expect(fetch1.gateway).toBe('arweave');
220+
expect(fetch1.fetchHeaders).toBeUndefined();
221+
216222
const ipfs =
217223
'ipfs://ipfs/bafybeifwoqwdhs5djtx6vopvuwfcdrqeuecayp5wzpzjylxycejnhtrhgu/vague_art_paintings/vague_art_paintings_6_metadata.json';
218-
expect(getFetchableDecentralizedStorageUrl(ipfs).toString()).toBe(
224+
const fetch2 = getFetchableMetadataUrl(ipfs);
225+
expect(fetch2.url.toString()).toBe(
219226
'https://cloudflare-ipfs.com/ipfs/bafybeifwoqwdhs5djtx6vopvuwfcdrqeuecayp5wzpzjylxycejnhtrhgu/vague_art_paintings/vague_art_paintings_6_metadata.json'
220227
);
228+
expect(fetch2.gateway).toBe('ipfs');
229+
expect(fetch2.fetchHeaders).toEqual({ Authorization: 'Bearer 1234567890' });
230+
221231
const ipfs2 = 'ipfs://QmYCnfeseno5cLpC75rmy6LQhsNYQCJabiuwqNUXMaA3Fo/1145.png';
222-
expect(getFetchableDecentralizedStorageUrl(ipfs2).toString()).toBe(
232+
const fetch3 = getFetchableMetadataUrl(ipfs2);
233+
expect(fetch3.url.toString()).toBe(
223234
'https://cloudflare-ipfs.com/ipfs/QmYCnfeseno5cLpC75rmy6LQhsNYQCJabiuwqNUXMaA3Fo/1145.png'
224235
);
236+
expect(fetch3.gateway).toBe('ipfs');
237+
expect(fetch3.fetchHeaders).toEqual({ Authorization: 'Bearer 1234567890' });
238+
239+
const ipfs3 = 'https://ipfs.io/ipfs/QmYCnfeseno5cLpC75rmy6LQhsNYQCJabiuwqNUXMaA3Fo/1145.png';
240+
const fetch4 = getFetchableMetadataUrl(ipfs3);
241+
expect(fetch4.url.toString()).toBe(
242+
'https://cloudflare-ipfs.com/ipfs/QmYCnfeseno5cLpC75rmy6LQhsNYQCJabiuwqNUXMaA3Fo/1145.png'
243+
);
244+
expect(fetch4.gateway).toBe('ipfs');
245+
expect(fetch4.fetchHeaders).toEqual({ Authorization: 'Bearer 1234567890' });
246+
247+
const http = 'https://test.io/1.json';
248+
const fetch5 = getFetchableMetadataUrl(http);
249+
expect(fetch5.url.toString()).toBe(http);
250+
expect(fetch5.gateway).toBeNull();
251+
expect(fetch5.fetchHeaders).toBeUndefined();
225252
});
226253

227254
test('replace URI string tokens', () => {

0 commit comments

Comments
 (0)