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:
@@ -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 님께서 제보해주셨습니다.)
|
||||
|
||||
+333
@@ -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",
|
||||
|
||||
+131
-51
@@ -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": {}
|
||||
|
||||
Reference in New Issue
Block a user