Skip to content

Commit 0fafe1c

Browse files
authored
Merge pull request #227 from hirosystems/master
merge master into develop
2 parents 0196466 + 1aa1603 commit 0fafe1c

File tree

8 files changed

+235
-86
lines changed

8 files changed

+235
-86
lines changed

CHANGELOG.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,63 @@
1+
## [0.7.0](https://github.com/hirosystems/token-metadata-api/compare/v0.6.3...v0.7.0) (2024-05-13)
2+
3+
4+
### Features
5+
6+
* add admin rpc to reprocess token image cache ([#205](https://github.com/hirosystems/token-metadata-api/issues/205)) ([2fdcb33](https://github.com/hirosystems/token-metadata-api/commit/2fdcb33908062770da4e334810fd04bb378db66a))
7+
* update ts client with image thumbnails ([#206](https://github.com/hirosystems/token-metadata-api/issues/206)) ([c24cb56](https://github.com/hirosystems/token-metadata-api/commit/c24cb56b854123b252eb2e2616bb8589c5b36f0f))
8+
* upload token images to gcs ([#204](https://github.com/hirosystems/token-metadata-api/issues/204)) ([1cec219](https://github.com/hirosystems/token-metadata-api/commit/1cec2195a2b3df9e9c85f0152732594caa8c8c51))
9+
10+
11+
### Bug Fixes
12+
13+
* get access token properly ([a6b98c5](https://github.com/hirosystems/token-metadata-api/commit/a6b98c5099a9de1d88e74eed66dece1c4c157422))
14+
* get gcs auth token dynamically for image cache ([#210](https://github.com/hirosystems/token-metadata-api/issues/210)) ([8434b22](https://github.com/hirosystems/token-metadata-api/commit/8434b229f6d38e6799bf84bd6f1eb4de106996bb))
15+
* image cache agent arg types ([5826628](https://github.com/hirosystems/token-metadata-api/commit/5826628a329225fbf697a092dc201fc74fb96d43))
16+
* improve image cache error handling ([#214](https://github.com/hirosystems/token-metadata-api/issues/214)) ([115a745](https://github.com/hirosystems/token-metadata-api/commit/115a745c268e7bb8115a488ca111e8b46cefed62))
17+
* reuse gcs token and validate image cache script errors ([#213](https://github.com/hirosystems/token-metadata-api/issues/213)) ([5e1af5c](https://github.com/hirosystems/token-metadata-api/commit/5e1af5c28cd0b1313f78a59b015669ceb07e5738))
18+
19+
## [0.7.0-beta.5](https://github.com/hirosystems/token-metadata-api/compare/v0.7.0-beta.4...v0.7.0-beta.5) (2024-05-13)
20+
21+
22+
### Bug Fixes
23+
24+
* improve image cache error handling ([#214](https://github.com/hirosystems/token-metadata-api/issues/214)) ([115a745](https://github.com/hirosystems/token-metadata-api/commit/115a745c268e7bb8115a488ca111e8b46cefed62))
25+
26+
## [0.7.0-beta.4](https://github.com/hirosystems/token-metadata-api/compare/v0.7.0-beta.3...v0.7.0-beta.4) (2024-05-08)
27+
28+
29+
### Bug Fixes
30+
31+
* get access token properly ([a6b98c5](https://github.com/hirosystems/token-metadata-api/commit/a6b98c5099a9de1d88e74eed66dece1c4c157422))
32+
33+
## [0.7.0-beta.3](https://github.com/hirosystems/token-metadata-api/compare/v0.7.0-beta.2...v0.7.0-beta.3) (2024-05-07)
34+
35+
36+
### Bug Fixes
37+
38+
* image cache agent arg types ([5826628](https://github.com/hirosystems/token-metadata-api/commit/5826628a329225fbf697a092dc201fc74fb96d43))
39+
40+
## [0.7.0-beta.2](https://github.com/hirosystems/token-metadata-api/compare/v0.7.0-beta.1...v0.7.0-beta.2) (2024-05-07)
41+
42+
43+
### Bug Fixes
44+
45+
* reuse gcs token and validate image cache script errors ([#213](https://github.com/hirosystems/token-metadata-api/issues/213)) ([5e1af5c](https://github.com/hirosystems/token-metadata-api/commit/5e1af5c28cd0b1313f78a59b015669ceb07e5738))
46+
47+
## [0.7.0-beta.1](https://github.com/hirosystems/token-metadata-api/compare/v0.6.3...v0.7.0-beta.1) (2024-05-07)
48+
49+
50+
### Features
51+
52+
* add admin rpc to reprocess token image cache ([#205](https://github.com/hirosystems/token-metadata-api/issues/205)) ([2fdcb33](https://github.com/hirosystems/token-metadata-api/commit/2fdcb33908062770da4e334810fd04bb378db66a))
53+
* update ts client with image thumbnails ([#206](https://github.com/hirosystems/token-metadata-api/issues/206)) ([c24cb56](https://github.com/hirosystems/token-metadata-api/commit/c24cb56b854123b252eb2e2616bb8589c5b36f0f))
54+
* upload token images to gcs ([#204](https://github.com/hirosystems/token-metadata-api/issues/204)) ([1cec219](https://github.com/hirosystems/token-metadata-api/commit/1cec2195a2b3df9e9c85f0152732594caa8c8c51))
55+
56+
57+
### Bug Fixes
58+
59+
* get gcs auth token dynamically for image cache ([#210](https://github.com/hirosystems/token-metadata-api/issues/210)) ([8434b22](https://github.com/hirosystems/token-metadata-api/commit/8434b229f6d38e6799bf84bd6f1eb4de106996bb))
60+
161
## [0.6.3](https://github.com/hirosystems/token-metadata-api/compare/v0.6.2...v0.6.3) (2024-05-07)
262

363

config/image-cache.js

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ const IMAGE_RESIZE_WIDTH = parseInt(process.env['IMAGE_CACHE_RESIZE_WIDTH'] ?? '
3434
const GCS_BUCKET_NAME = process.env['IMAGE_CACHE_GCS_BUCKET_NAME'];
3535
const GCS_OBJECT_NAME_PREFIX = process.env['IMAGE_CACHE_GCS_OBJECT_NAME_PREFIX'];
3636
const CDN_BASE_PATH = process.env['IMAGE_CACHE_CDN_BASE_PATH'];
37+
const TIMEOUT = parseInt(process.env['METADATA_FETCH_TIMEOUT_MS'] ?? '30000');
38+
const MAX_REDIRECTIONS = parseInt(process.env['METADATA_FETCH_MAX_REDIRECTIONS'] ?? '0');
39+
const MAX_RESPONSE_SIZE = parseInt(process.env['IMAGE_CACHE_MAX_BYTE_SIZE'] ?? '-1');
3740

3841
async function getGcsAuthToken() {
3942
const envToken = process.env['IMAGE_CACHE_GCS_AUTH_TOKEN'];
@@ -44,49 +47,46 @@ async function getGcsAuthToken() {
4447
{
4548
method: 'GET',
4649
headers: { 'Metadata-Flavor': 'Google' },
50+
throwOnError: true,
4751
}
4852
);
49-
if (response.data?.access_token) return response.data.access_token;
50-
throw new Error(`GCS token not found`);
53+
const json = await response.body.json();
54+
// Cache the token so we can reuse it for other images.
55+
process.env['IMAGE_CACHE_GCS_AUTH_TOKEN'] = json.access_token;
56+
return json.access_token;
5157
} catch (error) {
52-
throw new Error(`Error fetching GCS access token: ${error.message}`);
58+
throw new Error(`GCS access token error: ${error}`);
5359
}
5460
}
5561

5662
async function upload(stream, name, authToken) {
57-
try {
58-
const response = await request(
59-
`https://storage.googleapis.com/upload/storage/v1/b/${GCS_BUCKET_NAME}/o?uploadType=media&name=${GCS_OBJECT_NAME_PREFIX}${name}`,
60-
{
61-
method: 'POST',
62-
body: stream,
63-
headers: { 'Content-Type': 'image/png', Authorization: `Bearer ${authToken}` },
64-
}
65-
);
66-
if (response.statusCode !== 200) throw new Error(`GCS error: ${response.statusCode}`);
67-
return `${CDN_BASE_PATH}${name}`;
68-
} catch (error) {
69-
throw new Error(`Error uploading ${name}: ${error.message}`);
70-
}
63+
await request(
64+
`https://storage.googleapis.com/upload/storage/v1/b/${GCS_BUCKET_NAME}/o?uploadType=media&name=${GCS_OBJECT_NAME_PREFIX}${name}`,
65+
{
66+
method: 'POST',
67+
body: stream,
68+
headers: { 'Content-Type': 'image/png', Authorization: `Bearer ${authToken}` },
69+
throwOnError: true,
70+
}
71+
);
72+
return `${CDN_BASE_PATH}${name}`;
7173
}
7274

7375
fetch(
7476
IMAGE_URL,
7577
{
7678
dispatcher: new Agent({
77-
headersTimeout: process.env['METADATA_FETCH_TIMEOUT_MS'],
78-
bodyTimeout: process.env['METADATA_FETCH_TIMEOUT_MS'],
79-
maxRedirections: process.env['METADATA_FETCH_MAX_REDIRECTIONS'],
80-
maxResponseSize: process.env['IMAGE_CACHE_MAX_BYTE_SIZE'],
79+
headersTimeout: TIMEOUT,
80+
bodyTimeout: TIMEOUT,
81+
maxRedirections: MAX_REDIRECTIONS,
82+
maxResponseSize: MAX_RESPONSE_SIZE,
83+
throwOnError: true,
8184
connect: {
8285
rejectUnauthorized: false, // Ignore SSL cert errors.
8386
},
8487
}),
8588
},
86-
({ statusCode, body }) => {
87-
if (statusCode !== 200) throw new Error(`Failed to fetch image: ${statusCode}`);
88-
return body;
89-
}
89+
({ body }) => body
9090
)
9191
.then(async response => {
9292
const imageReadStream = Readable.fromWeb(response.body);
@@ -99,15 +99,49 @@ fetch(
9999
passThrough.pipe(fullSizeTransform);
100100
passThrough.pipe(thumbnailTransform);
101101

102-
const authToken = await getGcsAuthToken();
103-
const results = await Promise.all([
104-
upload(fullSizeTransform, `${CONTRACT_PRINCIPAL}/${TOKEN_NUMBER}.png`, authToken),
105-
upload(thumbnailTransform, `${CONTRACT_PRINCIPAL}/${TOKEN_NUMBER}-thumb.png`, authToken),
106-
]);
107-
108-
// The API will read these strings as CDN URLs.
109-
for (const result of results) console.log(result);
102+
let didRetryUnauthorized = false;
103+
while (true) {
104+
const authToken = await getGcsAuthToken();
105+
try {
106+
const results = await Promise.all([
107+
upload(fullSizeTransform, `${CONTRACT_PRINCIPAL}/${TOKEN_NUMBER}.png`, authToken),
108+
upload(thumbnailTransform, `${CONTRACT_PRINCIPAL}/${TOKEN_NUMBER}-thumb.png`, authToken),
109+
]);
110+
for (const r of results) console.log(r);
111+
break;
112+
} catch (error) {
113+
if (
114+
!didRetryUnauthorized &&
115+
error.cause &&
116+
error.cause.code == 'UND_ERR_RESPONSE_STATUS_CODE' &&
117+
(error.cause.statusCode === 401 || error.cause.statusCode === 403)
118+
) {
119+
// GCS token is probably expired. Force a token refresh before trying again.
120+
process.env['IMAGE_CACHE_GCS_AUTH_TOKEN'] = undefined;
121+
didRetryUnauthorized = true;
122+
} else throw error;
123+
}
124+
}
110125
})
111126
.catch(error => {
112-
throw new Error(`Error processing image: ${error.message}`);
127+
console.error(error);
128+
// TODO: Handle `Input buffer contains unsupported image format` error from sharp when the image
129+
// is actually a video or another media file.
130+
let exitCode = 1;
131+
if (
132+
error.cause &&
133+
(error.cause.code == 'UND_ERR_HEADERS_TIMEOUT' ||
134+
error.cause.code == 'UND_ERR_BODY_TIMEOUT' ||
135+
error.cause.code == 'UND_ERR_CONNECT_TIMEOUT' ||
136+
error.cause.code == 'ECONNRESET')
137+
) {
138+
exitCode = 2;
139+
} else if (
140+
error.cause &&
141+
error.cause.code == 'UND_ERR_RESPONSE_STATUS_CODE' &&
142+
error.cause.statusCode === 429
143+
) {
144+
exitCode = 3;
145+
}
146+
process.exit(exitCode);
113147
});

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { StacksNodeRpcClient } from '../../stacks-node/stacks-node-rpc-client';
1616
import { StacksNodeClarityError, TooManyRequestsHttpError } from '../../util/errors';
1717
import {
1818
fetchAllMetadataLocalesFromBaseUri,
19-
getFetchableUrl,
19+
getFetchableDecentralizedStorageUrl,
2020
getTokenSpecificUri,
2121
} from '../../util/metadata-helpers';
2222
import { RetryableJobError } from '../errors';
@@ -214,7 +214,7 @@ export class ProcessTokenJob extends Job {
214214
return;
215215
}
216216
// Before we return the uri, check if its fetchable hostname is not already rate limited.
217-
const fetchable = getFetchableUrl(uri);
217+
const fetchable = getFetchableDecentralizedStorageUrl(uri);
218218
const rateLimitedHost = await this.db.getRateLimitedHost({ hostname: fetchable.hostname });
219219
if (rateLimitedHost) {
220220
const retryAfter = Date.parse(rateLimitedHost.retry_after);

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

Lines changed: 79 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,108 @@
11
import * as child_process from 'child_process';
22
import { ENV } from '../../env';
3-
import { MetadataParseError } from './errors';
4-
import { parseDataUrl, getFetchableUrl } from './metadata-helpers';
3+
import { MetadataParseError, MetadataTimeoutError, TooManyRequestsHttpError } from './errors';
4+
import { parseDataUrl, getFetchableDecentralizedStorageUrl } from './metadata-helpers';
55
import { logger } from '@hirosystems/api-toolkit';
66
import { PgStore } from '../../pg/pg-store';
7+
import { errors } from 'undici';
8+
import { RetryableJobError } from '../queue/errors';
79

810
/**
9-
* If an external image processor script is configured, then it will process the given image URL for
10-
* the purpose of caching on a CDN (or whatever else it may be created to do). The script is
11-
* expected to return a new URL for the image. If the script is not configured, then the original
12-
* URL is returned immediately. If a data-uri is passed, it is also immediately returned without
13-
* being passed to the script.
11+
* If an external image processor script is configured in the `METADATA_IMAGE_CACHE_PROCESSOR` ENV
12+
* var, this function will process the given image URL for the purpose of caching on a CDN (or
13+
* whatever else it may be created to do). The script is expected to return a new URL for the image
14+
* via `stdout`, with an optional 2nd line with another URL for a thumbnail version of the same
15+
* cached image. If the script is not configured, then the original URL is returned immediately. If
16+
* a data-uri is passed, it is also immediately returned without being passed to the script.
17+
*
18+
* The Image Cache script must return a status code of `0` to mark a successful cache. Other code
19+
* returns available are:
20+
* * `1`: A generic error occurred. Cache should not be retried.
21+
* * `2`: Image fetch timed out before caching was possible. Should be retried.
22+
* * `3`: Image fetch failed due to rate limits from the remote server. Should be retried.
1423
*/
15-
export async function processImageUrl(
24+
export async function processImageCache(
1625
imgUrl: string,
1726
contractPrincipal: string,
1827
tokenNumber: bigint
1928
): Promise<string[]> {
2029
const imageCacheProcessor = ENV.METADATA_IMAGE_CACHE_PROCESSOR;
21-
if (!imageCacheProcessor) {
22-
return [imgUrl];
23-
}
24-
if (imgUrl.startsWith('data:')) {
25-
return [imgUrl];
30+
if (!imageCacheProcessor || imgUrl.startsWith('data:')) return [imgUrl];
31+
logger.info(`ImageCache processing token ${contractPrincipal} (${tokenNumber}) at ${imgUrl}`);
32+
const { code, stdout, stderr } = await callImageCacheScript(
33+
imageCacheProcessor,
34+
imgUrl,
35+
contractPrincipal,
36+
tokenNumber
37+
);
38+
switch (code) {
39+
case 0:
40+
try {
41+
const urls = stdout
42+
.trim()
43+
.split('\n')
44+
.map(r => new URL(r).toString());
45+
logger.info(urls, `ImageCache processed token ${contractPrincipal} (${tokenNumber})`);
46+
return urls;
47+
} catch (error) {
48+
// The script returned a code `0` but the results are invalid. This could happen because of
49+
// an unknown script error so we should mark it as retryable.
50+
throw new RetryableJobError(
51+
`ImageCache unknown error`,
52+
new Error(`Invalid cached url for ${imgUrl}: ${stdout}, stderr: ${stderr}`)
53+
);
54+
}
55+
case 2:
56+
throw new RetryableJobError(`ImageCache fetch timed out`, new MetadataTimeoutError(imgUrl));
57+
case 3:
58+
throw new RetryableJobError(
59+
`ImageCache fetch rate limited`,
60+
new TooManyRequestsHttpError(new URL(imgUrl), new errors.ResponseStatusCodeError())
61+
);
62+
default:
63+
throw new Error(`ImageCache script error (code ${code}): ${stderr}`);
2664
}
27-
logger.info(`ImageCache processing image for token ${contractPrincipal} (${tokenNumber})...`);
65+
}
66+
67+
async function callImageCacheScript(
68+
imageCacheProcessor: string,
69+
imgUrl: string,
70+
contractPrincipal: string,
71+
tokenNumber: bigint
72+
): Promise<{
73+
code: number;
74+
stdout: string;
75+
stderr: string;
76+
}> {
2877
const repoDir = process.cwd();
29-
const { code, stdout, stderr } = await new Promise<{
78+
return await new Promise<{
3079
code: number;
3180
stdout: string;
3281
stderr: string;
33-
}>((resolve, reject) => {
82+
}>(resolve => {
3483
const cp = child_process.spawn(
3584
imageCacheProcessor,
3685
[imgUrl, contractPrincipal, tokenNumber.toString()],
3786
{ cwd: repoDir }
3887
);
88+
let code = 0;
3989
let stdout = '';
4090
let stderr = '';
4191
cp.stdout.on('data', data => (stdout += data));
4292
cp.stderr.on('data', data => (stderr += data));
43-
cp.on('close', code => resolve({ code: code ?? 0, stdout, stderr }));
44-
cp.on('error', error => reject(error));
93+
cp.on('close', _ => resolve({ code, stdout, stderr }));
94+
cp.on('exit', processCode => {
95+
code = processCode ?? 0;
96+
});
4597
});
46-
if (code !== 0 && stderr) {
47-
logger.warn(stderr, `ImageCache error`);
48-
}
49-
const result = stdout.trim().split('\n');
50-
try {
51-
return result.map(r => new URL(r).toString());
52-
} catch (error) {
53-
throw new Error(
54-
`Image processing script returned an invalid url for ${imgUrl}: ${result}, stderr: ${stderr}`
55-
);
56-
}
5798
}
5899

59-
export function getImageUrl(uri: string): string {
100+
/**
101+
* Converts a raw image URI from metadata into a fetchable URL.
102+
* @param uri - Original image URI
103+
* @returns Normalized URL string
104+
*/
105+
export function normalizeImageUri(uri: string): string {
60106
// Support images embedded in a Data URL
61107
if (uri.startsWith('data:')) {
62108
const dataUrl = parseDataUrl(uri);
@@ -68,7 +114,7 @@ export function getImageUrl(uri: string): string {
68114
}
69115
return uri;
70116
}
71-
const fetchableUrl = getFetchableUrl(uri);
117+
const fetchableUrl = getFetchableDecentralizedStorageUrl(uri);
72118
return fetchableUrl.toString();
73119
}
74120

@@ -81,8 +127,8 @@ export async function reprocessTokenImageCache(
81127
const imageUris = await db.getTokenImageUris(contractPrincipal, tokenIds);
82128
for (const token of imageUris) {
83129
try {
84-
const [cached, thumbnail] = await processImageUrl(
85-
getFetchableUrl(token.image).toString(),
130+
const [cached, thumbnail] = await processImageCache(
131+
getFetchableDecentralizedStorageUrl(token.image).toString(),
86132
contractPrincipal,
87133
BigInt(token.token_number)
88134
);

0 commit comments

Comments
 (0)