fix(core,admin_basic): S3 스토리지 드라이버 실동작 결함 수정 및 S3 호환 스토리지 연결 지원

공개 제보 — 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이
동작하지 않던 결함의 전면 수정.

- S3 어댑터(league/flysystem-aws-s3-v3)·predis 를 코어 기본 의존성으로 포함
 — 어댑터 부재 즉사, phpredis 확장 없는 서버의 redis 선택 전면 다운 차단
 (부트 시 확장 부재 감지 → predis 자동 폴백)
- storage_driver=s3 저장 시 코어 첨부 업로드 디스크를 s3 로 전환
 (ATTACHMENT_DISK env 명시가 항상 우선, 기존 행은 저장 당시 disk 로 서빙)
- 첨부·템플릿 레이아웃 첨부 서빙을 행 disk 를 따르는 스토리지 스트림으로 교체
 — 로컬 절대 경로 전제 fileResponse 는 S3 행에서 filemtime stat 500
 (streamedFileResponse: 행 메타 기반 ETag/304/Cache-Control)
- S3 호환 스토리지(R2/MinIO/NCP) 연결 지원: 엔드포인트 URL·path-style 설정
 신설, 리전 목록 선택 → 자유 입력 전환, 연결 테스트를 실제 저장 경로와
 동일 설정(endpoint/path-style)으로 정렬
- 사용 불능 드라이버(어댑터·PHP 확장 부재)의 저장/테스트 요청을 사유와 함께
 422 로 차단하는 서버 게이트 신설 (DriverRegistryService 능력 판정)
- 웹소켓 연결 테스트에 서버(백엔드 발송용) endpoint 검사 추가 — 클라이언트만
 검사해 테스트 성공 + 실제 발송 실패가 가능하던 비대칭 해소
- env 빈 값(`KEY=`) 함정 정규화: AWS_URL/AWS_ENDPOINT/ATTACHMENT_DISK 빈 문자열을
 미설정으로 취급 (config 정규화 + 예시 파일 주석 처리)
- 플러그인 드라이버 폴백의 log 카테고리 죽은 키(logging.default) 정정 및
 websocket 유령 설정 키 제거
- 실 AWS S3 종단 검증 완료 (설정 저장 → 업로드 S3 실저장 → 서빙 200/304)
This commit is contained in:
HeuJung
2026-08-13 15:15:19 +09:00
parent c128a6ea3a
commit ea58c606a2
56 changed files with 2899 additions and 243 deletions
@@ -6,6 +6,17 @@
## [1.0.5] - 2026-08-12
### Added
- 환경설정 > 드라이버 > 파일 스토리지에 S3 호환 스토리지 설정이 추가되었습니다 — 엔드포인트 URL 입력칸과 Path-style 주소 토글로 Cloudflare R2, MinIO 등도 연결할 수 있습니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
### Changed
- S3 리전이 5개 목록 선택에서 자유 입력으로 바뀌었습니다 — 새로 생기는 AWS 리전이나 R2 의 `auto` 값을 그대로 입력할 수 있습니다.
- S3 URL 항목의 이름과 설명을 "공개 URL(CDN)" 용도로 명확히 했습니다 — API 요청 주소는 새로 생긴 엔드포인트 URL 칸에 입력합니다.
- 웹소켓 연결 테스트가 서버 발송용 주소까지 함께 검사하도록 테스트 요청 항목이 확장되었습니다.
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다 (S3 엔드포인트·리전 자유 입력 등 신규 설정 키가 코어 7.0.7 부터 지원됩니다).
### Fixed
- 표 하단 합계 영역의 값이 계산되지 않고 표현식 원문 그대로 표시되던 문제를 다시 수정했습니다. 1.0.4 에 수록 예정이던 수정이 빌드 산출물에 반영되지 않은 채 배포되어, 주문 상세의 합계 행에서 수량·적립금·배송비 자리에 값 대신 알 수 없는 문자열이 보였습니다. (#98 @hwaryeon1234 님께서 제보해주셨습니다.)
@@ -0,0 +1,333 @@
/**
* @file admin-settings-drivers-s3-fields.test.tsx
* @description 드라이버 탭 S3 블록 렌더링 테스트 (공개 #99 / 내부 #563)
*
* 실제 partial(_tab_drivers.json)의 s3_settings 블록을 그대로 렌더하여 검증한다:
* - s3 선택 시 리전이 자유 입력 Input 으로 렌더 (Select 아님 — A4)
* - s3_endpoint Input · s3_use_path_style Toggle 렌더 (A3)
* - 연결 테스트 payload 에 s3_endpoint/s3_use_path_style 포함, s3_url 의도적 제외 (A10)
* - websocket 테스트 payload 에 server 3필드 포함 (A6)
* - 저장 payload 가 활성 탭 폼을 키 열거 없이 통째로 전송 (신규 키 자동 포함)
*/
import React from 'react';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
const partialPath = resolve(
__dirname,
'../../layouts/partials/admin_settings/_tab_drivers.json'
);
const driversPartial = JSON.parse(readFileSync(partialPath, 'utf-8'));
const settingsLayoutPath = resolve(__dirname, '../../layouts/admin_settings.json');
const settingsLayout = JSON.parse(readFileSync(settingsLayoutPath, 'utf-8'));
// ---------------------------------------------------------------------------
// 테스트용 컴포넌트
// ---------------------------------------------------------------------------
const TestDiv: React.FC<any> = ({ className, children, 'data-testid': testId }) => (
<div className={className} data-testid={testId}>{children}</div>
);
const TestInput: React.FC<any> = ({ className, disabled, type, name, placeholder }) => (
<input
className={className}
disabled={disabled}
type={type}
name={name}
placeholder={placeholder}
data-testid={name}
/>
);
const TestSelect: React.FC<any> = ({ name }) => (
<select name={name} data-testid={`select-${name}`} />
);
const TestToggle: React.FC<any> = ({ name, disabled }) => (
<input type="checkbox" role="switch" name={name} disabled={disabled} data-testid={`toggle-${name}`} />
);
const TestButton: React.FC<any> = ({ className, disabled, type, children, text }) => (
<button className={className} disabled={disabled} type={type} data-testid="test-button">
{children || text}
</button>
);
const TestSpan: React.FC<any> = ({ className, children, text }) => (
<span className={className}>{children || text}</span>
);
const TestP: React.FC<any> = ({ className, children, text }) => (
<p className={className}>{children || text}</p>
);
const TestLabel: React.FC<any> = ({ className, children, text }) => (
<label className={className}>{children || text}</label>
);
const TestIcon: React.FC<any> = ({ name, className }) => (
<i className={className} data-icon={name} />
);
const TestFragment: React.FC<any> = ({ children }) => <>{children}</>;
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
Select: { component: TestSelect, metadata: { name: 'Select', type: 'composite' } },
Toggle: { component: TestToggle, metadata: { name: 'Toggle', type: 'composite' } },
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } },
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
};
return registry;
}
// ---------------------------------------------------------------------------
// partial JSON 탐색 헬퍼
// ---------------------------------------------------------------------------
/** id 로 노드를 깊이 우선 탐색합니다. */
function findNodeById(node: any, id: string): any {
if (!node || typeof node !== 'object') return null;
if (node.id === id) return node;
for (const child of node.children ?? []) {
const found = findNodeById(child, id);
if (found) return found;
}
return null;
}
/** 루트 배열에서 id 노드를 찾습니다. */
function findInPartial(id: string): any {
for (const root of driversPartial.components ?? [driversPartial]) {
const found = findNodeById(root, id);
if (found) return found;
}
return null;
}
/** 노드 서브트리에서 조건에 맞는 모든 노드를 수집합니다. */
function collectNodes(node: any, predicate: (n: any) => boolean, acc: any[] = []): any[] {
if (!node || typeof node !== 'object') return acc;
if (predicate(node)) acc.push(node);
for (const child of node.children ?? []) collectNodes(child, predicate, acc);
return acc;
}
/** 레이아웃 JSON 어디에 있든(children/actions/modals 등) apiCall 액션을 전부 수집합니다. */
function collectApiCallsDeep(node: any, acc: any[] = []): any[] {
if (!node || typeof node !== 'object') return acc;
if (node.handler === 'apiCall') acc.push(node);
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
value.forEach((item) => collectApiCallsDeep(item, acc));
} else if (value && typeof value === 'object') {
collectApiCallsDeep(value, acc);
}
}
return acc;
}
/** 노드 서브트리에서 apiCall 액션들을 수집합니다 (actions 배열 내 중첩 sequence 포함). */
function collectApiCalls(node: any, acc: any[] = []): any[] {
if (!node || typeof node !== 'object') return acc;
if (Array.isArray(node.actions)) {
const walk = (action: any) => {
if (!action || typeof action !== 'object') return;
if (action.handler === 'apiCall') acc.push(action);
for (const nested of action.actions ?? []) walk(nested);
};
node.actions.forEach(walk);
}
for (const child of node.children ?? []) collectApiCalls(child, acc);
return acc;
}
// ---------------------------------------------------------------------------
// 렌더링 테스트
// ---------------------------------------------------------------------------
describe('드라이버 탭 S3 블록 (실 partial 렌더)', () => {
let registry: ComponentRegistry;
beforeEach(() => {
registry = setupTestRegistry();
});
afterEach(() => {
(registry as any).registry = {};
});
function renderS3Block() {
const s3Settings = findInPartial('s3_settings');
expect(s3Settings).not.toBeNull();
const layout = {
version: '1.0.0',
layout_name: 'test_drivers_s3',
components: [s3Settings],
};
return createLayoutTest(layout as any, {
initialState: {
_local: {
form: {
drivers: {
storage_driver: 's3',
s3_bucket: '',
s3_region: '',
s3_access_key: '',
s3_secret_key: '',
s3_endpoint: '',
s3_use_path_style: false,
s3_url: '',
},
},
errors: {},
},
},
});
}
// @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
// @effects invalid_region_rejected
it('s3 선택 시 리전이 자유 입력 Input 으로 렌더된다 (Select 아님)', async () => {
const testUtils = renderS3Block();
await testUtils.render();
// 존재 확정 후 부재 단언 (부재 단언 단독 금지 규율)
expect(screen.getByTestId('drivers.s3_region')).toBeInTheDocument();
expect(screen.getByTestId('drivers.s3_region').tagName).toBe('INPUT');
expect(screen.queryByTestId('select-drivers.s3_region')).toBeNull();
testUtils.cleanup();
});
it('s3_endpoint Input 과 s3_use_path_style Toggle 이 렌더된다', async () => {
const testUtils = renderS3Block();
await testUtils.render();
expect(screen.getByTestId('drivers.s3_endpoint')).toBeInTheDocument();
expect(screen.getByTestId('toggle-drivers.s3_use_path_style')).toBeInTheDocument();
testUtils.cleanup();
});
it('기존 s3 필드(bucket/access/secret/url)가 유지된다 (회귀 가드)', async () => {
const testUtils = renderS3Block();
await testUtils.render();
for (const field of ['s3_bucket', 's3_access_key', 's3_secret_key', 's3_url']) {
expect(screen.getByTestId(`drivers.${field}`)).toBeInTheDocument();
}
testUtils.cleanup();
});
});
// ---------------------------------------------------------------------------
// 구조(payload 계약) 테스트 — 실제 partial JSON 을 직접 검증
// ---------------------------------------------------------------------------
describe('드라이버 탭 연결 테스트 payload 계약', () => {
// @scenario region_shape=aws_region,endpoint=custom_url,driver_usability=adapter_present,attachment_disk=local_default
// @effects endpoint_injected
it('s3 테스트 payload 에 s3_endpoint/s3_use_path_style 이 포함되고 s3_url 은 제외된다', () => {
const s3Settings = findInPartial('s3_settings');
const apiCalls = collectApiCalls(s3Settings);
const testCall = apiCalls.find(
(a) => a.target === '/api/admin/settings/test-driver'
);
expect(testCall).toBeDefined();
const body = testCall.params.body;
expect(Object.keys(body)).toEqual(
expect.arrayContaining([
'storage_driver',
's3_bucket',
's3_region',
's3_access_key',
's3_secret_key',
's3_endpoint',
's3_use_path_style',
])
);
// s3_url 은 공개 URL base — 연결 테스트와 무관 (의도적 제외, A10)
expect(body).not.toHaveProperty('s3_url');
});
it('websocket 테스트 payload 에 server 3필드가 포함된다 (A6)', () => {
const roots = driversPartial.components ?? [driversPartial];
const allCalls: any[] = [];
for (const root of roots) collectApiCalls(root, allCalls);
const websocketCall = allCalls.find(
(a) =>
a.target === '/api/admin/settings/test-driver' &&
a.params?.body?.websocket_enabled !== undefined
);
expect(websocketCall).toBeDefined();
expect(Object.keys(websocketCall.params.body)).toEqual(
expect.arrayContaining([
'websocket_host',
'websocket_port',
'websocket_scheme',
'websocket_server_host',
'websocket_server_port',
'websocket_server_scheme',
])
);
});
it('리전 Select 하드코딩 옵션(regions.*)이 partial 에서 제거되었다', () => {
const raw = JSON.stringify(driversPartial);
// 존재 확정 — 대체된 자유 입력 힌트 키가 같은 s3 블록에 실려 있다
// (partial 경로/구조가 바뀌어 빈 대상을 검사하면 부재 단언만으로는 공회전 통과)
expect(raw).toContain('admin.settings.drivers.storage.s3_region_desc');
expect(raw).not.toContain('storage.regions.');
});
it('onError fallback 메시지가 다국어 키를 사용한다 (a9)', () => {
const raw = JSON.stringify(driversPartial);
// 존재 확정 — 영문 하드코딩을 대체한 다국어 키가 실제로 쓰이고 있다
expect(raw).toContain('$t:admin.settings.drivers.test_failed');
expect(raw).not.toContain('Connection test failed');
});
// @scenario region_shape=aws_region,endpoint=custom_url,driver_usability=adapter_present,attachment_disk=local_default
// @effects local_roundtrip_preserves_new_keys
it('저장 payload 는 활성 탭 폼을 키 열거 없이 통째로 전송한다 (신규 s3 키 자동 포함)', () => {
const saveCalls = collectApiCallsDeep(settingsLayout).filter(
(a) => a.target === '/api/admin/settings' && a.params?.method === 'POST'
);
// 존재 확정 후 계약 단언
expect(saveCalls.length).toBeGreaterThan(0);
const saveBody = saveCalls.map((a) => a.params?.body).find(
(body) => typeof body === 'string' && body.includes('_tab')
);
expect(saveBody).toBeDefined();
// 탭 폼 객체를 통째로 전달 — s3_endpoint/s3_use_path_style 등 신규 키가
// 별도 열거 없이 자동 포함된다 (열거 방식이면 키 추가 시 조용히 누락)
expect(saveBody).toContain('[tab]: form[tab] || {}');
expect(saveBody).not.toContain('s3_');
});
});
@@ -1627,17 +1627,15 @@
},
"s3_bucket": "S3 Bucket",
"s3_region": "S3 Region",
"regions": {
"ap_northeast_2": "Asia Pacific (Seoul)",
"ap_northeast_1": "Asia Pacific (Tokyo)",
"us_east_1": "US East (N. Virginia)",
"us_west_2": "US West (Oregon)",
"eu_west_1": "Europe (Ireland)"
},
"s3_region_desc": "Enter an AWS region code or the region value of your S3-compatible storage. (e.g. ap-northeast-2, or auto for Cloudflare R2)",
"s3_access_key": "Access Key",
"s3_secret_key": "Secret Key",
"s3_url": "S3 URL",
"s3_url_desc": "Only enter if using a custom endpoint or CDN URL."
"s3_endpoint": "Endpoint URL",
"s3_endpoint_desc": "API endpoint of S3-compatible storage (Cloudflare R2, MinIO, etc.). Leave empty for AWS S3.",
"s3_use_path_style": "Use path-style addressing",
"s3_use_path_style_desc": "Enable for storages that only support path-style addressing, such as MinIO.",
"s3_url": "S3 URL (Public URL)",
"s3_url_desc": "CDN or custom domain used to build public file URLs. Enter the API address in the Endpoint URL field instead."
},
"cache": {
"title": "Cache",
@@ -1631,17 +1631,15 @@
},
"s3_bucket": "S3 버킷",
"s3_region": "S3 리전",
"regions": {
"ap_northeast_2": "아시아 태평양 (서울)",
"ap_northeast_1": "아시아 태평양 (도쿄)",
"us_east_1": "미국 동부 (버지니아)",
"us_west_2": "미국 서부 (오레곤)",
"eu_west_1": "유럽 (아일랜드)"
},
"s3_region_desc": "AWS 리전 코드 또는 S3 호환 스토리지의 리전 값을 입력하세요. (예: ap-northeast-2, Cloudflare R2 는 auto)",
"s3_access_key": "Access Key",
"s3_secret_key": "Secret Key",
"s3_url": "S3 URL",
"s3_url_desc": "커스텀 엔드포인트 또는 CDN URL을 사용하는 경우에만 입력하세요."
"s3_endpoint": "엔드포인트 URL",
"s3_endpoint_desc": "S3 호환 스토리지(Cloudflare R2, MinIO 등)의 API 주소입니다. AWS S3 는 비워 둡니다.",
"s3_use_path_style": "Path-style 주소 사용",
"s3_use_path_style_desc": "MinIO 등 path-style 주소 전용 스토리지에서 켭니다.",
"s3_url": "S3 URL (공개 URL)",
"s3_url_desc": "파일 공개 URL 생성에 쓰는 CDN/커스텀 도메인입니다. API 요청 주소는 엔드포인트 URL에 입력하세요."
},
"cache": {
"title": "캐시",
@@ -694,7 +694,7 @@
"type": "basic",
"name": "Button",
"props": {
"disabled": "{{!_local.hasChanges || _local.isSaving || ((_global.activeSettingsTab || query.tab) === 'drivers' && ((_local.form?.drivers?.storage_driver === 's3' && !_local.driverTestResults?.s3?.success && ((_local.form?.drivers?.storage_driver || '') !== (_local.originalDrivers?.storage_driver || '') || (_local.form?.drivers?.s3_bucket || '') !== (_local.originalDrivers?.s3_bucket || '') || (_local.form?.drivers?.s3_region || '') !== (_local.originalDrivers?.s3_region || '') || (_local.form?.drivers?.s3_access_key || '') !== (_local.originalDrivers?.s3_access_key || '') || (_local.form?.drivers?.s3_secret_key || '') !== (_local.originalDrivers?.s3_secret_key || ''))) || (_local.form?.drivers?.cache_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.cache_driver || '') !== (_local.originalDrivers?.cache_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.cache_driver === 'memcached' && !_local.driverTestResults?.memcached?.success && ((_local.form?.drivers?.cache_driver || '') !== (_local.originalDrivers?.cache_driver || '') || (_local.form?.drivers?.memcached_host || '') !== (_local.originalDrivers?.memcached_host || '') || String(_local.form?.drivers?.memcached_port ?? '') !== String(_local.originalDrivers?.memcached_port ?? ''))) || (_local.form?.drivers?.session_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.session_driver || '') !== (_local.originalDrivers?.session_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.queue_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.queue_driver || '') !== (_local.originalDrivers?.queue_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.websocket_enabled && !_local.driverTestResults?.websocket?.success && (Boolean(_local.form?.drivers?.websocket_enabled) !== Boolean(_local.originalDrivers?.websocket_enabled) || (_local.form?.drivers?.websocket_host || '') !== (_local.originalDrivers?.websocket_host || '') || String(_local.form?.drivers?.websocket_port ?? '') !== String(_local.originalDrivers?.websocket_port ?? '') || (_local.form?.drivers?.websocket_scheme || '') !== (_local.originalDrivers?.websocket_scheme || '')))))}}",
"disabled": "{{!_local.hasChanges || _local.isSaving || ((_global.activeSettingsTab || query.tab) === 'drivers' && ((_local.form?.drivers?.storage_driver === 's3' && !_local.driverTestResults?.s3?.success && ((_local.form?.drivers?.storage_driver || '') !== (_local.originalDrivers?.storage_driver || '') || (_local.form?.drivers?.s3_bucket || '') !== (_local.originalDrivers?.s3_bucket || '') || (_local.form?.drivers?.s3_region || '') !== (_local.originalDrivers?.s3_region || '') || (_local.form?.drivers?.s3_access_key || '') !== (_local.originalDrivers?.s3_access_key || '') || (_local.form?.drivers?.s3_secret_key || '') !== (_local.originalDrivers?.s3_secret_key || '') || (_local.form?.drivers?.s3_endpoint || '') !== (_local.originalDrivers?.s3_endpoint || '') || Boolean(_local.form?.drivers?.s3_use_path_style) !== Boolean(_local.originalDrivers?.s3_use_path_style))) || (_local.form?.drivers?.cache_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.cache_driver || '') !== (_local.originalDrivers?.cache_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.cache_driver === 'memcached' && !_local.driverTestResults?.memcached?.success && ((_local.form?.drivers?.cache_driver || '') !== (_local.originalDrivers?.cache_driver || '') || (_local.form?.drivers?.memcached_host || '') !== (_local.originalDrivers?.memcached_host || '') || String(_local.form?.drivers?.memcached_port ?? '') !== String(_local.originalDrivers?.memcached_port ?? ''))) || (_local.form?.drivers?.session_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.session_driver || '') !== (_local.originalDrivers?.session_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.queue_driver === 'redis' && !_local.driverTestResults?.redis?.success && ((_local.form?.drivers?.queue_driver || '') !== (_local.originalDrivers?.queue_driver || '') || (_local.form?.drivers?.redis_host || '') !== (_local.originalDrivers?.redis_host || '') || String(_local.form?.drivers?.redis_port ?? '') !== String(_local.originalDrivers?.redis_port ?? '') || (_local.form?.drivers?.redis_password || '') !== (_local.originalDrivers?.redis_password || ''))) || (_local.form?.drivers?.websocket_enabled && !_local.driverTestResults?.websocket?.success && (Boolean(_local.form?.drivers?.websocket_enabled) !== Boolean(_local.originalDrivers?.websocket_enabled) || (_local.form?.drivers?.websocket_host || '') !== (_local.originalDrivers?.websocket_host || '') || String(_local.form?.drivers?.websocket_port ?? '') !== String(_local.originalDrivers?.websocket_port ?? '') || (_local.form?.drivers?.websocket_scheme || '') !== (_local.originalDrivers?.websocket_scheme || '')))))}}",
"className": "btn btn-primary flex-center gap-1.5"
},
"actions": [
@@ -877,7 +877,7 @@
}
],
"initLocal": {
"driverTestResults": [],
"driverTestResults": {},
"editingDefinition": null,
"editingTemplate": null,
"editLang": "ko",
@@ -149,53 +149,31 @@
},
{
"type": "basic",
"name": "Div",
"name": "Input",
"props": {
"className": "w-full md:w-64"
"type": "text",
"name": "drivers.s3_region",
"placeholder": "ap-northeast-2",
"disabled": "{{_computed.isReadOnly}}",
"className": "{{_local.errors?.['drivers.s3_region'] ? 'input-error md:w-64 font-mono' : 'md:w-64 font-mono'}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['drivers.s3_region']}}",
"props": {
"className": "form-error"
},
"children": [
{
"type": "composite",
"name": "Select",
"props": {
"name": "drivers.s3_region",
"disabled": "{{_computed.isReadOnly}}",
"className": "w-full",
"error": "{{_local.errors?.['drivers.s3_region']?.[0] ?? ''}}",
"options": [
{
"value": "ap-northeast-2",
"label": "$t:admin.settings.drivers.storage.regions.ap_northeast_2"
},
{
"value": "ap-northeast-1",
"label": "$t:admin.settings.drivers.storage.regions.ap_northeast_1"
},
{
"value": "us-east-1",
"label": "$t:admin.settings.drivers.storage.regions.us_east_1"
},
{
"value": "us-west-2",
"label": "$t:admin.settings.drivers.storage.regions.us_west_2"
},
{
"value": "eu-west-1",
"label": "$t:admin.settings.drivers.storage.regions.eu_west_1"
}
]
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['drivers.s3_region']}}",
"props": {
"className": "form-error"
},
"text": "{{_local.errors?.['drivers.s3_region']?.[0] ?? ''}}"
}
]
"text": "{{_local.errors?.['drivers.s3_region']?.[0] ?? ''}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-hint mt-1"
},
"text": "$t:admin.settings.drivers.storage.s3_region_desc"
}
]
},
@@ -273,6 +251,102 @@
}
]
},
{
"id": "s3_endpoint_field",
"type": "basic",
"name": "Div",
"props": {
"className": "py-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.storage.s3_endpoint"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "drivers.s3_endpoint",
"placeholder": "https://<account-id>.r2.cloudflarestorage.com",
"disabled": "{{_computed.isReadOnly}}",
"className": "{{_local.errors?.['drivers.s3_endpoint'] ? 'input-error' : ''}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['drivers.s3_endpoint']}}",
"props": {
"className": "form-error"
},
"text": "{{_local.errors?.['drivers.s3_endpoint']?.[0] ?? ''}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-hint mt-1"
},
"text": "$t:admin.settings.drivers.storage.s3_endpoint_desc"
}
]
},
{
"id": "s3_use_path_style_field",
"type": "basic",
"name": "Div",
"props": {
"className": "py-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-heading"
},
"text": "$t:admin.settings.drivers.storage.s3_use_path_style"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-label-subtle"
},
"text": "$t:admin.settings.drivers.storage.s3_use_path_style_desc"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "drivers.s3_use_path_style",
"size": "md",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
@@ -382,6 +456,7 @@
"handler": "apiCall",
"target": "/api/admin/settings/test-driver",
"auth_required": true,
"comment": "s3_url 은 공개 URL(CDN) base 라 연결 테스트와 무관 — 의도적 미전송",
"params": {
"method": "POST",
"body": {
@@ -389,7 +464,9 @@
"s3_bucket": "{{_local.form.drivers?.s3_bucket}}",
"s3_region": "{{_local.form.drivers?.s3_region}}",
"s3_access_key": "{{_local.form.drivers?.s3_access_key}}",
"s3_secret_key": "{{_local.form.drivers?.s3_secret_key}}"
"s3_secret_key": "{{_local.form.drivers?.s3_secret_key}}",
"s3_endpoint": "{{_local.form.drivers?.s3_endpoint}}",
"s3_use_path_style": "{{!!_local.form.drivers?.s3_use_path_style}}"
}
},
"onSuccess": [
@@ -422,7 +499,7 @@
"target": "local",
"driverTestResults.s3": {
"success": false,
"message": "Connection test failed"
"message": "$t:admin.settings.drivers.test_failed"
}
}
},
@@ -841,7 +918,7 @@
"target": "local",
"driverTestResults.redis": {
"success": false,
"message": "Connection test failed"
"message": "$t:admin.settings.drivers.test_failed"
}
}
},
@@ -1091,7 +1168,7 @@
"target": "local",
"driverTestResults.memcached": {
"success": false,
"message": "Connection test failed"
"message": "$t:admin.settings.drivers.test_failed"
}
}
},
@@ -2545,7 +2622,10 @@
"websocket_enabled": true,
"websocket_host": "{{_local.form.drivers?.websocket_host}}",
"websocket_port": "{{_local.form.drivers?.websocket_port}}",
"websocket_scheme": "{{_local.form.drivers?.websocket_scheme}}"
"websocket_scheme": "{{_local.form.drivers?.websocket_scheme}}",
"websocket_server_host": "{{_local.form.drivers?.websocket_server_host}}",
"websocket_server_port": "{{_local.form.drivers?.websocket_server_port}}",
"websocket_server_scheme": "{{_local.form.drivers?.websocket_server_scheme}}"
}
},
"onSuccess": [
@@ -2578,7 +2658,7 @@
"target": "local",
"driverTestResults.websocket": {
"success": false,
"message": "Connection test failed"
"message": "$t:admin.settings.drivers.test_failed"
}
}
},
@@ -22,7 +22,7 @@
"url": "https://sirsoft.com"
},
"release_date": "2026-05-15",
"g7_version": ">=7.0.6",
"g7_version": ">=7.0.7",
"dependencies": {
"modules": {},
"plugins": {}