Skip to content

Commit f87c1af

Browse files
committed
handle images
1 parent 6fcb797 commit f87c1af

12 files changed

+280
-8
lines changed

package-lock.json

+18
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,8 @@
6767
},
6868
"lint-staged": {
6969
"*.(js|cjs|mjs)": "eslint"
70+
},
71+
"dependencies": {
72+
"@adobe/helix-shared-process-queue": "^3.1.1"
7073
}
7174
}

src/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export async function makeContext(pctx, req, env) {
5656
ctx.log = console;
5757
ctx.info = {
5858
method: req.method.toUpperCase(),
59+
extension: ctx.url.pathname.split('.').pop(),
5960
headers: Object.fromEntries(
6061
[...req.headers.entries()]
6162
.map(([k, v]) => [k.toLowerCase(), v]),

src/routes/catalog/StorageClient.js

+58
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,64 @@ export default class StorageClient {
8686
return saveResults;
8787
}
8888

89+
/**
90+
* Stores a file under `*.path` with content `*.location`
91+
*
92+
* @param {{ path: string; location: string; }[]} images
93+
*/
94+
async saveImages(images) {
95+
const {
96+
env,
97+
log,
98+
config: {
99+
org,
100+
site,
101+
storeCode,
102+
storeViewCode,
103+
},
104+
} = this.ctx;
105+
106+
const promises = images.map(async (image) => {
107+
try {
108+
const key = `${org}/${site}/${storeCode}/${storeViewCode}/images/${image.path}`;
109+
await env.CATALOG_BUCKET.put(key, '', {
110+
httpMetadata: {
111+
contentType: 'text/plain',
112+
},
113+
customMetadata: {
114+
location: image.location,
115+
},
116+
});
117+
} catch (e) {
118+
log.error(`Error saving image ${image.path} => ${image.location}:`, e);
119+
}
120+
});
121+
await Promise.all(promises);
122+
}
123+
124+
/**
125+
* @param {string} path
126+
* @returns {Promise<string>}
127+
*/
128+
async getImageLocation(path) {
129+
const {
130+
env,
131+
config: {
132+
org,
133+
site,
134+
storeCode,
135+
storeViewCode,
136+
},
137+
} = this.ctx;
138+
139+
const key = `${org}/${site}/${storeCode}/${storeViewCode}/images/${path}`;
140+
const object = await env.CATALOG_BUCKET.head(key);
141+
if (!object) {
142+
throw errorWithResponse(404, 'Image not found');
143+
}
144+
return object.customMetadata.location;
145+
}
146+
89147
/**
90148
* Handler function to process a batch of products.
91149
* @param {ProductBusEntry[]} batch - An array of products to save.

src/routes/catalog/update.js

+4-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { assertValidProduct } from '../../utils/product.js';
1414
import { errorResponse } from '../../utils/http.js';
1515
import StorageClient from './StorageClient.js';
1616
import { assertAuthorization } from '../../utils/auth.js';
17-
17+
import { extractAndReplaceImages } from '../../utils/media.js';
1818
/**
1919
* Handles a PUT request to update a product.
2020
* @param {Context} ctx - The context object containing request information and utilities.
@@ -34,14 +34,15 @@ export default async function update(ctx) {
3434

3535
await assertAuthorization(ctx);
3636

37+
const product = await extractAndReplaceImages(ctx, data);
3738
const storage = StorageClient.fromContext(ctx);
38-
const saveResults = await storage.saveProducts([data]);
39+
const saveResults = await storage.saveProducts([product]);
3940

4041
log.info({
4142
action: 'save_products',
4243
result: JSON.stringify(saveResults),
4344
timestamp: new Date().toISOString(),
4445
});
4546

46-
return new Response(JSON.stringify(saveResults), { status: 201 });
47+
return new Response(JSON.stringify({ ...saveResults, product }), { status: 201 });
4748
}

src/routes/content/handler.js

+7-3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { errorResponse } from '../../utils/http.js';
1414
import adobeCommerce from './adobe-commerce/index.js';
1515
import helixCommerce from './helix-commerce.js';
16+
import media from './media.js';
1617

1718
const ALLOWED_METHODS = ['GET'];
1819

@@ -25,13 +26,17 @@ export default async function contentHandler(ctx) {
2526
log,
2627
// url,
2728
config,
28-
info: { method },
29+
info,
2930
} = ctx;
3031

31-
if (!ALLOWED_METHODS.includes(method)) {
32+
if (!ALLOWED_METHODS.includes(info.method)) {
3233
return errorResponse(405, 'method not allowed');
3334
}
3435

36+
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(info.extension)) {
37+
return media(ctx);
38+
}
39+
3540
if (!config.pageType) {
3641
return errorResponse(404, 'invalid config for tenant site (missing pageType)');
3742
}
@@ -41,6 +46,5 @@ export default async function contentHandler(ctx) {
4146
return adobeCommerce(ctx);
4247
}
4348

44-
// TODO: handle .json requests like catalog GETs?
4549
return helixCommerce(ctx);
4650
}

src/routes/content/helix-commerce.js

-1
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@ export default async function handler(ctx) {
149149
const { config: { params } } = ctx;
150150
const { urlkey } = params;
151151
let { sku } = params;
152-
153152
if (!sku && !urlkey) {
154153
return errorResponse(404, 'missing sku or urlkey');
155154
}

src/routes/content/media.js

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import { errorResponse } from '../../utils/http.js';
14+
15+
/**
16+
* @param {Context} ctx
17+
*/
18+
export default async function handler(ctx) {
19+
const {
20+
env,
21+
url,
22+
config: { org, site },
23+
} = ctx;
24+
25+
const filename = url.pathname.split('/').pop();
26+
const key = `${org}/${site}/media/${filename}`;
27+
const resp = await env.CATALOG_BUCKET.get(key);
28+
if (!resp) {
29+
return errorResponse(404, 'File not found');
30+
}
31+
32+
return new Response(resp.body, {
33+
headers: {
34+
'Content-Type': resp.httpMetadata.contentType,
35+
},
36+
});
37+
}

src/schemas/Config.js

+6
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ const ConfigEntry = {
7171
type: 'array',
7272
items: { type: 'string' },
7373
},
74+
media: {
75+
type: 'object',
76+
properties: {
77+
prefix: { type: 'string' },
78+
},
79+
},
7480
},
7581
};
7682

src/types.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ declare global {
251251
data: any;
252252
info: {
253253
method: string;
254+
extension: string;
254255
headers: Record<string, string>;
255256
}
256257
attributes: {

src/utils/config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export async function resolveConfig(ctx, overrides = {}) {
105105
}
106106

107107
// order paths by preference
108-
const suffix = `/${ctx.url.pathname.split('/').slice(3).join('/')}`;
108+
const suffix = `/${ctx.url.pathname.split('/').slice(4).join('/')}`;
109109
const paths = findOrderedMatches(
110110
Object.keys(confMap).filter((p) => p !== 'base'),
111111
suffix,

src/utils/media.js

+144
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import processQueue from '@adobe/helix-shared-process-queue';
14+
15+
/**
16+
* @typedef {Object} ImageData
17+
* @property {string} sourceUrl
18+
* @property {ArrayBuffer} data
19+
* @property {string} hash
20+
* @property {string} mimeType
21+
* @property {number} length
22+
* @property {string} [extension]
23+
*/
24+
25+
/**
26+
* @param {string} url
27+
* @returns {string|undefined}
28+
*/
29+
const extractExtension = (url) => {
30+
const match = url.match(/\.([^.]+)$/);
31+
return match ? match[1] : undefined;
32+
};
33+
34+
/**
35+
* @param {Context} ctx
36+
* @param {string} imageUrl
37+
* @returns {Promise<ImageData | null>}
38+
*/
39+
async function fetchImage(ctx, imageUrl) {
40+
const resp = await fetch(imageUrl);
41+
if (!resp.ok) {
42+
throw new Error(`Failed to fetch image: ${resp.statusText}`);
43+
}
44+
45+
const data = await resp.arrayBuffer();
46+
const arr = await crypto.subtle.digest('SHA-1', data);
47+
const hash = Array.from(new Uint8Array(arr))
48+
.map((byte) => byte.toString(16).padStart(2, '0'))
49+
.join('');
50+
51+
return {
52+
data,
53+
sourceUrl: imageUrl,
54+
hash,
55+
mimeType: resp.headers.get('content-type'),
56+
length: data.byteLength,
57+
extension: extractExtension(imageUrl),
58+
};
59+
}
60+
61+
/**
62+
*
63+
* @param {Context} ctx
64+
* @param {ImageData} image
65+
* @returns {Promise<string>} new url
66+
*/
67+
async function uploadImage(ctx, image) {
68+
const {
69+
env,
70+
config: { org, site },
71+
} = ctx;
72+
const {
73+
data,
74+
hash,
75+
mimeType,
76+
extension,
77+
sourceUrl,
78+
} = image;
79+
80+
const filename = `media_${hash}${extension ? `.${extension}` : ''}`;
81+
const key = `${org}/${site}/media/${filename}`;
82+
const resp = await env.CATALOG_BUCKET.head(key);
83+
if (resp) {
84+
return `./${filename}`;
85+
}
86+
87+
await env.CATALOG_BUCKET.put(key, data, {
88+
httpMetadata: {
89+
contentType: mimeType,
90+
},
91+
customMetadata: {
92+
sourceLocation: sourceUrl,
93+
},
94+
});
95+
return `./${filename}`;
96+
}
97+
98+
/**
99+
* @param {Context} ctx
100+
* @param {ProductBusEntry} product
101+
* @returns {Promise<ProductBusEntry>}
102+
*/
103+
export async function extractAndReplaceImages(ctx, product) {
104+
/** @type {Map<string, Promise<string>>} */
105+
const processed = new Map();
106+
107+
/**
108+
* @param {string} url
109+
* @returns {Promise<string|undefined>} new url
110+
*/
111+
const processImage = async (url) => {
112+
if (processed.has(url)) {
113+
return processed.get(url);
114+
}
115+
116+
/** @type {(value: string) => void} */
117+
let resolve;
118+
const promise = new Promise((r) => {
119+
resolve = r;
120+
});
121+
processed.set(url, promise);
122+
123+
const img = await fetchImage(ctx, url);
124+
const newUrl = await uploadImage(ctx, img);
125+
resolve(newUrl);
126+
return newUrl;
127+
};
128+
129+
await Promise.all([
130+
processQueue([...product.images], async (image) => {
131+
const newUrl = await processImage(image.url);
132+
if (newUrl) {
133+
image.url = newUrl;
134+
}
135+
}),
136+
processQueue([...product.variants], async (variant) => {
137+
const newUrl = await processImage(variant.image);
138+
if (newUrl) {
139+
variant.image = newUrl;
140+
}
141+
}),
142+
]);
143+
return product;
144+
}

0 commit comments

Comments
 (0)