diff --git a/.env.example b/.env.example
index 99dcfdff..373dd7dd 100644
--- a/.env.example
+++ b/.env.example
@@ -100,6 +100,11 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
+# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
+# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
+# AWS_URL=
+# AWS_ENDPOINT=
+# ATTACHMENT_DISK=
VITE_APP_NAME="${APP_NAME}"
diff --git a/.env.testing.example b/.env.testing.example
index 1dadfece..6aaace74 100644
--- a/.env.testing.example
+++ b/.env.testing.example
@@ -77,6 +77,11 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
+# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
+# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
+# AWS_URL=
+# AWS_ENDPOINT=
+# ATTACHMENT_DISK=
VITE_APP_NAME="${APP_NAME}"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5bc84d5b..17766879 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,9 +9,16 @@
### Added
- 레이아웃 편집기 첨부 파일 업로드에 업로드 전/후 액션 훅과 파일 가공 필터 훅 제공 — 확장에서 다른 업로드 경로와 동일하게 개입할 수 있습니다.
+- S3 호환 스토리지(Cloudflare R2, MinIO, 네이버 클라우드 등) 연결 지원 — 드라이버 설정에 엔드포인트 URL 과 Path-style 주소 옵션이 추가되어, AWS 가 아닌 S3 호환 스토리지도 관리자 화면 설정만으로 연결할 수 있습니다. S3 리전도 목록 선택 대신 자유 입력으로 바뀌어 새로 생기는 리전이나 R2 의 `auto` 값을 그대로 쓸 수 있습니다.
+- 서버에서 동작할 수 없는 드라이버(필요한 라이브러리·PHP 확장이 없는 경우)를 저장하려 하면 사유와 함께 거부합니다 — 잘못된 드라이버 저장으로 사이트가 멈추는 사고를 저장 시점에 차단합니다.
### Fixed
+- 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이 동작하지 않던 문제를 수정했습니다. S3 구동에 필요한 스토리지 어댑터가 빠져 있었고, 연결 테스트는 통과해도 실제 저장 경로는 동작하지 않았습니다. 이제 어댑터가 기본 포함되고, 연결 테스트도 실제 연결 경로와 같은 설정(엔드포인트·Path-style 포함)을 사용합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
+- 파일 스토리지를 S3 로 저장해도 첨부 파일 업로드는 계속 서버 로컬 디스크에 저장되던 문제를 수정했습니다. 이제 S3 저장 시 새 첨부 파일이 S3 로 저장되며, 기존 파일은 종전 위치에서 계속 서빙됩니다. 서버 환경변수(`ATTACHMENT_DISK`)로 저장 위치를 명시한 경우에는 그 값이 우선합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
+- phpredis PHP 확장이 없는 서버에서 캐시·세션·큐 드라이버로 Redis 를 선택하면 사이트 전체가 열리지 않던 문제를 예방했습니다 — Redis 클라이언트 라이브러리(predis)가 기본 포함되어 확장 없이도 동작합니다.
+- 웹소켓 연결 테스트가 브라우저 접속 주소만 검사해, 테스트는 성공해도 서버 발송(알림 등)은 실패할 수 있던 문제를 수정했습니다 — 이제 서버 발송용 주소도 함께 검사하고, 실패 시 어느 쪽 주소가 문제인지 구분해 알려줍니다.
+- 드라이버 설정의 S3 URL 이 너무 길 때 표시되는 영어 안내 문구가 실제 허용 길이(500자)와 다르게 255자로 안내되던 문제를 수정했습니다.
- 확인 질문이 있는 Artisan 명령(확장 설치·삭제·업데이트, 코어 업데이트, 검색 인덱스 재생성 등)을 터미널이 아닌 곳에서 실행하면 아무 반응 없이 멈춰 있던 문제를 수정했습니다. 예약 작업·배포 스크립트·CI 처럼 사람이 답할 수 없는 환경에서는 질문이 화면에 나오지도 않은 채 응답을 기다렸기 때문에, 명령이 실패한 것도 아니고 진행 중인 것도 아닌 상태로 남아 "명령이 몹시 느리다" 로만 보였습니다. 이제 그런 환경에서는 질문을 건너뛰고 기본 동작을 따릅니다 — 삭제·업데이트처럼 되돌리기 어려운 명령의 기본 동작은 "중단" 이므로, 확인 없이 수행되는 일은 없습니다. 그대로 진행하려면 종전처럼 `--no-interaction` 이나 `--force` 를 붙이면 됩니다.
- Windows 에서 확장(모듈·플러그인·템플릿) 업데이트가 "디렉토리 이동 실패" 로 중단되던 문제를 해소했습니다. Windows 는 편집기·개발 도구·파일 감시 프로그램이 폴더를 보고 있기만 해도 폴더 이름 바꾸기를 차단하므로, 폴더를 통째로 이동하는 방식으로는 실패를 피할 수 없었습니다. 이제 이동이 차단되면 파일 단위 교체로 자동 전환해 어떤 프로그램도 종료하지 않고 업데이트를 완료합니다. 잠근 프로그램을 찾아 강제 종료하던 이전 동작은 제거했습니다 — 사용 중인 편집기나 개발 도구가 예고 없이 종료되는 부작용이 있었고, 폴더를 감시만 하는 프로그램은 찾아내지도 못했습니다. 백업 복원에도 같은 방식이 적용되며, 실패한 업데이트가 남긴 임시 폴더는 다음 업데이트 때 자동으로 정리됩니다.
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
diff --git a/README.ko.md b/README.ko.md
index da5861c5..13c684a7 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -517,6 +517,7 @@ cp .env.example .env
+
@@ -527,7 +528,6 @@ cp .env.example .env
-
diff --git a/README.md b/README.md
index 3e1f71a9..9b042eed 100644
--- a/README.md
+++ b/README.md
@@ -533,6 +533,7 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
+
@@ -543,7 +544,6 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
-
diff --git a/app/Http/Controllers/Api/Base/BaseApiController.php b/app/Http/Controllers/Api/Base/BaseApiController.php
index c8f4e2f5..3b489b6f 100644
--- a/app/Http/Controllers/Api/Base/BaseApiController.php
+++ b/app/Http/Controllers/Api/Base/BaseApiController.php
@@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* API 컨트롤러의 최상위 베이스 클래스
@@ -174,6 +175,40 @@ abstract class BaseApiController extends Controller
return $response;
}
+ /**
+ * 스토리지 스트림 응답에 ETag/캐싱 헤더를 부착합니다 (fileResponse 의 디스크 인지 대응).
+ *
+ * fileResponse() 는 로컬 절대 경로 전제(filemtime/filesize)라 S3 등 원격 디스크
+ * 행에는 쓸 수 없습니다. 원격/로컬 공통 서빙은 StorageInterface::response() 가
+ * 만든 스트림에 본 메서드로 동일한 캐싱 계약(ETag/304/Cache-Control/Expires)을
+ * 입힙니다. ETag 는 파일 stat 대신 호출자가 준 결정적 소스(행 메타)로 만듭니다.
+ *
+ * @param StreamedResponse $response 스토리지 인라인 스트림 응답
+ * @param string $etagSource ETag 소스 문자열 (디스크·경로·수정시각·크기 등 행 메타)
+ * @param int $maxAge 캐시 유지 시간 (초)
+ * @return StreamedResponse|Response 스트림 응답 또는 304
+ */
+ protected function streamedFileResponse(StreamedResponse $response, string $etagSource, int $maxAge): StreamedResponse|Response
+ {
+ $etag = md5($etagSource);
+
+ // If-None-Match 헤더 확인 (ETag 비교)
+ if (request()->header('If-None-Match') === $etag) {
+ return response('', 304)->header('ETag', $etag);
+ }
+
+ // 환경별 캐싱 정책 (fileResponse 와 동일)
+ $cacheControl = app()->environment('production')
+ ? "public, max-age={$maxAge}, immutable"
+ : 'no-cache';
+
+ $response->headers->set('ETag', $etag);
+ $response->headers->set('Expires', gmdate('D, d M Y H:i:s', time() + $maxAge).' GMT');
+ $response->headers->set('Cache-Control', $cacheControl);
+
+ return $response;
+ }
+
/**
* JSON 응답을 반환합니다 (캐싱 헤더 포함).
*
diff --git a/app/Http/Controllers/Api/Public/PublicAttachmentController.php b/app/Http/Controllers/Api/Public/PublicAttachmentController.php
index b90dbd0e..186201be 100644
--- a/app/Http/Controllers/Api/Public/PublicAttachmentController.php
+++ b/app/Http/Controllers/Api/Public/PublicAttachmentController.php
@@ -3,10 +3,10 @@
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
+use App\Http\Requests\Public\Attachment\DownloadAttachmentRequest;
use App\Services\AttachmentService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
-use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -22,7 +22,7 @@ class PublicAttachmentController extends PublicBaseController
/**
* PublicAttachmentController 생성자
*
- * @param AttachmentService $attachmentService 첨부파일 서비스
+ * @param AttachmentService $attachmentService 첨부파일 서비스
*/
public function __construct(
private AttachmentService $attachmentService
@@ -36,11 +36,11 @@ class PublicAttachmentController extends PublicBaseController
* 이미지 파일은 캐싱 헤더와 함께 인라인 표시하고,
* 그 외 파일은 다운로드 방식으로 제공합니다.
*
- * @param Request $request HTTP 요청
- * @param string $hash 첨부파일 해시 (12자)
+ * @param DownloadAttachmentRequest $request 다운로드 요청
+ * @param string $hash 첨부파일 해시 (12자)
* @return BinaryFileResponse|StreamedResponse|Response|JsonResponse 파일 응답 또는 에러 응답
*/
- public function download(Request $request, string $hash): BinaryFileResponse|StreamedResponse|Response|JsonResponse
+ public function download(DownloadAttachmentRequest $request, string $hash): BinaryFileResponse|StreamedResponse|Response|JsonResponse
{
$user = $request->user();
@@ -48,10 +48,10 @@ class PublicAttachmentController extends PublicBaseController
// 파일 정보 조회 (권한 체크 포함)
$fileInfo = $this->attachmentService->getFileInfo($hash, $user);
- if (!$fileInfo) {
+ if (! $fileInfo) {
$attachment = $this->attachmentService->findByHash($hash);
- if (!$attachment) {
+ if (! $attachment) {
return $this->notFound('attachment.not_found');
}
@@ -59,10 +59,11 @@ class PublicAttachmentController extends PublicBaseController
}
// 이미지 파일은 캐싱 헤더와 함께 응답 (환경설정 레이아웃 캐시 TTL 사용, 기본 24시간)
+ // 행 disk 를 따르는 스토리지 스트림 — 로컬 경로 전제 fileResponse 는 S3 행에서 성립하지 않는다 (#99)
if (str_starts_with($fileInfo['mime_type'], 'image/')) {
- return $this->fileResponse(
- $fileInfo['path'],
- $fileInfo['mime_type'],
+ return $this->streamedFileResponse(
+ $fileInfo['response'],
+ $fileInfo['etag_source'],
(int) g7_core_settings('cache.layout_ttl', 86400)
);
}
@@ -70,7 +71,7 @@ class PublicAttachmentController extends PublicBaseController
// 이미지가 아닌 파일은 기존 다운로드 방식 유지
$response = $this->attachmentService->download($hash, $user);
- if (!$response) {
+ if (! $response) {
return $this->forbidden('attachment.access_denied');
}
diff --git a/app/Http/Controllers/Api/Public/PublicTemplateController.php b/app/Http/Controllers/Api/Public/PublicTemplateController.php
index 13d39d9e..a59747c6 100644
--- a/app/Http/Controllers/Api/Public/PublicTemplateController.php
+++ b/app/Http/Controllers/Api/Public/PublicTemplateController.php
@@ -15,6 +15,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 공개 템플릿 API 컨트롤러
@@ -340,21 +341,22 @@ class PublicTemplateController extends PublicBaseController
*
* @param string $identifier 템플릿 식별자
* @param TemplateLayoutAttachment $attachment 라우트 모델 바인딩된 첨부
- * @return BinaryFileResponse|Response|JsonResponse 파일 응답 또는 404
+ * @return StreamedResponse|Response|JsonResponse 파일 응답 또는 404
*/
- public function serveFile(string $identifier, TemplateLayoutAttachment $attachment): BinaryFileResponse|Response|JsonResponse
+ public function serveFile(string $identifier, TemplateLayoutAttachment $attachment): StreamedResponse|Response|JsonResponse
{
- $filePath = $this->layoutAttachmentService->getServableFilePath($identifier, $attachment);
+ $serveInfo = $this->layoutAttachmentService->getServableResponse($identifier, $attachment);
- if ($filePath === null) {
+ if ($serveInfo === null) {
return $this->notFound('templates.layout_attachments.errors.not_found');
}
// 이미지/일반 파일 모두 캐싱 헤더와 함께 인라인 응답 (레이아웃 캐시 TTL, 기본 24시간).
- // PublicAttachmentController 의 이미지 서빙과 동일한 fileResponse(ETag/Cache-Control) 사용.
- return $this->fileResponse(
- $filePath,
- $attachment->mime_type,
+ // PublicAttachmentController 의 이미지 서빙과 동일한 streamedFileResponse(ETag/Cache-Control) 사용
+ // — 행 disk 를 따르는 스토리지 스트림이라 S3 등 원격 디스크 행에서도 성립한다 (#99).
+ return $this->streamedFileResponse(
+ $serveInfo['response'],
+ $serveInfo['etag_source'],
(int) g7_core_settings('cache.layout_ttl', 86400)
);
}
diff --git a/app/Http/Requests/Public/Attachment/DownloadAttachmentRequest.php b/app/Http/Requests/Public/Attachment/DownloadAttachmentRequest.php
new file mode 100644
index 00000000..581f2a5e
--- /dev/null
+++ b/app/Http/Requests/Public/Attachment/DownloadAttachmentRequest.php
@@ -0,0 +1,34 @@
+ 검증 규칙 배열
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php
index ddcd5384..61af9831 100644
--- a/app/Http/Requests/Settings/SaveSettingsRequest.php
+++ b/app/Http/Requests/Settings/SaveSettingsRequest.php
@@ -5,6 +5,7 @@ namespace App\Http\Requests\Settings;
use App\Extension\HookManager;
use App\Models\Attachment;
use App\Search\Engines\DatabaseFulltextEngine;
+use App\Services\DriverRegistryService;
use App\Support\AllowedExtensions;
use App\Support\AssetUrl;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -30,9 +31,9 @@ class SaveSettingsRequest extends FormRequest
private const SUPPORTED_STORAGE_DRIVERS = ['local', 's3'];
/**
- * 지원되는 S3 리전 목록
+ * S3 리전 형식 (AWS 리전 코드 + S3 호환 스토리지 임의값 허용 — R2 의 `auto` 등)
*/
- private const SUPPORTED_S3_REGIONS = ['ap-northeast-2', 'ap-northeast-1', 'us-east-1', 'us-west-2', 'eu-west-1'];
+ private const S3_REGION_FORMAT = 'regex:/^[a-z0-9-]+$/';
/**
* 지원되는 캐시 드라이버 목록
@@ -54,11 +55,6 @@ class SaveSettingsRequest extends FormRequest
*/
private const SUPPORTED_WEBSOCKET_SCHEMES = ['http', 'https'];
- /**
- * 지원되는 검색엔진 드라이버 기본 목록
- */
- private const DEFAULT_SEARCH_ENGINE_DRIVERS = ['mysql-fulltext'];
-
/**
* 지원되는 로그 드라이버 목록
*/
@@ -313,22 +309,24 @@ class SaveSettingsRequest extends FormRequest
'advanced.pagination_max_page' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_pagination_max_page_min', 0), 'max:'.config('core.settings_limits.advanced_pagination_max_page_max', 100000)],
// 드라이버 설정 (drivers 탭)
- 'drivers.storage_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_STORAGE_DRIVERS)]),
+ 'drivers.storage_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_STORAGE_DRIVERS), $this->getDriverUsabilityRule('storage')]),
'drivers.s3_bucket' => ['nullable', 'string', 'max:255'],
- 'drivers.s3_region' => ['nullable', Rule::in(self::SUPPORTED_S3_REGIONS)],
+ 'drivers.s3_region' => ['nullable', 'string', 'max:64', self::S3_REGION_FORMAT],
'drivers.s3_access_key' => ['nullable', 'string', 'max:255'],
'drivers.s3_secret_key' => ['nullable', 'string', 'max:255'],
'drivers.s3_url' => ['nullable', 'url', 'max:500'],
- 'drivers.cache_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_CACHE_DRIVERS)]),
+ 'drivers.s3_endpoint' => ['nullable', 'url', 'max:500'],
+ 'drivers.s3_use_path_style' => ['nullable', 'boolean'],
+ 'drivers.cache_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_CACHE_DRIVERS), $this->getDriverUsabilityRule('cache')]),
'drivers.redis_host' => ['nullable', 'string', 'max:255'],
'drivers.redis_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_redis_port_min', 1), 'max:'.config('core.settings_limits.drivers_redis_port_max', 65535)],
'drivers.redis_password' => ['nullable', 'string', 'max:255'],
'drivers.redis_database' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_redis_database_min', 0), 'max:'.config('core.settings_limits.drivers_redis_database_max', 15)],
'drivers.memcached_host' => ['nullable', 'string', 'max:255'],
'drivers.memcached_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_memcached_port_min', 1), 'max:'.config('core.settings_limits.drivers_memcached_port_max', 65535)],
- 'drivers.session_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_SESSION_DRIVERS)]),
+ 'drivers.session_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_SESSION_DRIVERS), $this->getDriverUsabilityRule('session')]),
'drivers.session_lifetime' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_session_lifetime_min', 1), 'max:'.config('core.settings_limits.drivers_session_lifetime_max', 43200)], // 1분 ~ 30일
- 'drivers.queue_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_QUEUE_DRIVERS)]),
+ 'drivers.queue_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_QUEUE_DRIVERS), $this->getDriverUsabilityRule('queue')]),
'drivers.websocket_enabled' => ['nullable', 'boolean'],
'drivers.websocket_app_id' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
'drivers.websocket_app_key' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
@@ -478,6 +476,34 @@ class SaveSettingsRequest extends FormRequest
]);
}
+ /**
+ * 드라이버 가용성 검증 rule 을 반환합니다.
+ *
+ * 선택된 드라이버가 현재 서버에서 실제로 동작 가능한지(어댑터 클래스·PHP 확장 존재)를
+ * 저장 시점에 검사합니다. 사용 불능 드라이버가 저장되면 사이트 전면 다운으로 이어질 수
+ * 있으므로(예: phpredis 확장 없는 서버의 redis) 서버 게이트로 차단합니다.
+ *
+ * @param string $category 드라이버 카테고리 (storage, cache, session, queue)
+ * @return \Closure 검증 클로저
+ */
+ private function getDriverUsabilityRule(string $category): \Closure
+ {
+ return function ($attribute, $value, $fail) use ($category) {
+ if (empty($value)) {
+ return;
+ }
+
+ $registry = app(DriverRegistryService::class);
+
+ if (! $registry->isDriverUsable($category, $value)) {
+ $fail(__('validation.settings.driver_unusable', [
+ 'driver' => $value,
+ 'reason' => $registry->usabilityFailureReason($category, $value),
+ ]));
+ }
+ };
+ }
+
/**
* 추가 검증을 위해 validator after 콜백을 등록합니다.
*
@@ -734,11 +760,15 @@ class SaveSettingsRequest extends FormRequest
'drivers.storage_driver.required' => __('validation.settings.storage_driver_required'),
'drivers.storage_driver.in' => __('validation.settings.storage_driver_invalid'),
'drivers.s3_bucket.max' => __('validation.settings.s3_bucket_max'),
- 'drivers.s3_region.in' => __('validation.settings.s3_region_invalid'),
+ 'drivers.s3_region.regex' => __('validation.settings.s3_region_invalid'),
+ 'drivers.s3_region.max' => __('validation.settings.s3_region_max'),
'drivers.s3_access_key.max' => __('validation.settings.s3_access_key_max'),
'drivers.s3_secret_key.max' => __('validation.settings.s3_secret_key_max'),
'drivers.s3_url.url' => __('validation.settings.s3_url_invalid'),
'drivers.s3_url.max' => __('validation.settings.s3_url_max'),
+ 'drivers.s3_endpoint.url' => __('validation.settings.s3_endpoint_invalid'),
+ 'drivers.s3_endpoint.max' => __('validation.settings.s3_endpoint_max'),
+ 'drivers.s3_use_path_style.boolean' => __('validation.settings.s3_use_path_style_boolean'),
'drivers.cache_driver.required' => __('validation.settings.cache_driver_required'),
'drivers.cache_driver.in' => __('validation.settings.cache_driver_invalid'),
'drivers.redis_host.max' => __('validation.settings.redis_host_max'),
@@ -916,6 +946,8 @@ class SaveSettingsRequest extends FormRequest
'drivers.s3_access_key' => __('validation.attributes.s3_access_key'),
'drivers.s3_secret_key' => __('validation.attributes.s3_secret_key'),
'drivers.s3_url' => __('validation.attributes.s3_url'),
+ 'drivers.s3_endpoint' => __('validation.attributes.s3_endpoint'),
+ 'drivers.s3_use_path_style' => __('validation.attributes.s3_use_path_style'),
'drivers.cache_driver' => __('validation.attributes.cache_driver'),
'drivers.redis_host' => __('validation.attributes.redis_host'),
'drivers.redis_port' => __('validation.attributes.redis_port'),
diff --git a/app/Http/Requests/Settings/TestDriverConnectionRequest.php b/app/Http/Requests/Settings/TestDriverConnectionRequest.php
index 45968f99..4d57cbb3 100644
--- a/app/Http/Requests/Settings/TestDriverConnectionRequest.php
+++ b/app/Http/Requests/Settings/TestDriverConnectionRequest.php
@@ -3,6 +3,8 @@
namespace App\Http\Requests\Settings;
use App\Extension\HookManager;
+use App\Services\DriverRegistryService;
+use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -20,9 +22,9 @@ class TestDriverConnectionRequest extends FormRequest
private const SUPPORTED_STORAGE_DRIVERS = ['local', 's3'];
/**
- * 지원되는 S3 리전 목록
+ * S3 리전 형식 (AWS 리전 코드 + S3 호환 스토리지 임의값 허용 — R2 의 `auto` 등)
*/
- private const SUPPORTED_S3_REGIONS = ['ap-northeast-2', 'ap-northeast-1', 'us-east-1', 'us-west-2', 'eu-west-1'];
+ private const S3_REGION_FORMAT = 'regex:/^[a-z0-9-]+$/';
/**
* 지원되는 캐시 드라이버 목록
@@ -57,24 +59,26 @@ class TestDriverConnectionRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
- * @return array|string>
+ * @return array|string>
*/
public function rules(): array
{
$rules = [
// 드라이버 선택
- 'storage_driver' => ['nullable', Rule::in(self::SUPPORTED_STORAGE_DRIVERS)],
- 'cache_driver' => ['nullable', Rule::in(self::SUPPORTED_CACHE_DRIVERS)],
- 'session_driver' => ['nullable', Rule::in(self::SUPPORTED_SESSION_DRIVERS)],
- 'queue_driver' => ['nullable', Rule::in(self::SUPPORTED_QUEUE_DRIVERS)],
+ 'storage_driver' => ['nullable', Rule::in(self::SUPPORTED_STORAGE_DRIVERS), $this->getDriverUsabilityRule('storage')],
+ 'cache_driver' => ['nullable', Rule::in(self::SUPPORTED_CACHE_DRIVERS), $this->getDriverUsabilityRule('cache')],
+ 'session_driver' => ['nullable', Rule::in(self::SUPPORTED_SESSION_DRIVERS), $this->getDriverUsabilityRule('session')],
+ 'queue_driver' => ['nullable', Rule::in(self::SUPPORTED_QUEUE_DRIVERS), $this->getDriverUsabilityRule('queue')],
'websocket_enabled' => ['nullable', 'boolean'],
// S3 설정
's3_bucket' => ['nullable', 'string', 'max:255'],
- 's3_region' => ['nullable', Rule::in(self::SUPPORTED_S3_REGIONS)],
+ 's3_region' => ['nullable', 'string', 'max:64', self::S3_REGION_FORMAT],
's3_access_key' => ['nullable', 'string', 'max:255'],
's3_secret_key' => ['nullable', 'string', 'max:255'],
's3_url' => ['nullable', 'url', 'max:500'],
+ 's3_endpoint' => ['nullable', 'url', 'max:500'],
+ 's3_use_path_style' => ['nullable', 'boolean'],
// Redis 설정
'redis_host' => ['nullable', 'string', 'max:255'],
@@ -86,11 +90,14 @@ class TestDriverConnectionRequest extends FormRequest
'memcached_host' => ['nullable', 'string', 'max:255'],
'memcached_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
- // Websocket 설정
+ // Websocket 설정 — client(브라우저 접속) / server(백엔드 broadcast HTTP API) endpoint 분리
'websocket_app_key' => ['nullable', 'string', 'max:255'],
'websocket_host' => ['nullable', 'string', 'max:255'],
'websocket_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'websocket_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)],
+ 'websocket_server_host' => ['nullable', 'string', 'max:255'],
+ 'websocket_server_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
+ 'websocket_server_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
@@ -107,11 +114,15 @@ class TestDriverConnectionRequest extends FormRequest
return [
'storage_driver.in' => __('validation.settings.storage_driver_invalid'),
's3_bucket.max' => __('validation.settings.s3_bucket_max'),
- 's3_region.in' => __('validation.settings.s3_region_invalid'),
+ 's3_region.regex' => __('validation.settings.s3_region_invalid'),
+ 's3_region.max' => __('validation.settings.s3_region_max'),
's3_access_key.max' => __('validation.settings.s3_access_key_max'),
's3_secret_key.max' => __('validation.settings.s3_secret_key_max'),
's3_url.url' => __('validation.settings.s3_url_invalid'),
's3_url.max' => __('validation.settings.s3_url_max'),
+ 's3_endpoint.url' => __('validation.settings.s3_endpoint_invalid'),
+ 's3_endpoint.max' => __('validation.settings.s3_endpoint_max'),
+ 's3_use_path_style.boolean' => __('validation.settings.s3_use_path_style_boolean'),
'cache_driver.in' => __('validation.settings.cache_driver_invalid'),
'redis_host.max' => __('validation.settings.redis_host_max'),
'redis_port.integer' => __('validation.settings.redis_port_integer'),
@@ -134,6 +145,38 @@ class TestDriverConnectionRequest extends FormRequest
'websocket_port.min' => __('validation.settings.websocket_port_min'),
'websocket_port.max' => __('validation.settings.websocket_port_max'),
'websocket_scheme.in' => __('validation.settings.websocket_scheme_invalid'),
+ 'websocket_server_host.max' => __('validation.settings.websocket_server_host_max'),
+ 'websocket_server_port.integer' => __('validation.settings.websocket_server_port_integer'),
+ 'websocket_server_port.min' => __('validation.settings.websocket_server_port_min'),
+ 'websocket_server_port.max' => __('validation.settings.websocket_server_port_max'),
+ 'websocket_server_scheme.in' => __('validation.settings.websocket_server_scheme_invalid'),
];
}
+
+ /**
+ * 드라이버 가용성 검증 rule 을 반환합니다.
+ *
+ * 저장 게이트(SaveSettingsRequest)와 동일 판정 — 사용 불능 드라이버는 테스트 요청
+ * 단계에서도 422 로 차단해 어느 경로로도 통과하지 못하게 합니다.
+ *
+ * @param string $category 드라이버 카테고리 (storage, cache, session, queue)
+ * @return \Closure 검증 클로저
+ */
+ private function getDriverUsabilityRule(string $category): \Closure
+ {
+ return function ($attribute, $value, $fail) use ($category) {
+ if (empty($value)) {
+ return;
+ }
+
+ $registry = app(DriverRegistryService::class);
+
+ if (! $registry->isDriverUsable($category, $value)) {
+ $fail(__('validation.settings.driver_unusable', [
+ 'driver' => $value,
+ 'reason' => $registry->usabilityFailureReason($category, $value),
+ ]));
+ }
+ };
+ }
}
diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php
index a7064352..65eedba2 100644
--- a/app/Providers/CoreServiceProvider.php
+++ b/app/Providers/CoreServiceProvider.php
@@ -269,14 +269,14 @@ class CoreServiceProvider extends ServiceProvider
$this->app->when(AttachmentService::class)
->needs(StorageInterface::class)
->give(function () {
- return new CoreStorageDriver(config('attachment.disk', 'local'));
+ return new CoreStorageDriver(config('attachment.disk', 'attachments'));
});
// TemplateLayoutAttachmentService용 CoreStorageDriver 바인딩
$this->app->when(TemplateLayoutAttachmentService::class)
->needs(StorageInterface::class)
->give(function () {
- return new CoreStorageDriver(config('attachment.disk', 'local'));
+ return new CoreStorageDriver(config('attachment.disk', 'attachments'));
});
// 코어 서비스용 CoreCacheDriver 바인딩
@@ -888,7 +888,8 @@ class CoreServiceProvider extends ServiceProvider
$configKey = $driverRegistry->getConfigKey($category);
if ($configKey && $defaultDriver) {
- Config::set($configKey, $defaultDriver);
+ // log 카테고리의 적용 키(stack.channels)는 배열형 — 형태 변환은 레지스트리가 담당
+ Config::set($configKey, $driverRegistry->getConfigValueForDriver($category, $defaultDriver));
}
Log::warning("플러그인 드라이버 '{$selectedDriver}'가 '{$category}' 카테고리에서 사용 불가능합니다. 기본 드라이버 '{$defaultDriver}'로 폴백합니다.");
diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php
index 678f4846..88f6f1c5 100644
--- a/app/Providers/SettingsServiceProvider.php
+++ b/app/Providers/SettingsServiceProvider.php
@@ -6,6 +6,7 @@ use App\Repositories\JsonConfigRepository;
use App\Support\AllowedExtensions;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
+use Predis\Client;
/**
* 설정 서비스 프로바이더
@@ -48,7 +49,6 @@ class SettingsServiceProvider extends ServiceProvider
$this->applyAppConfig($configRepository);
$this->applyDebugConfig($configRepository);
$this->applyDriverConfig($configRepository);
- $this->applyCacheConfig($configRepository);
$this->applyUploadConfig($configRepository);
$this->applyCoreUpdateConfig($configRepository);
$this->applyGeoIpConfig($configRepository);
@@ -342,6 +342,16 @@ class SettingsServiceProvider extends ServiceProvider
Config::set('filesystems.default', $driverSettings['storage_driver']);
}
+ // 코어 첨부 업로드 디스크: ATTACHMENT_DISK env 명시가 항상 우선하고,
+ // 미설정 시 storage_driver=s3 를 따른다. env() 직접 호출은 config:cache 환경에서
+ // null 로 고정되므로 config 에 태운 attachment.disk_explicit 로 판별한다.
+ // 빈 문자열도 미명시로 취급한다 — `ATTACHMENT_DISK=` 가 복사된 .env 에서
+ // 빈 값을 명시로 읽으면 전환이 영구 미발동한다 (config 정규화의 2차 방어).
+ // 기존 행은 행 disk 로 서빙되므로 신구 디스크 혼재는 안전하다.
+ if (($driverSettings['storage_driver'] ?? null) === 's3' && in_array(config('attachment.disk_explicit'), [null, ''], true)) {
+ Config::set('attachment.disk', 's3');
+ }
+
// 웹소켓 설정
$this->applyWebsocketConfig($driverSettings);
@@ -359,6 +369,16 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function applyRedisConfig(array $driverSettings): void
{
+ // phpredis 확장이 없는 서버에서 redis 드라이버 선택 시 `Class "Redis" not found` 로
+ // 사이트 전면 다운되는 결함 방어 — 설정된 클라이언트가 phpredis 인데 확장이 없고
+ // predis 가 있으면 predis 로 폴백한다. 확장이 있으면 기존 phpredis 경로 그대로다.
+ // env('REDIS_CLIENT') 미명시 판별은 무효였다: .env.example 이 REDIS_CLIENT=phpredis
+ // 를 활성 배포하므로 표준 설치에서 영구 미발동이었고, env() 직접 호출은
+ // config:cache 환경에서 null 로 고정된다 (A8 disk_explicit 와 동형 함정).
+ if ($this->shouldFallBackToPredis(extension_loaded('redis'))) {
+ Config::set('database.redis.client', 'predis');
+ }
+
if (! empty($driverSettings['redis_host'])) {
Config::set('database.redis.default.host', $driverSettings['redis_host']);
Config::set('database.redis.cache.host', $driverSettings['redis_host']);
@@ -380,6 +400,25 @@ class SettingsServiceProvider extends ServiceProvider
}
}
+ /**
+ * Redis 클라이언트를 predis 로 폴백해야 하는지 판정합니다.
+ *
+ * 설정된 클라이언트(config — env 시점 값이 config:cache 에도 박제됨)가 phpredis 를
+ * 가리키는데 확장이 로드되어 있지 않고 predis 가 존재하면 참. phpredis 명시 설정이라도
+ * 확장이 없으면 어차피 동작 불가이므로 predis 전환이 유일한 동작 경로다 (#99 A2).
+ * 확장 로드 여부는 인자로 받는다 — 확장 설치 머신에서 부재 상태를 재현할 수 없어
+ * 판정 자체를 단위 검증 가능하게 분리한 것 (테스트가 양 분기를 주입 검증).
+ *
+ * @param bool $phpredisLoaded phpredis 확장 로드 여부 (extension_loaded('redis'))
+ * @return bool predis 로 폴백해야 하면 true
+ */
+ private function shouldFallBackToPredis(bool $phpredisLoaded): bool
+ {
+ return config('database.redis.client', 'phpredis') !== 'predis'
+ && ! $phpredisLoaded
+ && class_exists(Client::class);
+ }
+
/**
* Memcached 연결 설정을 적용합니다.
*/
@@ -418,6 +457,16 @@ class SettingsServiceProvider extends ServiceProvider
if (! empty($driverSettings['s3_url'])) {
Config::set('filesystems.disks.s3.url', $driverSettings['s3_url']);
}
+
+ // S3 호환 스토리지(R2/MinIO/NCP 등)의 API 요청 대상 — s3_url(공개 URL base)과 별개 축이다.
+ // endpoint 미주입 시 SDK 는 AWS 리전 도메인으로만 요청하므로 호환 스토리지 연결이 불가능하다.
+ if (! empty($driverSettings['s3_endpoint'])) {
+ Config::set('filesystems.disks.s3.endpoint', $driverSettings['s3_endpoint']);
+ }
+
+ if (! empty($driverSettings['s3_use_path_style'])) {
+ Config::set('filesystems.disks.s3.use_path_style_endpoint', true);
+ }
}
/**
@@ -619,26 +668,6 @@ class SettingsServiceProvider extends ServiceProvider
Config::set('settings.identity', $settings);
}
- /**
- * 캐시 설정을 Laravel config에 적용합니다.
- */
- private function applyCacheConfig(JsonConfigRepository $configRepository): void
- {
- $cacheSettings = $configRepository->getCategory('cache');
-
- if (empty($cacheSettings)) {
- return;
- }
-
- if (! empty($cacheSettings['driver'])) {
- Config::set('cache.default', $cacheSettings['driver']);
- }
-
- if (! empty($cacheSettings['prefix'])) {
- Config::set('cache.prefix', $cacheSettings['prefix']);
- }
- }
-
/**
* 업로드 설정을 Laravel config에 적용합니다.
*/
@@ -650,10 +679,6 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
- if (! empty($uploadSettings['disk'])) {
- Config::set('filesystems.default', $uploadSettings['disk']);
- }
-
// 관리자 설정은 MB, config/attachment.* 는 KB — 변환은 이 지점 단 한 곳에서만 수행한다.
// (기존에는 존재하지 않는 키 `max_size` 를 읽어 설정이 어디에도 반영되지 않았다)
if (! empty($uploadSettings['max_file_size'])) {
diff --git a/app/Services/AttachmentService.php b/app/Services/AttachmentService.php
index 540a4c46..e0c517e5 100644
--- a/app/Services/AttachmentService.php
+++ b/app/Services/AttachmentService.php
@@ -270,12 +270,15 @@ class AttachmentService
/**
* 이미지 파일 정보 조회 (캐싱 응답용)
*
- * 권한 체크 후 파일 경로와 메타 정보를 반환합니다.
- * 컨트롤러에서 fileResponse()로 캐싱 헤더와 함께 응답할 수 있습니다.
+ * 권한 체크 후 인라인 스트림 응답과 캐싱용 메타를 반환합니다.
+ * 로컬 절대 경로 조립(getBasePath) 방식은 S3 등 원격 디스크 행에서 성립하지
+ * 않으므로(#99 — filemtime stat 실패 500), 행 disk 를 그대로 따르는
+ * StorageInterface::response() 스트림으로 서빙합니다. 컨트롤러는
+ * streamedFileResponse() 로 캐싱 헤더(ETag/304)를 입혀 응답합니다.
*
* @param string $hash 첨부파일 해시
* @param User|null $user 요청한 사용자 (null이면 비로그인)
- * @return array{path: string, mime_type: string, filename: string}|null 파일 정보 또는 null
+ * @return array{response: StreamedResponse, etag_source: string, mime_type: string, filename: string}|null 서빙 정보 또는 null
*
* @throws AuthorizationException 기능 레벨 권한이 없는 경우
*/
@@ -293,9 +296,19 @@ class AttachmentService
// 필터 훅 - 다운로드 전 처리
$attachment = HookManager::applyFilters('core.attachment.before_download', $attachment, $user);
- // 파일 존재 확인
- $diskStorage = $this->storage->withDisk($attachment->disk);
- if (! $diskStorage->exists('', $attachment->path)) {
+ // 행 disk 기준 인라인 스트림 (존재 검사는 response() 내부에서 수행)
+ // Content-Type/Length 를 행 메타로 선지정해 원격 디스크의 추가 메타 조회를 생략한다.
+ $response = $this->storage->withDisk($attachment->disk)->response(
+ '',
+ $attachment->path,
+ $attachment->original_filename,
+ [
+ 'Content-Type' => $attachment->mime_type,
+ 'Content-Length' => (string) $attachment->size,
+ ]
+ );
+
+ if (! $response) {
Log::error('첨부파일 스토리지에 없음', [
'attachment_id' => $attachment->id,
'path' => $attachment->path,
@@ -305,7 +318,14 @@ class AttachmentService
}
return [
- 'path' => $diskStorage->getBasePath('').DIRECTORY_SEPARATOR.$attachment->path,
+ 'response' => $response,
+ // 파일 stat 없이 결정적인 ETag 소스 (업로드 파일은 경로당 불변)
+ 'etag_source' => implode('|', [
+ $attachment->disk,
+ $attachment->path,
+ (string) $attachment->updated_at?->getTimestamp(),
+ (string) $attachment->size,
+ ]),
'mime_type' => $attachment->mime_type,
'filename' => $attachment->original_filename,
];
diff --git a/app/Services/DriverConnectionTester.php b/app/Services/DriverConnectionTester.php
index 121ab437..f69fc939 100644
--- a/app/Services/DriverConnectionTester.php
+++ b/app/Services/DriverConnectionTester.php
@@ -8,6 +8,7 @@ use Aws\S3\S3Client;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
+use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use Predis\Client;
/**
@@ -101,9 +102,19 @@ class DriverConnectionTester
];
}
+ // Flysystem S3 어댑터 존재 선검사 — 테스트는 SDK 직접 경로지만 실제 파일 I/O 는
+ // Storage::disk('s3') → 어댑터를 경유하므로, 어댑터가 없으면 테스트가 성공해도
+ // 실경로가 즉사한다 (#99 의 결함 은폐 구조). sdk_missing 검사와 대칭.
+ if (! class_exists(AwsS3V3Adapter::class)) {
+ return [
+ 'success' => false,
+ 'message' => __('settings.s3_adapter_missing'),
+ ];
+ }
+
$startTime = microtime(true);
- $client = new S3Client([
+ $clientOptions = [
'version' => 'latest',
'region' => $region,
'credentials' => [
@@ -114,7 +125,18 @@ class DriverConnectionTester
'timeout' => 5,
'connect_timeout' => 3,
],
- ]);
+ ];
+
+ // S3 호환 스토리지(R2/MinIO 등) — 실경로(applyS3Config 주입)와 동일하게
+ // endpoint/path-style 을 반영해야 테스트가 실제 연결 대상과 일치한다.
+ if (! empty($config['s3_endpoint'])) {
+ $clientOptions['endpoint'] = $config['s3_endpoint'];
+ }
+ if (! empty($config['s3_use_path_style'])) {
+ $clientOptions['use_path_style_endpoint'] = true;
+ }
+
+ $client = new S3Client($clientOptions);
// 버킷 존재 확인
$client->headBucket(['Bucket' => $bucket]);
@@ -372,15 +394,53 @@ class DriverConnectionTester
* @return array 테스트 결과
*/
public function testWebsocket(array $config): array
+ {
+ $clientHost = $config['websocket_host'] ?? 'localhost';
+ $clientPort = (int) ($config['websocket_port'] ?? 8080);
+ $clientScheme = $config['websocket_scheme'] ?? 'https';
+
+ // 서버(백엔드 broadcast HTTP API) endpoint — 빈 값은 클라이언트 값으로 폴백한다
+ // (SettingsServiceProvider::applyWebsocketConfig 와 동일 규칙). 백엔드 송신은
+ // server endpoint 를 쓰므로 client 만 검사하면 "테스트 성공 + 실제 발송 실패" 가 된다.
+ $serverHost = ($config['websocket_server_host'] ?? '') !== '' ? $config['websocket_server_host'] : $clientHost;
+ $serverPort = (int) ($config['websocket_server_port'] ?? 0) > 0 ? (int) $config['websocket_server_port'] : $clientPort;
+ $serverScheme = ($config['websocket_server_scheme'] ?? '') !== '' ? $config['websocket_server_scheme'] : $clientScheme;
+
+ $clientUrl = sprintf('%s://%s:%d', $clientScheme, $clientHost, $clientPort);
+ $serverUrl = sprintf('%s://%s:%d', $serverScheme, $serverHost, $serverPort);
+
+ // 클라이언트 endpoint 검사
+ $clientResult = $this->probeWebsocketEndpoint($clientUrl);
+
+ if (! $clientResult['success']) {
+ return $clientResult;
+ }
+
+ // 서버 endpoint 검사 (클라이언트와 동일 주소면 중복 요청 생략)
+ if ($serverUrl !== $clientUrl) {
+ $serverResult = $this->probeWebsocketEndpoint($serverUrl);
+
+ if (! $serverResult['success']) {
+ return [
+ 'success' => false,
+ 'message' => __('settings.websocket_server_test_failed'),
+ 'error' => $serverResult['error'] ?? ($serverResult['message'] ?? ''),
+ ];
+ }
+ }
+
+ return $clientResult;
+ }
+
+ /**
+ * Websocket(Reverb) HTTP endpoint 하나를 probe 합니다.
+ *
+ * @param string $url 검사할 endpoint URL
+ * @return array 테스트 결과
+ */
+ private function probeWebsocketEndpoint(string $url): array
{
try {
- $host = $config['websocket_host'] ?? 'localhost';
- $port = (int) ($config['websocket_port'] ?? 8080);
- $scheme = $config['websocket_scheme'] ?? 'https';
-
- // Reverb 서버 상태 확인 (기본 HTTP 엔드포인트)
- $url = sprintf('%s://%s:%d', $scheme, $host, $port);
-
// Reverb 는 통상 localhost·사내 IP 에서 동작하므로 사설 주소 자체는 정상 구성이다.
// 다만 이 진단 요청을 임의 목적지 탐색에 전용하지 못하도록, 정상 설정에서는 나올 수
// 없는 값(userinfo 위장, http/https 이외 scheme, 제어문자 주입)은 거부한다.
@@ -426,6 +486,7 @@ class DriverConnectionTester
];
} catch (\Exception $e) {
Log::warning('Websocket connection test failed', [
+ 'url' => $url,
'error' => $e->getMessage(),
]);
diff --git a/app/Services/DriverRegistryService.php b/app/Services/DriverRegistryService.php
index 36cc9a11..209b4fad 100644
--- a/app/Services/DriverRegistryService.php
+++ b/app/Services/DriverRegistryService.php
@@ -5,7 +5,8 @@ namespace App\Services;
use App\Extension\HookManager;
use App\Repositories\JsonConfigRepository;
use Illuminate\Support\Facades\Lang;
-use Illuminate\Support\Facades\Log;
+use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
+use Predis\Client;
/**
* 코어 드라이버 레지스트리 서비스
@@ -59,7 +60,10 @@ class DriverRegistryService
'cache' => 'cache.default',
'session' => 'session.driver',
'queue' => 'queue.default',
- 'log' => 'logging.default',
+ // 실제 적용 키 — SettingsServiceProvider::applyLogConfig() 가 기록하는 키와 동일해야
+ // 플러그인 로그 드라이버 폴백이 실효한다 (logging.default 는 어디에서도 읽지 않는 죽은 키였다).
+ // 이 키의 값은 채널 "배열" 이다 — getConfigValueForDriver() 로 형태를 맞춘다.
+ 'log' => 'logging.channels.stack.channels',
'websocket' => 'broadcasting.default',
'mail' => 'mail.default',
];
@@ -75,7 +79,9 @@ class DriverRegistryService
'session' => ['category' => 'drivers', 'key' => 'session_driver'],
'queue' => ['category' => 'drivers', 'key' => 'queue_driver'],
'log' => ['category' => 'drivers', 'key' => 'log_driver'],
- 'websocket' => ['category' => 'drivers', 'key' => 'websocket_driver'],
+ // websocket 은 드라이버 ID 선택 설정이 없다 (불리언 websocket_enabled 뿐) —
+ // 종전의 'websocket_driver' 는 어떤 저장 경로에도 없는 유령 키라 항상 skip 이었다.
+ // 카테고리 제외로 같은 동작을 명시화한다 (getSettingsKey('websocket') === null).
'mail' => ['category' => 'mail', 'key' => 'mailer'],
];
@@ -237,6 +243,70 @@ class DriverRegistryService
return $inUse;
}
+ /**
+ * 주어진 드라이버가 현재 서버에서 실제로 동작 가능한지 판정합니다.
+ *
+ * isDriverAvailable() 이 "등록 여부"(카탈로그 소속)를 보는 것과 달리, 이 메서드는
+ * 어댑터 클래스·PHP 확장 등 런타임 능력의 존재를 검사합니다. 사용 불능 드라이버가
+ * 저장되면 다음 부팅에서 사이트 전면 다운으로 이어질 수 있으므로 저장/테스트
+ * FormRequest 의 서버 게이트가 이 판정을 사용합니다.
+ *
+ * @param string $category 드라이버 카테고리 (storage, cache, session, queue 등)
+ * @param string $driverId 드라이버 ID
+ * @return bool 동작 가능하면 true
+ */
+ public function isDriverUsable(string $category, string $driverId): bool
+ {
+ return $this->usabilityFailureReason($category, $driverId) === null;
+ }
+
+ /**
+ * 드라이버 사용 불능 사유를 반환합니다.
+ *
+ * 판정은 드라이버 ID 가 요구하는 런타임 능력 기준입니다. 코어가 능력을 모르는
+ * 드라이버(플러그인 등록 드라이버 포함)는 null(사용 가능) — 플러그인 드라이버의
+ * 가용성은 그 플러그인의 ServiceProvider 가 보증합니다.
+ *
+ * @param string $category 드라이버 카테고리
+ * @param string $driverId 드라이버 ID
+ * @return string|null 사용 불능 사유 (다국어), 사용 가능하면 null
+ */
+ public function usabilityFailureReason(string $category, string $driverId): ?string
+ {
+ return match ($driverId) {
+ 's3' => class_exists(AwsS3V3Adapter::class)
+ ? null
+ : __('settings.driver_unusable_s3_adapter'),
+ // redis 는 설정된 클라이언트 기준으로 판정한다 — predis 명시 설정이면 predis
+ // 존재만 본다. phpredis 설정은 확장 부재여도 부트 폴백(SettingsServiceProvider::
+ // shouldFallBackToPredis)이 predis 로 전환하므로 predis 존재까지 사용 가능이다.
+ 'redis' => (config('database.redis.client', 'phpredis') === 'predis'
+ ? class_exists(Client::class)
+ : (extension_loaded('redis') || class_exists(Client::class)))
+ ? null
+ : __('settings.driver_unusable_redis_client'),
+ 'memcached' => extension_loaded('memcached')
+ ? null
+ : __('settings.driver_unusable_memcached_extension'),
+ default => null,
+ };
+ }
+
+ /**
+ * 카테고리의 Config 키에 기록할 값 형태로 드라이버 ID 를 변환합니다.
+ *
+ * log 카테고리의 적용 키(logging.channels.stack.channels)는 채널 배열이므로
+ * 배열로 감싸고, 그 외 카테고리는 드라이버 ID 문자열 그대로입니다.
+ *
+ * @param string $category 드라이버 카테고리
+ * @param string $driverId 드라이버 ID
+ * @return string|array Config 기록 값
+ */
+ public function getConfigValueForDriver(string $category, string $driverId): string|array
+ {
+ return $category === 'log' ? [$driverId] : $driverId;
+ }
+
/**
* 지원되는 카테고리 목록을 반환합니다.
*
diff --git a/app/Services/TemplateLayoutAttachmentService.php b/app/Services/TemplateLayoutAttachmentService.php
index b62e5aa9..5630f0bd 100644
--- a/app/Services/TemplateLayoutAttachmentService.php
+++ b/app/Services/TemplateLayoutAttachmentService.php
@@ -13,6 +13,7 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
+use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 템플릿 레이아웃 첨부 파일 서비스
@@ -169,17 +170,17 @@ class TemplateLayoutAttachmentService
}
/**
- * 서빙 가능한 첨부 파일의 절대 경로를 돌려줍니다.
+ * 서빙 가능한 첨부의 인라인 스트림 응답과 캐싱 메타를 돌려줍니다.
*
- * 첨부가 주어진 템플릿 소속인지 검증하고, 스토리지에 파일이 실제로 존재하면
- * 컨트롤러가 스트림할 수 있는 절대 파일시스템 경로를 반환한다. 소속 불일치 /
- * 파일 부재 시 null (컨트롤러가 404).
+ * 첨부가 주어진 템플릿 소속인지 검증하고, 행 disk 를 따르는 스토리지 스트림
+ * 응답을 반환한다 (S3 등 원격 디스크 행 포함 — 로컬 절대 경로 방식은 #99 에서
+ * filemtime 500). 소속 불일치 / 파일 부재 시 null (컨트롤러가 404).
*
* @param string $templateIdentifier 요청 경로의 템플릿 식별자
* @param TemplateLayoutAttachment $attachment 서빙 대상 첨부
- * @return string|null 절대 파일 경로 또는 null
+ * @return array{response: StreamedResponse, etag_source: string}|null 서빙 정보 또는 null
*/
- public function getServableFilePath(string $templateIdentifier, TemplateLayoutAttachment $attachment): ?string
+ public function getServableResponse(string $templateIdentifier, TemplateLayoutAttachment $attachment): ?array
{
$template = $this->templateRepository->findByIdentifier($templateIdentifier);
// 경로 식별자와 첨부의 소속 템플릿이 일치해야 한다 (교차 템플릿 접근 차단).
@@ -187,12 +188,31 @@ class TemplateLayoutAttachmentService
return null;
}
- $driver = $this->storage->withDisk($attachment->disk);
- if (! $driver->exists(self::STORAGE_CATEGORY, $attachment->path)) {
+ // 행 disk 기준 인라인 스트림 (존재 검사는 response() 내부에서 수행).
+ // 로컬 절대 경로 조립(getBasePath)은 S3 등 원격 디스크 행에서 성립하지 않는다 (#99).
+ $response = $this->storage->withDisk($attachment->disk)->response(
+ self::STORAGE_CATEGORY,
+ $attachment->path,
+ $attachment->original_name ?? basename($attachment->path),
+ [
+ 'Content-Type' => $attachment->mime_type,
+ 'Content-Length' => (string) $attachment->size,
+ ]
+ );
+
+ if (! $response) {
return null;
}
- // 절대 경로 — {category base}/{relative path}. fileResponse 가 절대 경로를 요구.
- return rtrim($driver->getBasePath(self::STORAGE_CATEGORY), '/\\').'/'.$attachment->path;
+ return [
+ 'response' => $response,
+ // 파일 stat 없이 결정적인 ETag 소스 (업로드 파일은 경로당 불변)
+ 'etag_source' => implode('|', [
+ $attachment->disk,
+ $attachment->path,
+ (string) $attachment->updated_at?->getTimestamp(),
+ (string) $attachment->size,
+ ]),
+ ];
}
}
diff --git a/app/Support/ApiDoc/ParameterDescriber.php b/app/Support/ApiDoc/ParameterDescriber.php
index 42837901..7986ff97 100644
--- a/app/Support/ApiDoc/ParameterDescriber.php
+++ b/app/Support/ApiDoc/ParameterDescriber.php
@@ -178,10 +178,12 @@ class ParameterDescriber
'memcached_host' => 'Memcached 호스트 주소',
'memcached_port' => 'Memcached 포트 번호',
's3_bucket' => 'S3 버킷명',
- 's3_region' => 'S3 리전',
+ 's3_region' => 'S3 리전 (소문자 영숫자·하이픈 — AWS 리전 코드 또는 S3 호환 스토리지 값, R2 는 auto)',
's3_access_key' => 'S3 액세스 키',
's3_secret_key' => 'S3 시크릿 키',
- 's3_url' => 'S3 엔드포인트 URL',
+ 's3_url' => 'S3 공개 URL(CDN) base — 파일 URL 생성용 (API 요청 주소 아님)',
+ 's3_endpoint' => 'S3 API 엔드포인트 — S3 호환 스토리지(R2/MinIO 등)용, AWS S3 는 미입력',
+ 's3_use_path_style' => 'S3 path-style 주소 사용 여부 (MinIO 등)',
'ses_key' => 'SES 액세스 키',
'ses_secret' => 'SES 시크릿 키',
'ses_region' => 'SES 리전',
diff --git a/composer.json b/composer.json
index 2c69d7e3..04624205 100644
--- a/composer.json
+++ b/composer.json
@@ -19,6 +19,8 @@
"laravel/sanctum": "^4.2",
"laravel/scout": "^11.1",
"laravel/tinker": "^2.10.1",
+ "league/flysystem-aws-s3-v3": "^3.25.1",
+ "predis/predis": "^2.0",
"symfony/http-client": "^7.4",
"symfony/mailgun-mailer": "^7.4"
},
diff --git a/composer.lock b/composer.lock
index 97c428c9..9228fc16 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "675518b422a5d9dc29590b1f22a49c25",
+ "content-hash": "f34469561e87f11296bf77ecc5db9fbf",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -2547,6 +2547,61 @@
},
"time": "2026-06-25T06:52:23+00:00"
},
+ {
+ "name": "league/flysystem-aws-s3-v3",
+ "version": "3.35.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
+ "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4",
+ "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4",
+ "shasum": ""
+ },
+ "require": {
+ "aws/aws-sdk-php": "^3.371.5",
+ "league/flysystem": "^3.10.0",
+ "league/mime-type-detection": "^1.0.0",
+ "php": "^8.0.2"
+ },
+ "conflict": {
+ "guzzlehttp/guzzle": "<7.0",
+ "guzzlehttp/ringphp": "<1.1.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "League\\Flysystem\\AwsS3V3\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Frank de Jonge",
+ "email": "info@frankdejonge.nl"
+ }
+ ],
+ "description": "AWS S3 filesystem adapter for Flysystem.",
+ "keywords": [
+ "Flysystem",
+ "aws",
+ "file",
+ "files",
+ "filesystem",
+ "s3",
+ "storage"
+ ],
+ "support": {
+ "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2"
+ },
+ "time": "2026-07-01T23:25:49+00:00"
+ },
{
"name": "league/flysystem-local",
"version": "3.31.0",
@@ -3601,6 +3656,68 @@
],
"time": "2025-12-27T19:41:33+00:00"
},
+ {
+ "name": "predis/predis",
+ "version": "v2.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/predis/predis.git",
+ "reference": "07105e050622ed80bd60808367ced9e379f31530"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/predis/predis/zipball/07105e050622ed80bd60808367ced9e379f31530",
+ "reference": "07105e050622ed80bd60808367ced9e379f31530",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.3",
+ "phpstan/phpstan": "^1.9",
+ "phpunit/phpcov": "^6.0 || ^8.0",
+ "phpunit/phpunit": "^8.0 || ^9.4"
+ },
+ "suggest": {
+ "ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Predis\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Till Krüss",
+ "homepage": "https://till.im",
+ "role": "Maintainer"
+ }
+ ],
+ "description": "A flexible and feature-complete Redis/Valkey client for PHP.",
+ "homepage": "http://github.com/predis/predis",
+ "keywords": [
+ "nosql",
+ "predis",
+ "redis"
+ ],
+ "support": {
+ "issues": "https://github.com/predis/predis/issues",
+ "source": "https://github.com/predis/predis/tree/v2.4.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/tillkruss",
+ "type": "github"
+ }
+ ],
+ "time": "2025-11-12T18:00:11+00:00"
+ },
{
"name": "psr/clock",
"version": "1.0.0",
@@ -10573,5 +10690,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/config/attachment.php b/config/attachment.php
index 75e64a1a..43c2f5f9 100644
--- a/config/attachment.php
+++ b/config/attachment.php
@@ -9,8 +9,26 @@ return [
| 첨부파일 저장에 사용할 디스크를 지정합니다.
| config/filesystems.php에 정의된 디스크 이름을 사용합니다.
|
+ | 빈 값(`ATTACHMENT_DISK=`)은 미설정으로 간주한다 — env() 는 키가 존재하면
+ | 빈 문자열을 그대로 돌려주므로(기본값 미적용), `cp .env.example .env` 설치
+ | 절차에서 빈 값이 그대로 오면 Storage::disk('') 로 업로드가 전면 실패한다.
+ |
*/
- 'disk' => env('ATTACHMENT_DISK', 'attachments'),
+ 'disk' => env('ATTACHMENT_DISK') ?: 'attachments',
+
+ /*
+ |--------------------------------------------------------------------------
+ | 디스크 명시 설정 여부 (내부 판별용)
+ |--------------------------------------------------------------------------
+ |
+ | ATTACHMENT_DISK env 가 명시되었는지 판별하기 위한 키. env() 직접 호출은
+ | config:cache 환경에서 항상 null 이므로, config 캐시에 태워 두고 이 값으로
+ | 판별한다. null 이면 미명시 — 관리자 환경설정의 storage_driver(s3) 가
+ | 업로드 디스크를 전환할 수 있다 (SettingsServiceProvider::applyDriverConfig).
+ | 빈 값(`ATTACHMENT_DISK=`)도 미명시로 정규화한다 (위 disk 의 사유와 동일).
+ |
+ */
+ 'disk_explicit' => env('ATTACHMENT_DISK') ?: null,
/*
|--------------------------------------------------------------------------
diff --git a/config/filesystems.php b/config/filesystems.php
index 56165197..a2f3655b 100644
--- a/config/filesystems.php
+++ b/config/filesystems.php
@@ -99,8 +99,11 @@ return [
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
- 'url' => env('AWS_URL'),
- 'endpoint' => env('AWS_ENDPOINT'),
+ // 빈 값(`AWS_URL=`/`AWS_ENDPOINT=`)은 미설정으로 정규화 — env() 는 키가
+ // 존재하면 빈 문자열을 돌려주므로, '' 가 endpoint 로 주입되면 S3 클라이언트
+ // 구성이 깨진다 (cp .env.example 설치 절차 방어).
+ 'url' => env('AWS_URL') ?: null,
+ 'endpoint' => env('AWS_ENDPOINT') ?: null,
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => true,
'report' => false,
diff --git a/config/settings/defaults.json b/config/settings/defaults.json
index 2d57483e..9a915a66 100644
--- a/config/settings/defaults.json
+++ b/config/settings/defaults.json
@@ -123,6 +123,8 @@
"s3_access_key": "",
"s3_secret_key": "",
"s3_url": "",
+ "s3_endpoint": "",
+ "s3_use_path_style": false,
"cache_driver": "file",
"redis_host": "127.0.0.1",
"redis_port": 6379,
@@ -305,6 +307,8 @@
"s3_access_key": { "type": "string", "sensitive": true },
"s3_secret_key": { "type": "string", "sensitive": true },
"s3_url": { "type": "string", "sensitive": false },
+ "s3_endpoint": { "type": "string", "sensitive": false },
+ "s3_use_path_style": { "type": "boolean", "sensitive": false },
"cache_driver": { "type": "string", "sensitive": false },
"redis_host": { "type": "string", "sensitive": false },
"redis_port": { "type": "integer", "sensitive": false },
@@ -328,7 +332,8 @@
"websocket_verify_ssl": { "type": "boolean", "sensitive": false },
"websocket_server_host": { "type": "string", "sensitive": false },
"websocket_server_port": { "type": "integer", "sensitive": false },
- "websocket_server_scheme": { "type": "string", "sensitive": false }
+ "websocket_server_scheme": { "type": "string", "sensitive": false },
+ "search_engine_driver": { "type": "string", "sensitive": false }
}
},
"notifications": {
diff --git a/docs/backend/admin-settings-access.md b/docs/backend/admin-settings-access.md
index 3d0138d1..a11c9df9 100644
--- a/docs/backend/admin-settings-access.md
+++ b/docs/backend/admin-settings-access.md
@@ -41,7 +41,8 @@
| `drivers.search_engine_driver` | `scout.driver` |
| `drivers.redis_*` | `database.redis.*` |
| `drivers.memcached_*` | `cache.stores.memcached.*` |
-| `drivers.s3_*` | `filesystems.disks.s3.*` |
+| `drivers.s3_*` | `filesystems.disks.s3.*` — `s3_url` → `url`(공개 URL base), `s3_endpoint` → `endpoint`(API 요청 대상), `s3_use_path_style` → `use_path_style_endpoint` |
+| `drivers.storage_driver` = `s3` | `attachment.disk` = `s3` (단, `ATTACHMENT_DISK` env 명시 시 env 우선 — `attachment.disk_explicit` 로 판별) |
| `geoip.feature_enabled`, `geoip.license_key`, `geoip.auto_update_enabled` | `geoip.*` |
위 매핑은 [`app/Providers/SettingsServiceProvider.php`](../../app/Providers/SettingsServiceProvider.php) 가 단일 SSoT.
diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md
index f8c1695e..e5a8c409 100644
--- a/docs/backend/api/settings.md
+++ b/docs/backend/api/settings.md
@@ -72,11 +72,13 @@ _단건 응답: `data` 객체의 필드._
| notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 탭 설정 그룹. channels 는 알림 채널 목록으로 각 원소가 id(채널 식별자)·is_active(활성 여부)·sort_order(표시 순서)를 가짐 |
| identity | object | `{"default_provider":"g7:core.mail","purpose_providers":{"…` | 본인인증(IDV) 탭 설정 그룹 (기본 provider·목적별 provider 매핑(purpose_providers)·챌린지 유효시간(분)·최대 시도 횟수) |
| available_drivers | object | `{"storage":[{"id":"local","label":{"ko":"로컬","en":"Local"…` | 드라이버 선택지 카탈로그 (DriverRegistryService 산물). 종류별(storage/cache/session/queue 등) 선택 가능한 드라이버 목록을 id/다국어 label 형태로 제공 |
-| _meta | object | `{"limits":{"upload_max_file_size_min":1,"upload_max_file_…` | |
+| _meta | object | `{"limits":{"upload_max_file_size_min":1,"upload_max_file_…` | 화면 검증 메타 — `limits` 는 각 설정 항목의 min/max 경계값 맵 (`config/core.php` 의 `settings_limits` 가 SSoT, 화면 입력 힌트와 FormRequest 검증이 같은 값을 공유) |
| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
**응답 예시**
+
+
```http
HTTP/1.1 200
```
@@ -120,19 +122,19 @@ HTTP/1.1 200
"webp",
"... (총 11건 중 5건 표시)"
],
- "image_max_width": "800",
- "image_max_height": "600",
+ "image_max_width": 2000,
+ "image_max_height": 2000,
"image_quality": 85
},
"seo": {
- "meta_title_suffix": null,
- "meta_description": null,
- "meta_keywords": null,
- "google_analytics_id": null,
- "google_site_verification": null,
- "...": "(24개 키 생략, 총 29개)"
+ "meta_title_suffix": "",
+ "meta_description": "",
+ "meta_keywords": "",
+ "google_analytics_id": "",
+ "google_site_verification": "",
+ "...": "(23개 키 생략, 총 28개)"
},
- "...": "(11개 키 생략, 총 16개)"
+ "...": "(12개 키 생략, 총 17개)"
}
}
```
@@ -243,10 +245,12 @@ HTTP/1.1 200
| advanced.geoip_auto_update_enabled | body | boolean | 아니오 | — | GeoIP DB 자동 업데이트 사용 여부 (주 1회 자동 재다운로드) |
| drivers.storage_driver | body | string | 아니오 | — | 스토리지 드라이버 (local/s3) |
| drivers.s3_bucket | body | string | 아니오 | max 255 | S3 버킷명 |
-| drivers.s3_region | body | string | 아니오 | — | S3 리전 |
+| drivers.s3_region | body | string | 아니오 | max 64, 소문자 영숫자·하이픈 (`^[a-z0-9-]+$`) | S3 리전 — AWS 리전 코드 또는 S3 호환 스토리지 값 (Cloudflare R2 는 `auto`, MinIO 관례는 `us-east-1`) |
| drivers.s3_access_key | body | string | 아니오 | max 255 | S3 액세스 키 |
| drivers.s3_secret_key | body | string | 아니오 | max 255 | S3 시크릿 키 |
-| drivers.s3_url | body | string | 아니오 | max 500 | S3 엔드포인트 URL |
+| drivers.s3_url | body | string | 아니오 | url, max 500 | S3 공개 URL(CDN) base — 파일 URL 생성에만 사용 (API 요청 주소 아님) |
+| drivers.s3_endpoint | body | string | 아니오 | url, max 500 | S3 API 엔드포인트 — S3 호환 스토리지(R2/MinIO/NCP 등)용. AWS S3 는 미입력 (예: `https://.r2.cloudflarestorage.com`) |
+| drivers.s3_use_path_style | body | boolean | 아니오 | — | S3 path-style 주소 사용 여부 — MinIO 등 path-style 전용 스토리지에서 true |
| drivers.cache_driver | body | string | 아니오 | — | 캐시 드라이버 (file/redis/memcached) |
| drivers.redis_host | body | string | 아니오 | max 255 | Redis 호스트 주소 |
| drivers.redis_port | body | integer | 아니오 | min 1, max 65535 | Redis 포트 번호 |
@@ -328,6 +332,7 @@ Content-Type: application/json
"general.language": "예시값",
"general.currency": "예시값",
"general.maintenance_mode": true,
+ "general.asset_url_mode": "https://example.com",
"general.site_logo": [
"예시값"
],
@@ -370,6 +375,11 @@ Content-Type: application/json
"seo.cache_ttl": 1,
"seo.sitemap_enabled": true,
"seo.sitemap_cache_ttl": 1,
+ "seo.sitemap_urls_per_file": 1,
+ "seo.sitemap_gzip": true,
+ "seo.sitemap_serve_stale_on_miss": true,
+ "seo.sitemap_max_urls_per_contributor": 1,
+ "seo.sitemap_hreflang_enabled": true,
"seo.sitemap_schedule": "예시값",
"seo.sitemap_schedule_time": "예시값",
"seo.generator_enabled": true,
@@ -379,6 +389,10 @@ Content-Type: application/json
"security.auth_token_lifetime": 1,
"security.max_login_attempts": 1,
"security.login_lockout_time": 1,
+ "security.password_min_length": 1,
+ "security.require_password_special_char": true,
+ "security.two_factor_auth": true,
+ "security.allow_internal_outbound_urls": true,
"advanced.cache_enabled": true,
"advanced.layout_cache_enabled": true,
"advanced.layout_cache_ttl": 1,
@@ -386,6 +400,7 @@ Content-Type: application/json
"advanced.stats_cache_ttl": 1,
"advanced.seo_cache_enabled": true,
"advanced.seo_cache_ttl": 1,
+ "advanced.seo_sitemap_cache_ttl": 1,
"advanced.debug_mode": true,
"advanced.sql_query_log": true,
"advanced.core_update_github_url": "https://example.com",
@@ -393,12 +408,16 @@ Content-Type: application/json
"advanced.geoip_enabled": true,
"advanced.geoip_license_key": "예시값",
"advanced.geoip_auto_update_enabled": true,
+ "advanced.pagination_result_cap": 1,
+ "advanced.pagination_max_page": 1,
"drivers.storage_driver": "예시값",
"drivers.s3_bucket": "예시값",
"drivers.s3_region": "예시값",
"drivers.s3_access_key": "예시값",
"drivers.s3_secret_key": "예시값",
"drivers.s3_url": "https://example.com",
+ "drivers.s3_endpoint": "예시값",
+ "drivers.s3_use_path_style": true,
"drivers.cache_driver": "예시값",
"drivers.redis_host": "예시값",
"drivers.redis_port": 1,
@@ -529,6 +548,8 @@ _단건 응답: `data` 객체의 필드._
**응답 예시**
+
+
```http
HTTP/1.1 200
```
@@ -795,6 +816,8 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
**응답 예시**
+
+
```http
HTTP/1.1 200
```
@@ -983,7 +1006,7 @@ _단건 응답: `data` 객체의 필드._
| php_memory_limit | string | `512M` | PHP `memory_limit` ini 값 |
| max_execution_time | string | `36000초` | PHP `max_execution_time` ini 값 (초 단위 접미사 부착) |
| upload_max_filesize | string | `2G` | PHP `upload_max_filesize` ini 값 |
-| opcache | object | `{"loaded":true,"enabled":true}` | |
+| opcache | object | `{"loaded":true,"enabled":true}` | PHP OPcache 상태 — `loaded`(확장 로드 여부) / `enabled`(런타임 활성화 여부, `opcache.enable` 설정 기준) |
| install_path | string | `C:\Users\HeuJung\htdocs\g7_2` | 애플리케이션 설치 루트 경로 (`base_path()`) |
| config_path | string | `C:\Users\HeuJung\htdocs\g7_2\storage\…` | 설정 파일 저장 경로 (`storage/app/settings`) |
| log_path | string | `C:\Users\HeuJung\htdocs\g7_2\storage\…` | 로그 파일 저장 경로 (`storage/logs`) |
@@ -995,6 +1018,8 @@ _단건 응답: `data` 객체의 필드._
**응답 예시**
+
+
```http
HTTP/1.1 200
```
@@ -1008,22 +1033,22 @@ HTTP/1.1 200
"web_server": "nginx/1.27.3",
"php_version": "8.3.26",
"mysql_version": "Mysql 8.4.3",
- "g7_version": "7.0.6",
+ "g7_version": "7.0.7",
"g7_release_year": "2026",
"laravel_version": "12.62.0",
- "environment": "local",
+ "environment": "production",
"cpu_info": "Intel(R) Core(TM) Ultra 5 225H",
"memory_usage": {
"total": "31.49 GB",
- "used": "24.31 GB",
- "free": "7.18 GB",
- "percentage": 77.2
+ "used": "28.24 GB",
+ "free": "3.25 GB",
+ "percentage": 89.69
},
"disk_usage": {
"total": "474.72 GB",
- "used": "408.15 GB",
- "free": "66.57 GB",
- "percentage": 85.98
+ "used": "375.43 GB",
+ "free": "99.28 GB",
+ "percentage": 79.09
},
"php_memory_limit": "512M",
"max_execution_time": "36000초",
@@ -1032,10 +1057,10 @@ HTTP/1.1 200
"loaded": true,
"enabled": true
},
- "install_path": "C:\\Users\\HeuJung\\htdocs\\g7_2",
- "config_path": "C:\\Users\\HeuJung\\htdocs\\g7_2\\storage\\app/settings",
- "log_path": "C:\\Users\\HeuJung\\htdocs\\g7_2\\storage\\logs",
- "upload_path": "C:\\Users\\HeuJung\\htdocs\\g7_2\\storage\\app/public",
+ "install_path": "C:\\Users\\HeuJung\\htdocs\\g7",
+ "config_path": "C:\\Users\\HeuJung\\htdocs\\g7\\storage\\app/settings",
+ "log_path": "C:\\Users\\HeuJung\\htdocs\\g7\\storage\\logs",
+ "upload_path": "C:\\Users\\HeuJung\\htdocs\\g7\\storage\\app/public",
"php_extensions": {
"required": {
"openssl": true,
@@ -1066,13 +1091,13 @@ HTTP/1.1 200
"write": {
"host": "localhost",
"port": 3306,
- "database": "g7_2",
- "username": "g7_2"
+ "database": "g7",
+ "username": "g7"
},
"read": []
},
"timezone": "UTC",
- "server_time": "2026-08-04 12:53:55"
+ "server_time": "2026-08-13 05:00:24"
}
}
```
@@ -1107,10 +1132,12 @@ HTTP/1.1 200
| queue_driver | body | string | 아니오 | — | 큐 드라이버 (sync/database/redis) |
| websocket_enabled | body | boolean | 아니오 | — | WebSocket 사용 여부 |
| s3_bucket | body | string | 아니오 | max 255 | S3 버킷명 |
-| s3_region | body | string | 아니오 | — | S3 리전 |
+| s3_region | body | string | 아니오 | max 64, 소문자 영숫자·하이픈 (`^[a-z0-9-]+$`) | S3 리전 — AWS 리전 코드 또는 S3 호환 스토리지 값 (Cloudflare R2 는 `auto`) |
| s3_access_key | body | string | 아니오 | max 255 | S3 액세스 키 |
| s3_secret_key | body | string | 아니오 | max 255 | S3 시크릿 키 |
-| s3_url | body | string | 아니오 | max 500 | S3 엔드포인트 URL |
+| s3_url | body | string | 아니오 | url, max 500 | S3 공개 URL(CDN) base — 연결 테스트에는 사용되지 않음 |
+| s3_endpoint | body | string | 아니오 | url, max 500 | S3 API 엔드포인트 — 테스트 시 실제 아웃바운드 대상에 반영 (S3 호환 스토리지용) |
+| s3_use_path_style | body | boolean | 아니오 | — | S3 path-style 주소 사용 여부 (MinIO 등) |
| redis_host | body | string | 아니오 | max 255 | Redis 호스트 주소 |
| redis_port | body | integer | 아니오 | min 1, max 65535 | Redis 포트 번호 |
| redis_password | body | string | 아니오 | max 255 | Redis 비밀번호 |
@@ -1118,9 +1145,18 @@ HTTP/1.1 200
| memcached_host | body | string | 아니오 | max 255 | Memcached 호스트 주소 |
| memcached_port | body | integer | 아니오 | min 1, max 65535 | Memcached 포트 번호 |
| websocket_app_key | body | string | 아니오 | max 255 | WebSocket 앱 키 |
-| websocket_host | body | string | 아니오 | max 255 | WebSocket 호스트 주소 |
-| websocket_port | body | integer | 아니오 | min 1, max 65535 | WebSocket 포트 번호 |
-| websocket_scheme | body | string | 아니오 | — | WebSocket 스킴 (http/https) |
+| websocket_host | body | string | 아니오 | max 255 | WebSocket 클라이언트(브라우저 접속) 호스트 주소 |
+| websocket_port | body | integer | 아니오 | min 1, max 65535 | WebSocket 클라이언트 포트 번호 |
+| websocket_scheme | body | string | 아니오 | — | WebSocket 클라이언트 스킴 (http/https) |
+| websocket_server_host | body | string | 아니오 | max 255 | WebSocket 서버(백엔드 발송용) 호스트 주소 — 미입력 시 클라이언트 값으로 폴백 |
+| websocket_server_port | body | integer | 아니오 | min 1, max 65535 | WebSocket 서버 포트 번호 — 미입력 시 클라이언트 값으로 폴백 |
+| websocket_server_scheme | body | string | 아니오 | — | WebSocket 서버 스킴 (http/https) — 미입력 시 클라이언트 값으로 폴백 |
+
+> WebSocket 테스트는 클라이언트/서버 양측 endpoint 를 모두 probe 합니다. 백엔드 broadcast 는 서버 endpoint 를 사용하므로, 서버 endpoint 실패 시 별도 메시지(`settings.websocket_server_test_failed`)로 구분 보고됩니다.
+
+> S3 테스트는 Flysystem 어댑터(`league/flysystem-aws-s3-v3`) 존재를 선검사하며, `s3_endpoint`/`s3_use_path_style` 이 실제 아웃바운드 대상에 반영됩니다.
+
+> 사용 불능 드라이버(어댑터 클래스·PHP 확장 부재)는 저장/테스트 모두 422 (`validation.settings.driver_unusable`) 로 거부됩니다.
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.settings.test_driver_connection_validation_rules`).
@@ -1144,6 +1180,8 @@ Content-Type: application/json
"s3_access_key": "예시값",
"s3_secret_key": "예시값",
"s3_url": "https://example.com",
+ "s3_endpoint": "예시값",
+ "s3_use_path_style": true,
"redis_host": "예시값",
"redis_port": 1,
"redis_password": "Password123!",
@@ -1153,7 +1191,10 @@ Content-Type: application/json
"websocket_app_key": "예시값",
"websocket_host": "예시값",
"websocket_port": 1,
- "websocket_scheme": "예시값"
+ "websocket_scheme": "예시값",
+ "websocket_server_host": "예시값",
+ "websocket_server_port": 1,
+ "websocket_server_scheme": "예시값"
}
```
diff --git a/docs/extension/storage-driver.md b/docs/extension/storage-driver.md
index e26e09df..3a4139c5 100644
--- a/docs/extension/storage-driver.md
+++ b/docs/extension/storage-driver.md
@@ -25,6 +25,7 @@
- [마이그레이션 가이드](#마이그레이션-가이드)
- [API 레퍼런스](#api-레퍼런스)
- [트러블슈팅](#트러블슈팅)
+- [S3 호환 스토리지 연결](#s3-호환-스토리지-연결)
- [FAQ](#faq)
---
@@ -1245,6 +1246,24 @@ Route::get('/api/attachments/{id}/download', [AttachmentController::class, 'down
---
+## S3 호환 스토리지 연결
+
+코어 7.0.7+ 의 S3 드라이버는 AWS 뿐 아니라 S3 API 를 제공하는 호환 스토리지(Cloudflare R2, MinIO, 네이버 클라우드 등)에도 연결할 수 있다. 관리자 > 환경설정 > 드라이버 > 파일 스토리지의 두 항목이 이를 담당한다.
+
+| 항목 | 의미 | 예시 |
+| --- | --- | --- |
+| 엔드포인트 URL | SDK 가 요청을 보내는 API 주소. 비워 두면 AWS 리전 도메인을 사용 | `https://.r2.cloudflarestorage.com`, `http://minio.internal:9000` |
+| Path-style 주소 사용 | 버킷을 호스트가 아닌 경로에 두는 주소 형식(`endpoint/bucket/...`). MinIO 등 path-style 전용 스토리지에서 켠다 | — |
+
+- 리전은 자유 입력이다 — Cloudflare R2 는 `auto`, MinIO 는 관례상 `us-east-1` 을 쓴다.
+- **S3 URL(공개 URL)** 은 파일 공개 URL 생성에 쓰는 CDN/커스텀 도메인이며 API 요청 주소가 아니다 — API 주소는 엔드포인트 URL 에만 넣는다.
+- IP 주소 엔드포인트는 SDK 가 path-style 을 자동 적용하므로 토글과 무관하게 동작한다. 호스트명 엔드포인트에서는 토글이 주소 형식을 결정한다.
+- 연결 테스트는 실제 저장 경로와 같은 설정(엔드포인트·path-style 포함)을 사용한다.
+
+`.env` 로 설정하는 경우 대응 키는 `AWS_ENDPOINT` / `AWS_USE_PATH_STYLE_ENDPOINT` 다 (아래 FAQ Q2 의 ② 경로 참조).
+
+---
+
## FAQ
### Q1: 기존 파일은 어떻게 되나요?
@@ -1270,7 +1289,19 @@ if (Storage::disk('local')->exists($oldPath)) {
### Q2: S3로 전환하려면 어떻게 하나요?
-**A**: 다음 3단계만 수행하면 됩니다.
+**A**: 코어 7.0.7+ 는 S3 어댑터(`league/flysystem-aws-s3-v3`)를 기본 포함하므로 별도 패키지 설치가 필요 없습니다. 전환 경로는 두 가지입니다.
+
+**① 관리자 설정 UI (권장)** — 관리자 > 환경설정 > 드라이버 > 파일 스토리지:
+
+1. 스토리지 드라이버를 `Amazon S3` 로 선택
+2. 버킷 / 리전 / Access Key / Secret Key 입력
+3. S3 호환 스토리지(Cloudflare R2, MinIO, NCP 등)는 위 [S3 호환 스토리지 연결](#s3-호환-스토리지-연결) 절의 두 항목(엔드포인트 URL·Path-style 주소)을 함께 설정
+4. 연결 테스트 성공 후 저장 — 코어 첨부 업로드 디스크가 s3 로 전환됩니다
+ (`ATTACHMENT_DISK` env 를 명시한 경우 env 가 항상 우선. 기존 파일은 저장 당시의 disk 로 계속 서빙되므로 혼재 안전)
+
+참고: **S3 URL(공개 URL)** 칸은 파일 공개 URL 생성에 쓰는 CDN/커스텀 도메인이며, API 요청 주소가 아닙니다.
+
+**② `.env` (모듈별 커스텀)**:
1. `.env` 파일에 S3 설정 추가:
```env
@@ -1278,6 +1309,9 @@ if (Storage::disk('local')->exists($oldPath)) {
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=ap-northeast-2
AWS_BUCKET=your-bucket
+ # S3 호환 스토리지(R2/MinIO 등)만:
+ AWS_ENDPOINT=https://.r2.cloudflarestorage.com
+ AWS_USE_PATH_STYLE_ENDPOINT=false
```
2. 모듈 설정 변경:
diff --git a/docs/requirements.md b/docs/requirements.md
index 9cfd6a97..aa546ca6 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -237,7 +237,8 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates
| AWS SES | 이메일 발송 | 선택 |
| AWS SQS | 큐 처리 (대규모 트래픽) | 선택 |
-- `aws/aws-sdk-php` 패키지 포함됨
+- `aws/aws-sdk-php` · `league/flysystem-aws-s3-v3` 패키지 포함됨 (S3 및 S3 호환 스토리지 — Cloudflare R2, MinIO 등 — 를 별도 설치 없이 사용 가능)
+- `predis/predis` 패키지 포함됨 (phpredis PHP 확장이 없는 서버에서도 Redis 드라이버 사용 가능)
### 4.3 메일 서비스
diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
index 5e2c39ae..b301f960 100644
--- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
@@ -4,6 +4,22 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.6] - 2026-08-12
+
+### Added
+
+- S3 호환 스토리지 설정 신설 항목(엔드포인트 URL·Path-style 주소)과 리전 형식 안내의 검증 메시지 일본어 번역 추가 (`validation.settings.s3_endpoint_*`, `s3_use_path_style_boolean`, `s3_region_*`) — 드라이버 설정 저장 시 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
+- 드라이버 사용 불능 안내 문구 일본어 번역 추가 (`validation.settings.driver_unusable`, `settings.driver_unusable_*`, `settings.s3_adapter_missing`) — 필요한 라이브러리·PHP 확장이 없는 드라이버를 저장·테스트할 때의 사유 안내가 일본어 로케일에서 표시됩니다.
+- 웹소켓 서버(백엔드 발송용) endpoint 연결 실패 안내 일본어 번역 추가 (`settings.websocket_server_test_failed`).
+
+### Changed
+
+- S3 리전 검증 메시지를 목록 선택 안내에서 형식 안내(소문자 영숫자·하이픈, R2 는 `auto`)로 개정했습니다.
+
+### Removed
+
+- 참조처가 사라진 S3 리전 선택 검증 메시지(`validation.settings.s3_region_required`)를 원본(ko/en)과 함께 정리했습니다.
+
## [1.0.5] - 2026-08-10
### Added
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
index 120b375b..3df98c0e 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
@@ -71,6 +71,7 @@ return [
's3_test_failed' => 'S3バケットへの接続に失敗しました。',
's3_missing_config' => 'S3設定が不足しています。(バケット、リージョン、アクセスキー、シークレットキー)',
's3_sdk_missing' => 'AWS SDKがインストールされていません。',
+ 's3_adapter_missing' => 'S3ストレージアダプター(league/flysystem-aws-s3-v3)がインストールされていません。',
's3_bucket_not_found' => 'S3バケットが見つかりません。',
's3_access_denied' => 'S3バケットへのアクセスが拒否されました。',
's3_invalid_credentials' => 'S3認証情報が正しくありません。',
@@ -86,6 +87,10 @@ return [
'memcached_connection_failed' => 'Memcachedサーバーに接続できません。',
'websocket_test_success' => 'Websocketサーバーに正常に接続されました。',
'websocket_test_failed' => 'Websocketサーバーへの接続に失敗しました。',
+ 'websocket_server_test_failed' => 'Websocketサーバー(バックエンド送信用)エンドポイントへの接続に失敗しました。クライアントエンドポイントは正常です。',
+ 'driver_unusable_s3_adapter' => 'S3ストレージアダプター(league/flysystem-aws-s3-v3)がインストールされていません。',
+ 'driver_unusable_redis_client' => 'phpredis拡張とpredisライブラリのどちらも利用できません。',
+ 'driver_unusable_memcached_extension' => 'memcached PHP拡張がインストールされていません。',
'websocket_invalid_host' => 'Websocketホスト設定が正しくありません。ホストにはアドレスのみを入力し、アカウント情報(@)またはhttp/https以外のプロトコルは使用できません。',
'websocket_connection_refused' => 'Websocketサーバーに接続できません。サーバーが実行中であるか確認してください。',
'invalid_email' => '無効なメールアドレスです。',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
index cd0e5b62..1f1595b3 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
@@ -897,11 +897,16 @@ return [
'storage_driver_required' => 'ストレージドライバーを選択してください。',
'storage_driver_invalid' => '正しいストレージドライバーを選択してください。',
's3_bucket_max' => 'S3バケット名は255字を超えることはできません。',
- 's3_region_invalid' => '正しいS3リージョンを選択してください。',
+ 's3_region_invalid' => 'S3リージョンには小文字の英数字とハイフンのみ使用できます。(例: ap-northeast-2、Cloudflare R2 は auto)',
+ 's3_region_max' => 'S3リージョンは64字を超えることはできません。',
's3_access_key_max' => 'S3 Access Keyは255字を超えることはできません。',
's3_secret_key_max' => 'S3 Secret Keyは255字を超えることはできません。',
's3_url_invalid' => '正しいS3 URL形式ではありません。',
's3_url_max' => 'S3 URLは500字を超えることはできません。',
+ 's3_endpoint_invalid' => '正しいS3エンドポイントURL形式ではありません。',
+ 's3_endpoint_max' => 'S3エンドポイントURLは500字を超えることはできません。',
+ 's3_use_path_style_boolean' => 'Path-styleアドレス使用の設定はtrueまたはfalseである必要があります。',
+ 'driver_unusable' => "':driver' ドライバーはこのサーバーでは使用できません。:reason",
'cache_driver_required' => 'キャッシュドライバーを選択してください。',
'cache_driver_invalid' => '正しいキャッシュドライバーを選択してください。',
'redis_host_max' => 'Redisホストは255字を超えることはできません。',
@@ -950,7 +955,6 @@ return [
'log_days_min' => 'ログ保持日数は1以上である必要があります。',
'log_days_max' => 'ログ保持日数は365を超えることはできません。',
's3_bucket_required' => 'S3バケット名は必須です。',
- 's3_region_required' => 'S3リージョンを選択してください。',
's3_access_key_required' => 'S3 Access Keyは必須です。',
's3_secret_key_required' => 'S3 Secret Keyは必須です。',
's3_url_url' => 'S3 URLは有効なURLである必要があります。',
@@ -1073,6 +1077,8 @@ return [
's3_access_key' => 'S3 Access Key',
's3_secret_key' => 'S3 Secret Key',
's3_url' => 'S3 URL',
+ 's3_endpoint' => 'S3 エンドポイントURL',
+ 's3_use_path_style' => 'Path-styleアドレス使用',
'cache_driver' => 'キャッシュ ドライバー',
'redis_host' => 'Redis ホスト',
'redis_port' => 'Redis ポート',
diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json
index 4d25cdab..f904c3e6 100644
--- a/lang-packs/_bundled/g7-core-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-core-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
- "version": "1.0.5",
+ "version": "1.0.6",
"license": "MIT",
"scope": "core",
"target_identifier": null,
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
index 4a898f0b..51d3a512 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
@@ -4,6 +4,20 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.5] - 2026-08-12
+
+### Added
+
+- 환경설정 > 드라이버 > 파일 스토리지의 S3 신설 항목 일본어 번역 추가 (`settings.drivers.storage.s3_endpoint*`, `s3_use_path_style*`, `s3_region_desc`) — 엔드포인트 URL·Path-style 토글·리전 입력 도움말이 일본어 로케일에서 자연스럽게 표시됩니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
+
+### Changed
+
+- S3 URL 항목의 이름·설명을 공개 URL(CDN) 용도로 명확히 한 문구 개정을 반영했습니다.
+
+### Removed
+
+- 리전 목록 선택 라벨(`settings.drivers.storage.regions.*`) 5종 제거 — 리전이 자유 입력으로 바뀌어 더 이상 사용되지 않습니다.
+
## [1.0.4] - 2026-08-10
### Added
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
index 55b11d69..c8e50961 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
@@ -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": "キャッシュ",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
index d759ed88..063f3b9c 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
- "version": "1.0.4",
+ "version": "1.0.5",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
diff --git a/lang/en/settings.php b/lang/en/settings.php
index a16d02bf..be45cee4 100644
--- a/lang/en/settings.php
+++ b/lang/en/settings.php
@@ -82,6 +82,7 @@ return [
's3_test_failed' => 'Failed to connect to S3 bucket.',
's3_missing_config' => 'S3 configuration is missing. (bucket, region, access key, secret key)',
's3_sdk_missing' => 'AWS SDK is not installed.',
+ 's3_adapter_missing' => 'S3 storage adapter (league/flysystem-aws-s3-v3) is not installed.',
's3_bucket_not_found' => 'S3 bucket not found.',
's3_access_denied' => 'Access to S3 bucket was denied.',
's3_invalid_credentials' => 'S3 credentials are invalid.',
@@ -103,6 +104,10 @@ return [
// Websocket test messages
'websocket_test_success' => 'Successfully connected to Websocket server.',
'websocket_test_failed' => 'Failed to connect to Websocket server.',
+ 'websocket_server_test_failed' => 'Failed to connect to the Websocket server-side (backend broadcast) endpoint. The client endpoint is reachable.',
+ 'driver_unusable_s3_adapter' => 'S3 storage adapter (league/flysystem-aws-s3-v3) is not installed.',
+ 'driver_unusable_redis_client' => 'Neither the phpredis extension nor the predis library is available.',
+ 'driver_unusable_memcached_extension' => 'The memcached PHP extension is not installed.',
'websocket_invalid_host' => 'The Websocket host setting is invalid. Enter the address only — credentials (@) and protocols other than http/https are not allowed.',
'websocket_connection_refused' => 'Could not connect to Websocket server. Please check if the server is running.',
diff --git a/lang/en/validation.php b/lang/en/validation.php
index 2a851261..9a992bdb 100644
--- a/lang/en/validation.php
+++ b/lang/en/validation.php
@@ -1011,15 +1011,19 @@ return [
'storage_driver_invalid' => 'Please select a valid storage driver.',
's3_bucket_required' => 'S3 bucket name is required.',
's3_bucket_max' => 'S3 bucket name may not be greater than 255 characters.',
- 's3_region_required' => 'Please select an S3 region.',
- 's3_region_invalid' => 'Please select a valid S3 region.',
+ 's3_region_invalid' => 'S3 region may only contain lowercase letters, numbers, and hyphens. (e.g. ap-northeast-2, or auto for Cloudflare R2)',
+ 's3_region_max' => 'S3 region may not be greater than 64 characters.',
's3_access_key_required' => 'S3 access key is required.',
's3_access_key_max' => 'S3 access key may not be greater than 255 characters.',
's3_secret_key_required' => 'S3 secret key is required.',
's3_secret_key_max' => 'S3 secret key may not be greater than 255 characters.',
's3_url_url' => 'S3 URL must be a valid URL.',
's3_url_invalid' => 'S3 URL is not a valid URL format.',
- 's3_url_max' => 'S3 URL may not be greater than 255 characters.',
+ 's3_url_max' => 'S3 URL may not be greater than 500 characters.',
+ 's3_endpoint_invalid' => 'S3 endpoint is not a valid URL format.',
+ 's3_endpoint_max' => 'S3 endpoint may not be greater than 500 characters.',
+ 's3_use_path_style_boolean' => 'Path-style addressing must be true or false.',
+ 'driver_unusable' => "The ':driver' driver cannot be used on this server. :reason",
// Driver settings - Cache
'cache_driver_required' => 'Please select a cache driver.',
@@ -1200,6 +1204,8 @@ return [
's3_access_key' => 'S3 access key',
's3_secret_key' => 'S3 secret key',
's3_url' => 'S3 URL',
+ 's3_endpoint' => 'S3 endpoint URL',
+ 's3_use_path_style' => 'path-style addressing',
'cache_driver' => 'cache driver',
'redis_host' => 'Redis host',
'redis_port' => 'Redis port',
diff --git a/lang/ko/settings.php b/lang/ko/settings.php
index 670016ce..e71a61cd 100644
--- a/lang/ko/settings.php
+++ b/lang/ko/settings.php
@@ -82,6 +82,7 @@ return [
's3_test_failed' => 'S3 버킷 연결에 실패했습니다.',
's3_missing_config' => 'S3 설정이 누락되었습니다. (버킷, 리전, 액세스 키, 시크릿 키)',
's3_sdk_missing' => 'AWS SDK가 설치되어 있지 않습니다.',
+ 's3_adapter_missing' => 'S3 스토리지 어댑터(league/flysystem-aws-s3-v3)가 설치되어 있지 않습니다.',
's3_bucket_not_found' => 'S3 버킷을 찾을 수 없습니다.',
's3_access_denied' => 'S3 버킷에 대한 접근이 거부되었습니다.',
's3_invalid_credentials' => 'S3 인증 정보가 올바르지 않습니다.',
@@ -103,6 +104,10 @@ return [
// Websocket 테스트 메시지
'websocket_test_success' => 'Websocket 서버에 성공적으로 연결되었습니다.',
'websocket_test_failed' => 'Websocket 서버 연결에 실패했습니다.',
+ 'websocket_server_test_failed' => 'Websocket 서버(백엔드 발송용) endpoint 연결에 실패했습니다. 클라이언트 endpoint 는 정상입니다.',
+ 'driver_unusable_s3_adapter' => 'S3 스토리지 어댑터(league/flysystem-aws-s3-v3)가 설치되어 있지 않습니다.',
+ 'driver_unusable_redis_client' => 'phpredis 확장과 predis 라이브러리가 모두 없습니다.',
+ 'driver_unusable_memcached_extension' => 'memcached PHP 확장이 설치되어 있지 않습니다.',
'websocket_invalid_host' => 'Websocket 호스트 설정이 올바르지 않습니다. 호스트에는 주소만 입력하고 계정 정보(@)나 http/https 이외의 프로토콜은 사용할 수 없습니다.',
'websocket_connection_refused' => 'Websocket 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요.',
diff --git a/lang/ko/validation.php b/lang/ko/validation.php
index 2bb31837..0958393b 100644
--- a/lang/ko/validation.php
+++ b/lang/ko/validation.php
@@ -1006,11 +1006,16 @@ return [
'storage_driver_required' => '스토리지 드라이버를 선택해주세요.',
'storage_driver_invalid' => '올바른 스토리지 드라이버를 선택해주세요.',
's3_bucket_max' => 'S3 버킷 이름은 255자를 초과할 수 없습니다.',
- 's3_region_invalid' => '올바른 S3 리전을 선택해주세요.',
+ 's3_region_invalid' => 'S3 리전은 소문자 영숫자와 하이픈만 사용할 수 있습니다. (예: ap-northeast-2, Cloudflare R2 는 auto)',
+ 's3_region_max' => 'S3 리전은 64자를 초과할 수 없습니다.',
's3_access_key_max' => 'S3 Access Key는 255자를 초과할 수 없습니다.',
's3_secret_key_max' => 'S3 Secret Key는 255자를 초과할 수 없습니다.',
's3_url_invalid' => '올바른 S3 URL 형식이 아닙니다.',
's3_url_max' => 'S3 URL은 500자를 초과할 수 없습니다.',
+ 's3_endpoint_invalid' => '올바른 S3 엔드포인트 URL 형식이 아닙니다.',
+ 's3_endpoint_max' => 'S3 엔드포인트 URL은 500자를 초과할 수 없습니다.',
+ 's3_use_path_style_boolean' => 'Path-style 주소 사용 설정은 true 또는 false 값이어야 합니다.',
+ 'driver_unusable' => "':driver' 드라이버를 이 서버에서 사용할 수 없습니다. :reason",
'cache_driver_required' => '캐시 드라이버를 선택해주세요.',
'cache_driver_invalid' => '올바른 캐시 드라이버를 선택해주세요.',
'redis_host_max' => 'Redis 호스트는 255자를 초과할 수 없습니다.',
@@ -1063,7 +1068,6 @@ return [
// 드라이버 조건부 필수 메시지 (선택 드라이버에 따라 필수)
's3_bucket_required' => 'S3 버킷 이름은 필수입니다.',
- 's3_region_required' => 'S3 리전을 선택해주세요.',
's3_access_key_required' => 'S3 Access Key는 필수입니다.',
's3_secret_key_required' => 'S3 Secret Key는 필수입니다.',
's3_url_url' => 'S3 URL은 유효한 URL이어야 합니다.',
@@ -1196,6 +1200,8 @@ return [
's3_access_key' => 'S3 Access Key',
's3_secret_key' => 'S3 Secret Key',
's3_url' => 'S3 URL',
+ 's3_endpoint' => 'S3 엔드포인트 URL',
+ 's3_use_path_style' => 'Path-style 주소 사용',
'cache_driver' => '캐시 드라이버',
'redis_host' => 'Redis 호스트',
'redis_port' => 'Redis 포트',
diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
index 53a2ee88..77cbb20a 100644
--- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
+++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
@@ -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 님께서 제보해주셨습니다.)
diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-drivers-s3-fields.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-drivers-s3-fields.test.tsx
new file mode 100644
index 00000000..ee6b61ae
--- /dev/null
+++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-drivers-s3-fields.test.tsx
@@ -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 = ({ className, children, 'data-testid': testId }) => (
+