Skip to content

Commit 1777765

Browse files
authored
Merge pull request #995 from evershopcommerce/feat/file-storage-system-setting
Feat/file storage system setting
2 parents 340e8f3 + cfbddb0 commit 1777765

13 files changed

Lines changed: 83 additions & 26 deletions

File tree

packages/evershop/src/components/common/page-builder/fields/ImagePickerField.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Image as EvershopImage } from '@components/common/Image.js';
66
import { Button } from '@components/common/ui/Button.js';
77
import { ImagePlus, X } from 'lucide-react';
88
import React, { useEffect, useState } from 'react';
9+
import { normalizeImageSrc } from '../normalizeImageSrc.js';
910

1011
/**
1112
* Form field wrapper around the admin FileBrowser modal. Exposes a plain
@@ -34,11 +35,6 @@ export interface ImagePickerFieldProps {
3435
compact?: boolean;
3536
}
3637

37-
const normalize = (raw: string): string =>
38-
// Defensive — older FileBrowser builds emitted `/assets//file.jpg`. Strip
39-
// any consecutive slashes (but keep the leading one).
40-
(raw || '').replace(/\/{2,}/g, '/');
41-
4238
export function ImagePickerField({
4339
value,
4440
onChange,
@@ -80,7 +76,7 @@ export function ImagePickerField({
8076
}, [value]);
8177

8278
const handleInsert = (file: string) => {
83-
const next = normalize(file);
79+
const next = normalizeImageSrc(file);
8480
onChange(next);
8581
setOpen(false);
8682
if (onLoadDimensions && next) {

packages/evershop/src/components/common/page-builder/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export type { ContentAnchor } from './drawer/AnchorPicker.js';
4444

4545
// Form fields.
4646
export { ImagePickerField } from './fields/ImagePickerField.js';
47+
export { normalizeImageSrc } from './normalizeImageSrc.js';
4748
export {
4849
ColorSwatchField,
4950
DEFAULT_SWATCHES
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Normalize an image path picked from the file manager.
3+
*
4+
* Collapses accidental duplicate slashes in plain paths (`/assets//file.jpg`,
5+
* emitted by older FileBrowser builds for media-root files) WITHOUT touching
6+
* absolute (`https://bucket.s3….com/key`) or protocol-relative (`//cdn…/key`)
7+
* URLs: cloud file storage returns full URLs whose scheme slashes must
8+
* survive — collapsing them (`https:/…`) makes the storefront image proxy
9+
* treat the URL as a local path and 404.
10+
*/
11+
export const normalizeImageSrc = (raw: string | null | undefined): string => {
12+
const value = raw || '';
13+
if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(value)) {
14+
return value;
15+
}
16+
return value.replace(/\/{2,}/g, '/');
17+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
import { normalizeImageSrc } from '../../normalizeImageSrc.js';
3+
4+
describe('normalizeImageSrc (file-manager pick normalization)', () => {
5+
it('collapses duplicate slashes in local media paths', () => {
6+
expect(normalizeImageSrc('/assets//file.jpg')).toBe('/assets/file.jpg');
7+
expect(normalizeImageSrc('/assets///a//b.png')).toBe('/assets/a/b.png');
8+
});
9+
10+
it('keeps a normal local path untouched', () => {
11+
expect(normalizeImageSrc('/assets/catalog/1/img.jpg')).toBe(
12+
'/assets/catalog/1/img.jpg'
13+
);
14+
});
15+
16+
it('never touches absolute URLs (cloud storage returns these)', () => {
17+
const s3 =
18+
'https://my-bucket.s3.ap-southeast-2.amazonaws.com/catalog/img.jpg';
19+
expect(normalizeImageSrc(s3)).toBe(s3);
20+
const gcs = 'https://storage.googleapis.com/my-bucket/img.jpg';
21+
expect(normalizeImageSrc(gcs)).toBe(gcs);
22+
const azure = 'https://acct.blob.core.windows.net/media/img.jpg';
23+
expect(normalizeImageSrc(azure)).toBe(azure);
24+
expect(normalizeImageSrc('http://localhost:9000/bucket/img.jpg')).toBe(
25+
'http://localhost:9000/bucket/img.jpg'
26+
);
27+
});
28+
29+
it('never touches protocol-relative URLs', () => {
30+
expect(normalizeImageSrc('//cdn.example.com/img.jpg')).toBe(
31+
'//cdn.example.com/img.jpg'
32+
);
33+
});
34+
35+
it('preserves consecutive slashes inside an absolute URL path (valid S3 keys)', () => {
36+
const doubled = 'https://bucket.s3.amazonaws.com/folder//img.jpg';
37+
expect(normalizeImageSrc(doubled)).toBe(doubled);
38+
});
39+
40+
it('returns an empty string for null/undefined/empty input', () => {
41+
expect(normalizeImageSrc('')).toBe('');
42+
expect(normalizeImageSrc(null)).toBe('');
43+
expect(normalizeImageSrc(undefined)).toBe('');
44+
});
45+
});

packages/evershop/src/lib/util/secureFetch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ const REQUEST_TIMEOUT_MS = 15000;
55
/**
66
* Modules can contribute additional allowed hosts beyond the env variable — the
77
* cms module registers one that derives the active cloud file-storage host
8-
* (S3 bucket / Azure account / CDN base URL) from configuration, so operators
8+
* (S3 bucket / Azure account / GCS / CDN base URL) from configuration, so
9+
* operators
910
* don't have to duplicate it in `IMAGE_ALLOWED_HOSTS`. Providers run on every
1011
* check; one that throws is skipped so a misconfigured provider can never take
1112
* image serving down.

packages/evershop/src/modules/checkout/pages/frontStore/checkoutSuccess/Summary.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import React from 'react';
22
import './Summary.scss';
3+
import { useAppState } from '@components/common/context/app.js';
34
import { OrderSummaryItems } from '@components/frontStore/checkout/OrderSummaryItems.js';
45
import { OrderTotalSummary } from '@components/frontStore/checkout/OrderTotalSummary.js';
56
import { Order } from '@components/frontStore/customer/CustomerContext.jsx';
6-
import { useAppState } from '@components/common/context/app.js';
77
import { _ } from '@evershop/evershop/lib/locale/translate/_';
88

99
interface SummaryProps {

packages/evershop/src/modules/cms/bootstrap.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ import { getFileStorageImageHosts } from './services/storage/storageConfig.js';
3333

3434
export default (context: { command?: string } = {}) => {
3535
// The /images proxy automatically allows the host of the active cloud file
36-
// storage (S3 bucket / Azure account / CDN base URL) — without this, every
37-
// storefront image stored on S3/Azure would be refused by the allowlist.
36+
// storage (S3 bucket / Azure account / GCS / CDN base URL) — without this,
37+
// every storefront image stored in the cloud would be refused by the
38+
// allowlist.
3839
// The callback runs per check, so it sees settings saved after boot.
3940
registerAllowedImageHostsProvider(getFileStorageImageHosts);
4041

@@ -49,7 +50,7 @@ export default (context: { command?: string } = {}) => {
4950
getFileStorageImageHosts().length === 0
5051
) {
5152
warning(
52-
'IMAGE_ALLOWED_HOSTS is not set. The /images endpoint will not optimize external images — only local media/public/theme images are processed. Set IMAGE_ALLOWED_HOSTS to a comma-separated list of trusted hosts (e.g. "cdn.example.com,images.internal") to allow fetching external images. (When S3/Azure file storage is configured, its host is allowed automatically.)'
53+
'IMAGE_ALLOWED_HOSTS is not set. The /images endpoint will not optimize external images — only local media/public/theme images are processed. Set IMAGE_ALLOWED_HOSTS to a comma-separated list of trusted hosts (e.g. "cdn.example.com,images.internal") to allow fetching external images. (When S3/Azure/GCS file storage is configured, its host is allowed automatically.)'
5354
);
5455
}
5556

packages/evershop/src/modules/cms/components/BannerSetting.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CtaField } from '@components/common/page-builder/fields/CtaField.js';
88
import type { CtaValue } from '@components/common/page-builder/fields/CtaField.js';
99
import { ImagePickerField } from '@components/common/page-builder/fields/ImagePickerField.js';
1010
import { MarkdownBodyField } from '@components/common/page-builder/fields/MarkdownBodyField.js';
11+
import { normalizeImageSrc } from '@components/common/page-builder/normalizeImageSrc.js';
1112
import { LinkPicker } from '@components/common/page-builder/pickers/LinkPicker.js';
1213
import { useScopedFormContext } from '@components/common/page-builder/WidgetSettingsScope.js';
1314
import { Button } from '@components/common/ui/Button.js';
@@ -270,9 +271,7 @@ export default function BannerSetting({ bannerWidget }: BannerSettingProps) {
270271
<FileBrowser
271272
isMultiple={false}
272273
onInsert={(file) => {
273-
// Defensive normalization — older FileBrowser builds emitted
274-
// `/assets//file.jpg` for media-root images.
275-
const normalized = (file || '').replace(/\/{2,}/g, '/');
274+
const normalized = normalizeImageSrc(file);
276275
setValue('settings.src', normalized);
277276
setOpenFileBrowser(false);
278277
}}

packages/evershop/src/modules/cms/components/SlideshowSetting.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11

22
import { FileBrowser } from '@components/admin/FileBrowser.js';
3+
import { normalizeImageSrc } from '@components/common/page-builder/normalizeImageSrc.js';
34
import { LinkPicker } from '@components/common/page-builder/pickers/LinkPicker.js';
45
import {
56
useScopedFieldName,
@@ -550,12 +551,7 @@ export default function SlideshowSetting({
550551
const handleImagePick = (filePath: string) => {
551552
if (!imagePickerTarget) return;
552553
const { kind, slideIndex } = imagePickerTarget;
553-
// Defensively collapse duplicate slashes — older FileBrowser builds
554-
// emitted `/assets//file.jpg` for files at the media root, which broke
555-
// the storefront image lookup. Source bug is fixed in `browFiles.ts`,
556-
// but normalize here too so any client still on the old admin doesn't
557-
// re-poison the saved path.
558-
const normalized = (filePath || '').replace(/\/{2,}/g, '/');
554+
const normalized = normalizeImageSrc(filePath);
559555
if (kind === 'desktop') {
560556
updateSlide(slideIndex, { image: normalized, width: 0, height: 0 });
561557
loadImageDimensions(normalized, slideIndex);

packages/evershop/src/modules/cms/pages/admin/systemSetting/FileStorageSetting.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ export default function FileStorageSetting({
378378
/>
379379
<FieldHint>
380380
{_(
381-
'Optional CDN (e.g. CloudFront) serving the bucket — lets the bucket stay private. Without it, objects must be publicly readable via a bucket policy.'
381+
'Optional CDN (e.g. CloudFront) serving the bucket — lets the bucket stay private. Without it, objects must be publicly readable via a bucket policy. Example: https://cdn.mystore.com or https://d111abcdef8.cloudfront.net, a CloudFront distribution with this bucket as its origin. Files are served as <this URL>/<file path>, so the domain must point at the bucket root.'
382382
)}
383383
</FieldHint>
384384
</>
@@ -453,7 +453,7 @@ export default function FileStorageSetting({
453453
/>
454454
<FieldHint>
455455
{_(
456-
'Optional CDN (e.g. Azure CDN / Front Door) serving the container — lets the container stay private.'
456+
'Optional CDN (e.g. Azure CDN / Front Door) serving the container — lets the container stay private. Files are served as <this URL>/<file path> with no container segment added, so either set the CDN origin path to /<container> (example: https://mystore.azureedge.net) or include the container here (example: https://mystore.azureedge.net/media).'
457457
)}
458458
</FieldHint>
459459
</>
@@ -524,7 +524,7 @@ export default function FileStorageSetting({
524524
/>
525525
<FieldHint>
526526
{_(
527-
'Optional CDN (e.g. Cloud CDN) serving the bucket — lets the bucket stay private. Without it, objects must be publicly readable: grant "allUsers" the Storage Object Viewer role on the bucket (blocked when Public Access Prevention is enforced on your organization).'
527+
'Optional CDN (e.g. Cloud CDN) serving the bucket — lets the bucket stay private. Without it, objects must be publicly readable: grant "allUsers" the Storage Object Viewer role on the bucket (blocked when Public Access Prevention is enforced on your organization). Example: https://cdn.mystore.com, a load balancer domain with this bucket as its backend bucket.'
528528
)}
529529
</FieldHint>
530530
</>

0 commit comments

Comments
 (0)