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:
@@ -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}"
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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 요청이 발생하던 문제를 수정했습니다.
|
||||
|
||||
+1
-1
@@ -517,6 +517,7 @@ cp .env.example .env
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
@@ -527,7 +528,6 @@ cp .env.example .env
|
||||
<a href="https://github.com/devrhee16" title="devrhee16"><img src="https://github.com/devrhee16.png" width="48" alt="devrhee16"></a>
|
||||
<a href="https://github.com/GyusoonKim" title="GyusoonKim"><img src="https://github.com/GyusoonKim.png" width="48" alt="GyusoonKim"></a>
|
||||
<a href="https://github.com/Lastorder-DC" title="Lastorder-DC"><img src="https://github.com/Lastorder-DC.png" width="48" alt="Lastorder-DC"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/miles44229" title="miles44229"><img src="https://github.com/miles44229.png" width="48" alt="miles44229"></a>
|
||||
<a href="https://github.com/minyho" title="minyho"><img src="https://github.com/minyho.png" width="48" alt="minyho"></a>
|
||||
</p>
|
||||
|
||||
@@ -533,6 +533,7 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
@@ -543,7 +544,6 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
|
||||
<a href="https://github.com/devrhee16" title="devrhee16"><img src="https://github.com/devrhee16.png" width="48" alt="devrhee16"></a>
|
||||
<a href="https://github.com/GyusoonKim" title="GyusoonKim"><img src="https://github.com/GyusoonKim.png" width="48" alt="GyusoonKim"></a>
|
||||
<a href="https://github.com/Lastorder-DC" title="Lastorder-DC"><img src="https://github.com/Lastorder-DC.png" width="48" alt="Lastorder-DC"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/miles44229" title="miles44229"><img src="https://github.com/miles44229.png" width="48" alt="miles44229"></a>
|
||||
<a href="https://github.com/minyho" title="minyho"><img src="https://github.com/minyho.png" width="48" alt="minyho"></a>
|
||||
</p>
|
||||
|
||||
@@ -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 응답을 반환합니다 (캐싱 헤더 포함).
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Public\Attachment;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class DownloadAttachmentRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 사용자가 이 요청을 수행할 권한이 있는지 확인
|
||||
*
|
||||
* 공개 다운로드 라우트 — 접근 제어는 AttachmentService 의 권한 훅
|
||||
* (core.attachment.download)이 하이브리드로 수행합니다.
|
||||
*
|
||||
* @return bool 항상 true (권한은 서비스 훅 책임)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청에 적용할 검증 규칙
|
||||
*
|
||||
* 해시는 라우트 제약(`[a-zA-Z0-9]{12}`)이 이미 형식을 강제하고,
|
||||
* 그 외 입력 필드가 없는 요청입니다.
|
||||
*
|
||||
* @return array<string, mixed> 검증 규칙 배열
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
* @return array<string, ValidationRule|array<mixed>|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),
|
||||
]));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}'로 폴백합니다.");
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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<int, string> Config 기록 값
|
||||
*/
|
||||
public function getConfigValueForDriver(string $category, string $driverId): string|array
|
||||
{
|
||||
return $category === 'log' ? [$driverId] : $driverId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원되는 카테고리 목록을 반환합니다.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 리전',
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+119
-2
@@ -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"
|
||||
}
|
||||
|
||||
+19
-1
@@ -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,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_…` | <!-- TODO: 설명 --> |
|
||||
| _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 등 — 권한 맵 기반) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- @probed -->
|
||||
|
||||
```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://<account-id>.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` 객체의 필드._
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- @probed -->
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
@@ -795,6 +816,8 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- @probed -->
|
||||
|
||||
```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}` | <!-- TODO: 설명 --> |
|
||||
| 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` 객체의 필드._
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- @probed -->
|
||||
|
||||
```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": "예시값"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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://<account-id>.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://<account-id>.r2.cloudflarestorage.com
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
```
|
||||
|
||||
2. 모듈 설정 변경:
|
||||
|
||||
@@ -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 메일 서비스
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' => '無効なメールアドレスです。',
|
||||
|
||||
@@ -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 ポート',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "キャッシュ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요.',
|
||||
|
||||
|
||||
@@ -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 포트',
|
||||
|
||||
@@ -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": {}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Admin;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\DriverRegistryService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 드라이버 탭 S3 검증 테스트 (공개 #99 / 내부 #563, A4·A7)
|
||||
*
|
||||
* s3_region 형식 검증 완화(리전 하드코딩 제거), s3_endpoint/s3_use_path_style
|
||||
* 신규 키 검증, 드라이버 가용성 서버 게이트를 검증합니다.
|
||||
*/
|
||||
class SettingsControllerS3ValidationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
private string $token;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->admin = $this->createAdminUser();
|
||||
$this->token = $this->admin->createToken('test-token')->plainTextToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 권한을 가진 관리자 사용자를 생성합니다.
|
||||
*/
|
||||
private function createAdminUser(): User
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$permissionIds = [];
|
||||
foreach (['core.settings.read', 'core.settings.update'] as $permIdentifier) {
|
||||
$permission = Permission::firstOrCreate(
|
||||
['identifier' => $permIdentifier],
|
||||
[
|
||||
'name' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]),
|
||||
'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']),
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
]
|
||||
);
|
||||
$permissionIds[] = $permission->id;
|
||||
}
|
||||
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
[
|
||||
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
||||
'description' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
$adminRole->permissions()->syncWithoutDetaching($permissionIds);
|
||||
$user->roles()->attach($adminRole->id);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 헤더를 부착한 요청 헬퍼입니다.
|
||||
*/
|
||||
private function authRequest(): static
|
||||
{
|
||||
return $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* drivers 탭 저장의 기본 payload 를 반환합니다.
|
||||
*
|
||||
* @param array $overrides drivers 오버라이드 값
|
||||
* @return array 요청 payload
|
||||
*/
|
||||
private function driversPayload(array $overrides = []): array
|
||||
{
|
||||
return [
|
||||
'_tab' => 'drivers',
|
||||
'drivers' => array_merge([
|
||||
'storage_driver' => 'local',
|
||||
'cache_driver' => 'file',
|
||||
'session_driver' => 'file',
|
||||
'queue_driver' => 'database',
|
||||
'log_driver' => 'daily',
|
||||
'log_level' => 'error',
|
||||
'websocket_enabled' => false,
|
||||
], $overrides),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 리전 형식 검증 — 유효한 값(구 화이트리스트·신규 AWS 리전·R2 auto·MinIO 커스텀)은
|
||||
* 저장이 허용되어야 합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects s3_disk_resolves
|
||||
*/
|
||||
#[DataProvider('validRegionProvider')]
|
||||
public function test_store_accepts_valid_region_shapes(string $region): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
||||
'storage_driver' => 's3',
|
||||
's3_bucket' => 'my-bucket',
|
||||
's3_region' => $region,
|
||||
's3_access_key' => 'key',
|
||||
's3_secret_key' => 'secret',
|
||||
]));
|
||||
|
||||
$response->assertStatus(200)->assertJson(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효한 리전 형식 목록 (구 하드코딩 5종 밖 값 포함 — A4 회귀 가드)
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function validRegionProvider(): array
|
||||
{
|
||||
return [
|
||||
'구 화이트리스트 값' => ['ap-northeast-2'],
|
||||
'구 목록 밖 AWS 리전' => ['eu-central-1'],
|
||||
'Cloudflare R2 표준값' => ['auto'],
|
||||
'MinIO 관례값' => ['us-east-1'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 리전 형식 검증 — 불량 형식은 422 로 거부되어야 합니다.
|
||||
*
|
||||
* @scenario region_shape=invalid_format,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects invalid_region_rejected
|
||||
*/
|
||||
#[DataProvider('invalidRegionProvider')]
|
||||
public function test_store_rejects_invalid_region_shapes(string $region): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
||||
'storage_driver' => 's3',
|
||||
's3_bucket' => 'my-bucket',
|
||||
's3_region' => $region,
|
||||
's3_access_key' => 'key',
|
||||
's3_secret_key' => 'secret',
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['drivers.s3_region']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 불량 리전 형식 목록
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function invalidRegionProvider(): array
|
||||
{
|
||||
return [
|
||||
'특수문자' => ['S3!bad'],
|
||||
'대문자' => ['AP-NORTHEAST-2'],
|
||||
'공백 포함' => ['ap northeast 2'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* s3_endpoint 는 URL 형식이어야 합니다 (불량 형식 422).
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=custom_url,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects endpoint_injected
|
||||
*/
|
||||
public function test_store_validates_s3_endpoint_url_format(): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
||||
'storage_driver' => 's3',
|
||||
's3_bucket' => 'my-bucket',
|
||||
's3_region' => 'auto',
|
||||
's3_access_key' => 'key',
|
||||
's3_secret_key' => 'secret',
|
||||
's3_endpoint' => 'not-a-url',
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['drivers.s3_endpoint']);
|
||||
}
|
||||
|
||||
/**
|
||||
* s3_endpoint/s3_use_path_style 정상 값은 저장 왕복이 성립해야 합니다.
|
||||
*
|
||||
* @scenario region_shape=custom_minio,endpoint=path_style,driver_usability=adapter_present,attachment_disk=follow_s3
|
||||
*
|
||||
* @effects endpoint_injected, attachment_upload_follows_driver
|
||||
*/
|
||||
public function test_store_saves_s3_endpoint_and_path_style_roundtrip(): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
||||
'storage_driver' => 's3',
|
||||
's3_bucket' => 'my-bucket',
|
||||
's3_region' => 'us-east-1',
|
||||
's3_access_key' => 'key',
|
||||
's3_secret_key' => 'secret',
|
||||
's3_endpoint' => 'http://127.0.0.1:9000',
|
||||
's3_use_path_style' => true,
|
||||
]));
|
||||
|
||||
$response->assertStatus(200)->assertJson(['success' => true]);
|
||||
|
||||
$show = $this->authRequest()->getJson('/api/admin/settings');
|
||||
$show->assertStatus(200);
|
||||
|
||||
$drivers = $show->json('data.drivers');
|
||||
$this->assertSame('http://127.0.0.1:9000', $drivers['s3_endpoint'] ?? null);
|
||||
$this->assertTrue((bool) ($drivers['s3_use_path_style'] ?? false));
|
||||
}
|
||||
|
||||
/**
|
||||
* s3_use_path_style 은 boolean 이어야 합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=path_style,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects endpoint_injected
|
||||
*/
|
||||
public function test_store_validates_s3_use_path_style_boolean(): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
||||
'storage_driver' => 's3',
|
||||
's3_bucket' => 'my-bucket',
|
||||
's3_region' => 'auto',
|
||||
's3_access_key' => 'key',
|
||||
's3_secret_key' => 'secret',
|
||||
's3_use_path_style' => 'not-bool',
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['drivers.s3_use_path_style']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 드라이버 가용성 서버 게이트 — 사용 불능 드라이버 저장은 422 로 차단되어야 합니다 (A7).
|
||||
*
|
||||
* memcached PHP 확장 존재 여부는 환경 의존이므로, 확장이 있는 환경에서는
|
||||
* isDriverUsable 단위 검증(DriverRegistryServiceUsabilityTest)이 판정 로직을 담당하고
|
||||
* 이 테스트는 통과로 간주합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_absent,attachment_disk=local_default
|
||||
*
|
||||
* @effects unusable_driver_rejected
|
||||
*/
|
||||
public function test_store_rejects_unusable_driver(): void
|
||||
{
|
||||
if (extension_loaded('memcached')) {
|
||||
$this->markTestSkipped('memcached 확장이 설치된 환경 — 사용 불능 상태를 만들 수 없어 단위 테스트가 판정을 담당');
|
||||
}
|
||||
|
||||
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
||||
'cache_driver' => 'memcached',
|
||||
'memcached_host' => '127.0.0.1',
|
||||
'memcached_port' => 11211,
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['drivers.cache_driver']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 드라이버 가용성 게이트의 컨트롤러↔서비스 결합을 환경 무관하게 검증합니다 (A7).
|
||||
*
|
||||
* 위 테스트는 확장 미설치 환경에서만 실 422 를 밟으므로, 레지스트리를 컨테이너
|
||||
* partialMock 으로 교체해 "사용 불능" 판정 시 저장 요청이 422 + 사유 문구로
|
||||
* 차단되는 결합점을 어느 환경에서든 단언한다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_absent,attachment_disk=local_default
|
||||
*
|
||||
* @effects unusable_driver_rejected
|
||||
*/
|
||||
public function test_store_rejects_unusable_driver_via_registry_gate(): void
|
||||
{
|
||||
$this->partialMock(DriverRegistryService::class, function ($mock) {
|
||||
$mock->shouldReceive('isDriverUsable')
|
||||
->andReturnUsing(fn (string $category, string $driverId) => ! ($category === 'cache' && $driverId === 'redis'));
|
||||
$mock->shouldReceive('usabilityFailureReason')
|
||||
->andReturn('mocked: redis client missing');
|
||||
});
|
||||
|
||||
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
||||
'cache_driver' => 'redis',
|
||||
'redis_host' => '127.0.0.1',
|
||||
'redis_port' => 6379,
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['drivers.cache_driver']);
|
||||
|
||||
// 사유가 422 응답 본문까지 도달해야 운영자가 원인을 알 수 있다
|
||||
$messages = $response->json('errors')['drivers.cache_driver'] ?? [];
|
||||
$this->assertStringContainsString('mocked: redis client missing', implode(' ', $messages));
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 테스트 요청도 동일한 리전 완화·신규 키 규칙을 적용해야 합니다 (게이트 대칭).
|
||||
*
|
||||
* @scenario region_shape=auto,endpoint=custom_url,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects endpoint_injected
|
||||
*/
|
||||
public function test_test_driver_request_accepts_relaxed_region_and_new_keys(): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson('/api/admin/settings/test-driver', [
|
||||
'storage_driver' => 's3',
|
||||
's3_bucket' => 'my-bucket',
|
||||
's3_region' => 'auto',
|
||||
's3_access_key' => 'key',
|
||||
's3_secret_key' => 'secret',
|
||||
's3_endpoint' => 'http://127.0.0.1:9',
|
||||
's3_use_path_style' => true,
|
||||
]);
|
||||
|
||||
// 검증 통과(422 아님) — 연결 자체는 도달 불가 endpoint 라 실패 결과가 반환된다
|
||||
$response->assertStatus(200);
|
||||
$this->assertFalse($response->json('data.results.s3.success'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 테스트 요청이 websocket server 3필드를 수용해야 합니다 (A6 요청부).
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects test_covers_server_websocket
|
||||
*/
|
||||
public function test_test_driver_request_accepts_websocket_server_fields(): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson('/api/admin/settings/test-driver', [
|
||||
'websocket_enabled' => true,
|
||||
'websocket_host' => 'localhost',
|
||||
'websocket_port' => 65500,
|
||||
'websocket_scheme' => 'http',
|
||||
'websocket_server_host' => '127.0.0.1',
|
||||
'websocket_server_port' => 65501,
|
||||
'websocket_server_scheme' => 'http',
|
||||
'websocket_app_key' => 'k',
|
||||
]);
|
||||
|
||||
// 검증 통과 — 서버 미기동이므로 연결 실패 결과가 반환될 뿐 422 는 아니어야 한다
|
||||
$response->assertStatus(200);
|
||||
$this->assertFalse($response->json('data.results.websocket.success'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비인증 요청은 401 로 차단되어야 합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects unauthenticated_returns_401
|
||||
*/
|
||||
public function test_store_and_test_driver_return_401_without_authentication(): void
|
||||
{
|
||||
$this->postJson('/api/admin/settings', $this->driversPayload())->assertStatus(401);
|
||||
$this->postJson('/api/admin/settings/test-driver', ['storage_driver' => 's3'])->assertStatus(401);
|
||||
}
|
||||
}
|
||||
@@ -1373,6 +1373,42 @@ class SettingsControllerTest extends TestCase
|
||||
->assertJson(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 드라이버 탭 저장 왕복에 S3 신규 키(s3_endpoint/s3_use_path_style)가 포함되어야 합니다 (#563).
|
||||
*/
|
||||
public function test_store_drivers_roundtrip_includes_new_s3_keys(): void
|
||||
{
|
||||
$response = $this->authRequest()->postJson('/api/admin/settings', [
|
||||
'_tab' => 'drivers',
|
||||
'drivers' => [
|
||||
'storage_driver' => 's3',
|
||||
's3_bucket' => 'roundtrip-bucket',
|
||||
's3_region' => 'auto',
|
||||
's3_access_key' => 'roundtrip-key',
|
||||
's3_secret_key' => 'roundtrip-secret',
|
||||
's3_endpoint' => 'https://roundtrip.r2.cloudflarestorage.com',
|
||||
's3_use_path_style' => true,
|
||||
'cache_driver' => 'file',
|
||||
'session_driver' => 'file',
|
||||
'queue_driver' => 'database',
|
||||
'log_driver' => 'daily',
|
||||
'log_level' => 'error',
|
||||
'websocket_enabled' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['success' => true]);
|
||||
|
||||
$drivers = $this->authRequest()->getJson('/api/admin/settings')
|
||||
->assertStatus(200)
|
||||
->json('data.drivers');
|
||||
|
||||
$this->assertSame('auto', $drivers['s3_region'] ?? null);
|
||||
$this->assertSame('https://roundtrip.r2.cloudflarestorage.com', $drivers['s3_endpoint'] ?? null);
|
||||
$this->assertTrue((bool) ($drivers['s3_use_path_style'] ?? false));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 시스템 정보 probe 실패 격리 테스트 — gnuboard/g7#59
|
||||
// (구조/캐시/정상값은 위 systemInfo 테스트가 이미 커버. 여기서는 호스팅
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Public;
|
||||
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Models\Attachment;
|
||||
use App\Services\AttachmentService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* S3 디스크 첨부의 공개 이미지 서빙 회귀 테스트 (공개 #99 / 내부 #563)
|
||||
*
|
||||
* attachment.disk 가 s3 로 전환된 상태에서 이미지 인라인 서빙 분기가 로컬 절대
|
||||
* 경로(filemtime/filesize)를 전제해 500 이 나던 결함의 회귀 가드.
|
||||
* 실환경 재현: 실 AWS 업로드 후 GET /api/attachment/{hash} →
|
||||
* "filemtime(): stat failed for \\2026/08/13/….png" 500 (2026-08-13 라이브 로그).
|
||||
*
|
||||
* S3 디스크의 getBasePath('') 는 로컬 stat 가능한 경로가 아니므로(어댑터 prefix
|
||||
* 가 빈 문자열), 그 의미를 재현하는 StorageInterface 대역으로 원격 디스크 행을
|
||||
* 만든다. Storage::fake('s3') 는 로컬 디스크라 이 결함을 재현하지 못한다.
|
||||
*/
|
||||
class AttachmentS3DiskServingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 원격(S3) 디스크 의미를 재현하는 스토리지 대역을 컨텍스트 바인딩합니다.
|
||||
*
|
||||
* @param string $content 스트림으로 서빙될 바이트
|
||||
*/
|
||||
private function bindRemoteDiskStorage(string $content = 'png-bytes'): void
|
||||
{
|
||||
$storage = Mockery::mock(StorageInterface::class);
|
||||
$storage->shouldReceive('withDisk')->andReturnSelf();
|
||||
$storage->shouldReceive('exists')->andReturn(true);
|
||||
// S3 어댑터의 path('') 의미 재현 — 로컬 stat 불가능한 값 (라이브 로그의 '\2026/…' 원인)
|
||||
$storage->shouldReceive('getBasePath')->andReturn('');
|
||||
$storage->shouldReceive('response')->andReturnUsing(
|
||||
fn () => new StreamedResponse(
|
||||
function () use ($content) {
|
||||
echo $content;
|
||||
},
|
||||
200,
|
||||
['Content-Type' => 'image/png', 'Content-Length' => (string) strlen($content)]
|
||||
)
|
||||
);
|
||||
|
||||
$this->app->when(AttachmentService::class)
|
||||
->needs(StorageInterface::class)
|
||||
->give(fn () => $storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* s3 디스크 이미지 행을 생성합니다.
|
||||
*/
|
||||
private function makeS3ImageAttachment(): Attachment
|
||||
{
|
||||
return Attachment::factory()->create([
|
||||
'disk' => 's3',
|
||||
'path' => '2026/08/13/regression-guard.png',
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* s3 디스크 이미지 첨부가 200 스트림 + 캐싱 헤더로 서빙되어야 합니다.
|
||||
*
|
||||
* 종전 코드는 getBasePath 로 로컬 절대 경로를 조립해 filemtime 에서 죽었다(500).
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=follow_s3
|
||||
*
|
||||
* @effects attachment_upload_follows_driver
|
||||
*/
|
||||
public function test_s3_disk_image_attachment_serves_200_with_cache_headers(): void
|
||||
{
|
||||
$this->bindRemoteDiskStorage();
|
||||
$attachment = $this->makeS3ImageAttachment();
|
||||
|
||||
$response = $this->get('/api/attachment/'.$attachment->hash);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertNotEmpty($response->headers->get('ETag'));
|
||||
$this->assertStringContainsString('image/png', (string) $response->headers->get('Content-Type'));
|
||||
$this->assertNotEmpty((string) $response->headers->get('Cache-Control'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 동일 ETag 의 If-None-Match 재요청은 304 로 응답해야 합니다 (캐싱 계약 유지).
|
||||
*
|
||||
* ETag 는 파일 stat 이 아닌 행 메타(디스크·경로·수정시각·크기) 기반이어야
|
||||
* 원격 디스크에서도 결정적이다.
|
||||
*/
|
||||
public function test_s3_disk_image_attachment_etag_roundtrip_yields_304(): void
|
||||
{
|
||||
$this->bindRemoteDiskStorage();
|
||||
$attachment = $this->makeS3ImageAttachment();
|
||||
|
||||
$first = $this->get('/api/attachment/'.$attachment->hash);
|
||||
$first->assertStatus(200);
|
||||
$etag = $first->headers->get('ETag');
|
||||
$this->assertNotEmpty($etag);
|
||||
|
||||
$second = $this->withHeaders(['If-None-Match' => $etag])
|
||||
->get('/api/attachment/'.$attachment->hash);
|
||||
|
||||
$second->assertStatus(304);
|
||||
$this->assertSame($etag, $second->headers->get('ETag'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 디스크 이미지 행의 서빙 계약(200 + ETag)이 유지되어야 합니다 (혼재 회귀 가드).
|
||||
*
|
||||
* 기존 행은 행 disk 로 계속 서빙된다 — s3 전환 후에도 과거 로컬 행이 깨지면 안 된다.
|
||||
*/
|
||||
public function test_local_disk_image_attachment_still_serves(): void
|
||||
{
|
||||
$this->bindRemoteDiskStorage();
|
||||
|
||||
$attachment = Attachment::factory()->create([
|
||||
'disk' => 'attachments',
|
||||
'path' => '2026/08/13/legacy-local.png',
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
|
||||
$response = $this->get('/api/attachment/'.$attachment->hash);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertNotEmpty($response->headers->get('ETag'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* E2E: 환경설정 > 드라이버 — S3 리전 자유입력·엔드포인트·path-style (#99/#563)
|
||||
*
|
||||
* 시나리오 매니페스트: tests/scenarios/s3-storage-driver.yaml (마킹은 각 테스트의
|
||||
* @scenario k=v 조합 + @effects 가 담당 — 헤더 요약 마킹은 파서 형식이 아님)
|
||||
*
|
||||
* 배경: S3 리전이 5개 하드코딩 Select 라 Cloudflare R2(auto)·신규 AWS 리전을 입력할 수
|
||||
* 없었고, S3 호환 스토리지의 API 주소(endpoint)를 넣을 자리가 없어 s3_url(공개 URL)에
|
||||
* 잘못 넣는 혼란이 있었다. 리전 Input 전환 + endpoint/path-style 신설을 브라우저에서
|
||||
* 확인한다.
|
||||
*
|
||||
* 검증:
|
||||
* 1. s3 선택 시 리전이 자유 입력 Input(Select 아님)·endpoint Input·path-style Toggle 이
|
||||
* 번역문과 함께 마운트된다
|
||||
* 2. 불량 형식 리전은 저장 버튼 클릭이 422 로 거부되고 리전 필드에 inline 에러가
|
||||
* 표시된다 (s3 저장 게이트가 요구하는 연결 테스트만 route 스텁으로 성공 처리 —
|
||||
* 외부 S3 실연결이 E2E 환경에 없기 때문이며, 저장 요청·에러 렌더는 전부 실경로)
|
||||
* 3. local 드라이버 저장 왕복(외부 연결 불요 경로)이 신규 키를 보존한다
|
||||
*/
|
||||
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
|
||||
|
||||
/** 관리자 환경설정 드라이버 탭 진입 + s3 선택 */
|
||||
async function gotoDriversTabWithS3(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto('/admin/settings?tab=drivers');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// 템플릿 Select 는 options 모드에서 네이티브 select 가 아니라
|
||||
// 커스텀 드롭다운(button[aria-haspopup=listbox] + role=option)을 렌더한다.
|
||||
const storageSelect = page.locator('[name="drivers.storage_driver"]').first();
|
||||
await expect(storageSelect).toBeAttached({ timeout: 20_000 });
|
||||
await storageSelect.locator('button[aria-haspopup="listbox"]').first().click();
|
||||
await page.getByRole('option', { name: 'Amazon S3' }).click();
|
||||
await expect(page.locator('#s3_settings')).toBeAttached({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
// @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
||||
// @effects region_free_input_mounted
|
||||
test('@smoke #99 - s3 선택 시 리전이 자유 입력 Input 으로 마운트된다 (Select 아님)', async ({ page }) => {
|
||||
const token = issueToken('core.settings.read', 'core.settings.update');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
await gotoDriversTabWithS3(page);
|
||||
expect(page.url()).not.toMatch(/\/admin\/login/);
|
||||
|
||||
// 존재 확정: 리전 필드는 자유 입력 input 이다 (종전에는 커스텀 드롭다운이라 input 자체가 없었다)
|
||||
const regionInput = page.locator('input[name="drivers.s3_region"]').first();
|
||||
await expect(regionInput).toBeAttached();
|
||||
|
||||
// 부재 단언: 종전 리전 드롭다운(커스텀 Select 루트)이 제거되었다
|
||||
await expect(page.locator('[name="drivers.s3_region"] button[aria-haspopup="listbox"]')).toHaveCount(0);
|
||||
|
||||
// 자유 입력이 실제로 동작한다 (R2 표준값)
|
||||
await regionInput.fill('auto');
|
||||
await expect(regionInput).toHaveValue('auto');
|
||||
});
|
||||
|
||||
// @scenario region_shape=custom_minio,endpoint=path_style,driver_usability=adapter_present,attachment_disk=local_default
|
||||
// @effects endpoint_field_mounted, path_style_toggle_mounted
|
||||
test('#99 - endpoint Input 과 path-style Toggle 이 번역문과 함께 마운트된다', async ({ page }) => {
|
||||
const token = issueToken('core.settings.read', 'core.settings.update');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
await gotoDriversTabWithS3(page);
|
||||
|
||||
const endpointField = page.locator('#s3_endpoint_field');
|
||||
await expect(endpointField).toBeAttached();
|
||||
await expect(endpointField.locator('input[name="drivers.s3_endpoint"]')).toBeAttached();
|
||||
|
||||
const toggleField = page.locator('#s3_use_path_style_field');
|
||||
await expect(toggleField).toBeAttached();
|
||||
await expect(toggleField.locator('input[type="checkbox"]').first()).toBeAttached();
|
||||
|
||||
// 다국어 키 미해석 회귀 가드 — s3 블록 전체에 "$t:" 원문이 노출되면 안 된다
|
||||
const blockText = (await page.locator('#s3_settings').innerText()).trim();
|
||||
expect(blockText).not.toContain('$t:');
|
||||
expect(blockText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// @scenario region_shape=invalid_format,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
||||
// @effects invalid_region_rejected
|
||||
test('#99 - 불량 형식 리전은 저장 클릭이 422 로 거부되고 리전 필드에 오류가 표시된다', async ({ page }) => {
|
||||
const token = issueToken('core.settings.read', 'core.settings.update');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
// s3 저장 게이트는 연결 테스트 성공을 요구한다. 외부 S3 실연결이 E2E 환경에
|
||||
// 없으므로 연결 테스트 응답만 성공으로 스텁한다 — 저장 요청 자체는 실서버로
|
||||
// 나가 422 계약과 inline 에러 렌더를 실경로로 검증한다.
|
||||
await page.route('**/api/admin/settings/test-driver', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: { results: { s3: { success: true, message: 'stubbed' } } },
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await gotoDriversTabWithS3(page);
|
||||
|
||||
// s3 필드 입력 — 불량 형식 리전 포함 (대문자·특수문자)
|
||||
await page.locator('input[name="drivers.s3_bucket"]').fill('bucket');
|
||||
await page.locator('input[name="drivers.s3_region"]').fill('S3!bad');
|
||||
await page.locator('input[name="drivers.s3_access_key"]').fill('key');
|
||||
await page.locator('input[name="drivers.s3_secret_key"]').fill('secret');
|
||||
|
||||
// 연결 테스트(스텁 성공) → 저장 게이트 해제 → 저장 버튼 활성 전이
|
||||
await page.locator('#s3_test_section button').click();
|
||||
const saveButton = page.locator('#save_button');
|
||||
await expect(saveButton).toBeEnabled({ timeout: 10_000 });
|
||||
|
||||
// 저장 클릭 → 실서버 422
|
||||
const [saveResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' &&
|
||||
r.url().includes('/api/admin/settings') &&
|
||||
!r.url().includes('test-driver')
|
||||
),
|
||||
saveButton.click(),
|
||||
]);
|
||||
expect(saveResponse.status()).toBe(422);
|
||||
|
||||
// 서버가 돌려준 리전 오류 문구가 리전 필드 inline 에러로 렌더된다
|
||||
const body = await saveResponse.json();
|
||||
const regionMessage = body?.errors?.['drivers.s3_region']?.[0];
|
||||
expect(regionMessage).toBeTruthy();
|
||||
await expect(page.locator('input[name="drivers.s3_region"]')).toHaveClass(/input-error/);
|
||||
await expect(page.locator('#s3_settings')).toContainText(regionMessage);
|
||||
});
|
||||
|
||||
// @scenario region_shape=auto,endpoint=custom_url,driver_usability=adapter_present,attachment_disk=local_default
|
||||
// @effects local_roundtrip_preserves_new_keys
|
||||
test('#99 - local 드라이버 저장 왕복이 신규 키(s3_endpoint/path-style)를 보존한다', async ({ page }) => {
|
||||
const token = issueToken('core.settings.read', 'core.settings.update');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
await page.goto('/admin/settings?tab=drivers');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const result = await page.evaluate(async (bearer) => {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// 현재 값을 읽어 그대로 되돌려 놓는 왕복 — 검수 서버 상태를 바꾸지 않는다
|
||||
const before = await (
|
||||
await fetch('/api/admin/settings', { headers })
|
||||
).json();
|
||||
const drivers = before?.data?.drivers ?? {};
|
||||
|
||||
const save = await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
_tab: 'drivers',
|
||||
drivers: {
|
||||
...drivers,
|
||||
storage_driver: 'local',
|
||||
s3_region: 'auto',
|
||||
s3_endpoint: 'http://127.0.0.1:9000',
|
||||
s3_use_path_style: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const after = await (
|
||||
await fetch('/api/admin/settings', { headers })
|
||||
).json();
|
||||
|
||||
// 원상 복구 (검수 서버 상태 보존)
|
||||
await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ _tab: 'drivers', drivers }),
|
||||
});
|
||||
|
||||
return {
|
||||
saveStatus: save.status,
|
||||
savedEndpoint: after?.data?.drivers?.s3_endpoint,
|
||||
savedPathStyle: after?.data?.drivers?.s3_use_path_style,
|
||||
savedRegion: after?.data?.drivers?.s3_region,
|
||||
};
|
||||
}, token);
|
||||
|
||||
expect(result.saveStatus).toBe(200);
|
||||
expect(result.savedRegion).toBe('auto');
|
||||
expect(result.savedEndpoint).toBe('http://127.0.0.1:9000');
|
||||
expect(Boolean(result.savedPathStyle)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Providers;
|
||||
|
||||
use App\Providers\SettingsServiceProvider;
|
||||
use App\Repositories\JsonConfigRepository;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Mockery;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SettingsServiceProvider S3 설정 주입 테스트 (공개 #99 / 내부 #563)
|
||||
*
|
||||
* applyS3Config()의 endpoint/path-style 주입, s3_url→url 매핑,
|
||||
* applyDriverConfig()의 첨부 디스크 전환(A8), 죽은 주입 제거(a2·a3) 회귀를 검증합니다.
|
||||
*/
|
||||
class SettingsServiceProviderS3ConfigTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* applyS3Config를 리플렉션으로 호출합니다.
|
||||
*
|
||||
* @param array $driverSettings drivers 카테고리 설정 데이터
|
||||
*/
|
||||
private function callApplyS3Config(array $driverSettings): void
|
||||
{
|
||||
$provider = new SettingsServiceProvider($this->app);
|
||||
$method = new ReflectionMethod($provider, 'applyS3Config');
|
||||
$method->invoke($provider, $driverSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* applyDriverConfig를 리플렉션으로 호출합니다 (drivers 카테고리 mock 주입).
|
||||
*
|
||||
* @param array $driverSettings drivers 카테고리 설정 데이터
|
||||
*/
|
||||
private function callApplyDriverConfig(array $driverSettings): void
|
||||
{
|
||||
$repository = Mockery::mock(JsonConfigRepository::class);
|
||||
$repository->shouldReceive('getCategory')->with('drivers')->andReturn($driverSettings);
|
||||
|
||||
$provider = new SettingsServiceProvider($this->app);
|
||||
$method = new ReflectionMethod($provider, 'applyDriverConfig');
|
||||
$method->invoke($provider, $repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* s3_endpoint 가 filesystems.disks.s3.endpoint 로 주입되는지 테스트합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=custom_url,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects endpoint_injected
|
||||
*/
|
||||
public function test_s3_endpoint_injected_into_disk_config(): void
|
||||
{
|
||||
Config::set('filesystems.disks.s3.endpoint', null);
|
||||
|
||||
$this->callApplyS3Config([
|
||||
's3_endpoint' => 'https://account.r2.cloudflarestorage.com',
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
'https://account.r2.cloudflarestorage.com',
|
||||
config('filesystems.disks.s3.endpoint'),
|
||||
's3_endpoint 는 SDK 요청 대상(endpoint)에 주입되어야 함 — 미주입 시 S3 호환 스토리지 연결 불가 (#99)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* s3_use_path_style 이 use_path_style_endpoint 로 주입되는지 테스트합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=path_style,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects endpoint_injected
|
||||
*/
|
||||
public function test_s3_use_path_style_injected_into_disk_config(): void
|
||||
{
|
||||
Config::set('filesystems.disks.s3.use_path_style_endpoint', false);
|
||||
|
||||
$this->callApplyS3Config(['s3_use_path_style' => true]);
|
||||
|
||||
$this->assertTrue((bool) config('filesystems.disks.s3.use_path_style_endpoint'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 endpoint/path-style 은 주입되지 않는지 테스트합니다 (AWS 기본 경로 보존).
|
||||
*/
|
||||
public function test_empty_s3_endpoint_and_path_style_not_injected(): void
|
||||
{
|
||||
Config::set('filesystems.disks.s3.endpoint', null);
|
||||
Config::set('filesystems.disks.s3.use_path_style_endpoint', false);
|
||||
|
||||
$this->callApplyS3Config([
|
||||
's3_endpoint' => '',
|
||||
's3_use_path_style' => false,
|
||||
's3_bucket' => 'my-bucket',
|
||||
]);
|
||||
|
||||
$this->assertNull(config('filesystems.disks.s3.endpoint'));
|
||||
$this->assertFalse((bool) config('filesystems.disks.s3.use_path_style_endpoint'));
|
||||
}
|
||||
|
||||
/**
|
||||
* s3_url 은 public URL(url)에만 매핑되고 endpoint 에는 매핑되지 않는지 테스트합니다.
|
||||
*/
|
||||
public function test_s3_url_maps_to_public_url_not_endpoint(): void
|
||||
{
|
||||
Config::set('filesystems.disks.s3.url', null);
|
||||
Config::set('filesystems.disks.s3.endpoint', null);
|
||||
|
||||
$this->callApplyS3Config(['s3_url' => 'https://cdn.example.com']);
|
||||
|
||||
$this->assertSame('https://cdn.example.com', config('filesystems.disks.s3.url'));
|
||||
$this->assertNull(
|
||||
config('filesystems.disks.s3.endpoint'),
|
||||
's3_url 은 공개 URL base — SDK 요청 대상(endpoint)과 별개 축이어야 함'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* storage_driver=s3 + ATTACHMENT_DISK 미명시 시 첨부 디스크가 s3 로 전환되는지 테스트합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=follow_s3
|
||||
*
|
||||
* @effects attachment_upload_follows_driver
|
||||
*/
|
||||
public function test_attachment_disk_follows_s3_when_env_not_explicit(): void
|
||||
{
|
||||
Config::set('attachment.disk', 'attachments');
|
||||
Config::set('attachment.disk_explicit', null);
|
||||
|
||||
$this->callApplyDriverConfig(['storage_driver' => 's3']);
|
||||
|
||||
$this->assertSame('s3', config('attachment.disk'));
|
||||
}
|
||||
|
||||
/**
|
||||
* ATTACHMENT_DISK 명시 시 env 가 항상 우선하는지 테스트합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=env_override
|
||||
*
|
||||
* @effects attachment_upload_follows_driver
|
||||
*/
|
||||
public function test_attachment_disk_env_override_wins_over_s3(): void
|
||||
{
|
||||
Config::set('attachment.disk', 'custom-disk');
|
||||
Config::set('attachment.disk_explicit', 'custom-disk');
|
||||
|
||||
$this->callApplyDriverConfig(['storage_driver' => 's3']);
|
||||
|
||||
$this->assertSame('custom-disk', config('attachment.disk'));
|
||||
}
|
||||
|
||||
/**
|
||||
* ATTACHMENT_DISK= 빈 값(키 존재·값 없음)은 "미명시" 로 간주되어야 합니다.
|
||||
*
|
||||
* `cp .env.example .env` 설치 절차에서 `ATTACHMENT_DISK=` 가 그대로 복사되면
|
||||
* env() 는 기본값 대신 빈 문자열을 돌려준다 — `=== null` 판별이면 s3 전환이
|
||||
* 영구 미발동하고, `attachment.disk` 도 '' 가 되어 첨부 업로드가 전면 실패한다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=follow_s3
|
||||
*
|
||||
* @effects attachment_upload_follows_driver
|
||||
*/
|
||||
public function test_attachment_disk_follows_s3_when_env_is_blank_string(): void
|
||||
{
|
||||
Config::set('attachment.disk', 'attachments');
|
||||
Config::set('attachment.disk_explicit', '');
|
||||
|
||||
$this->callApplyDriverConfig(['storage_driver' => 's3']);
|
||||
|
||||
$this->assertSame(
|
||||
's3',
|
||||
config('attachment.disk'),
|
||||
'ATTACHMENT_DISK= 빈 값은 미명시와 동일해야 함 — 빈 문자열을 명시로 취급하면 신규 설치에서 전환이 절대 발동하지 않는다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* storage_driver=local 이면 첨부 디스크가 기본값을 유지하는지 테스트합니다.
|
||||
*/
|
||||
public function test_attachment_disk_unchanged_when_storage_driver_local(): void
|
||||
{
|
||||
Config::set('attachment.disk', 'attachments');
|
||||
Config::set('attachment.disk_explicit', null);
|
||||
|
||||
$this->callApplyDriverConfig(['storage_driver' => 'local']);
|
||||
|
||||
$this->assertSame('attachments', config('attachment.disk'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 실효성 — REDIS_CLIENT=phpredis 활성 배포(.env.example 표준 설치) + 확장 부재
|
||||
* 조합에서 predis 폴백이 발동해야 합니다.
|
||||
*
|
||||
* 종전 게이트(env('REDIS_CLIENT') === null)는 표준 설치에서 env 값이 'phpredis' 라
|
||||
* 영구 미발동 — A2 가 막으려던 `Class "Redis" not found` 전면 다운이 그대로 남는다.
|
||||
* 확장 설치 머신에서는 부재 상태를 재현할 수 없으므로(A5 선례: mock 불가 시 구성
|
||||
* 검증으로 대체), 판정 술어를 확장 로드 여부 인자로 분리해 검증한다.
|
||||
*/
|
||||
public function test_predis_fallback_fires_for_phpredis_client_without_extension(): void
|
||||
{
|
||||
$provider = new SettingsServiceProvider($this->app);
|
||||
$method = new ReflectionMethod($provider, 'shouldFallBackToPredis');
|
||||
|
||||
// 표준 설치 상태: config 클라이언트 phpredis + 확장 부재 → 폴백해야 함
|
||||
Config::set('database.redis.client', 'phpredis');
|
||||
$this->assertTrue(
|
||||
$method->invoke($provider, false),
|
||||
'REDIS_CLIENT=phpredis 활성 배포 + 확장 부재 = 표준 설치 시나리오 — 폴백 미발동이면 redis 저장 시 전면 다운 (#99 A2)'
|
||||
);
|
||||
|
||||
// 확장이 있으면 phpredis 경로 유지
|
||||
$this->assertFalse($method->invoke($provider, true));
|
||||
|
||||
// predis 명시 설정이면 이미 동작 경로 — 개입하지 않음
|
||||
Config::set('database.redis.client', 'predis');
|
||||
$this->assertFalse($method->invoke($provider, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* applyRedisConfig 통합 — 폴백 판정이 env() 가 아닌 config 값을 기준으로 해야 합니다.
|
||||
*
|
||||
* env() 직접 호출은 config:cache 환경에서 null 로 고정된다 (A8 disk_explicit 와 동형).
|
||||
* 이 머신은 확장 설치 여부에 따라 결과가 갈리므로 분기 단언한다.
|
||||
*/
|
||||
public function test_apply_redis_config_fallback_uses_config_not_env(): void
|
||||
{
|
||||
Config::set('database.redis.client', 'phpredis');
|
||||
|
||||
$provider = new SettingsServiceProvider($this->app);
|
||||
$method = new ReflectionMethod($provider, 'applyRedisConfig');
|
||||
$method->invoke($provider, []);
|
||||
|
||||
if (extension_loaded('redis')) {
|
||||
$this->assertSame('phpredis', config('database.redis.client'), '확장이 있으면 phpredis 유지');
|
||||
} else {
|
||||
$this->assertSame('predis', config('database.redis.client'), '확장 부재 시 predis 폴백 (표준 설치 REDIS_CLIENT=phpredis 포함)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* upload.disk 죽은 주입 제거 회귀 — upload 카테고리의 disk 값이
|
||||
* filesystems.default 를 더 이상 덮어쓰지 않아야 합니다 (a2).
|
||||
*/
|
||||
public function test_upload_disk_no_longer_overrides_filesystems_default(): void
|
||||
{
|
||||
Config::set('filesystems.default', 'local');
|
||||
|
||||
$repository = Mockery::mock(JsonConfigRepository::class);
|
||||
$repository->shouldReceive('getCategory')->with('upload')->andReturn([
|
||||
'disk' => 'rogue-disk',
|
||||
'max_file_size' => 10,
|
||||
]);
|
||||
|
||||
$provider = new SettingsServiceProvider($this->app);
|
||||
$method = new ReflectionMethod($provider, 'applyUploadConfig');
|
||||
$method->invoke($provider, $repository);
|
||||
|
||||
$this->assertSame(
|
||||
'local',
|
||||
config('filesystems.default'),
|
||||
'upload.disk 는 저장 rule 없는 유령 키 — filesystems.default 를 storage_driver 와 경쟁 기록하면 안 됨'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* cache.driver/prefix 죽은 주입 제거 회귀 — applyCacheConfig 메서드 자체가 제거되어야 합니다 (a3).
|
||||
*/
|
||||
public function test_apply_cache_config_dead_injection_removed(): void
|
||||
{
|
||||
$this->assertFalse(
|
||||
method_exists(SettingsServiceProvider::class, 'applyCacheConfig'),
|
||||
'cache.driver/prefix 는 저장 경로 없는 죽은 주입 — drivers.cache_driver(cache.default SSoT)와 경쟁하므로 제거 유지'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Services\DriverConnectionTester;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* DriverConnectionTester 테스트 (공개 #99 / 내부 #563, A5·A6)
|
||||
*
|
||||
* testS3 의 endpoint/path-style 반영·어댑터 선검사, testWebsocket 의
|
||||
* client/server 양측 endpoint probe 를 검증합니다.
|
||||
*
|
||||
* 한계: Http::fake 는 요청 URL 계약까지만 검증한다 (요청 본문 미검증 —
|
||||
* feedback_http_fake_does_not_validate_request_body). S3 종단 검증은
|
||||
* Chrome MCP MinIO 매트릭스가 담당한다.
|
||||
*/
|
||||
class DriverConnectionTesterTest extends TestCase
|
||||
{
|
||||
private DriverConnectionTester $tester;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->tester = new DriverConnectionTester;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 설정 누락 시 s3_missing_config 메시지를 반환하는지 테스트합니다.
|
||||
*/
|
||||
public function test_s3_missing_config_returns_failure(): void
|
||||
{
|
||||
$result = $this->tester->testS3(['s3_bucket' => 'only-bucket']);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertSame(__('settings.s3_missing_config'), $result['message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 endpoint 가 실제 아웃바운드 대상에 반영되는지 테스트합니다.
|
||||
*
|
||||
* 도달 불가한 로컬 endpoint 로 시도 → 연결 실패의 error 문자열에 해당 endpoint
|
||||
* 호스트가 나타나야 한다 (AWS 리전 도메인이 아니라). 이것이 endpoint 미반영
|
||||
* 결함(#99 ③ — s3_url 만 있고 SDK 요청 대상은 amazonaws.com 고정)의 회귀 가드다.
|
||||
*
|
||||
* @scenario region_shape=custom_minio,endpoint=path_style,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects endpoint_injected
|
||||
*/
|
||||
public function test_s3_custom_endpoint_reaches_outbound_target(): void
|
||||
{
|
||||
$result = $this->tester->testS3([
|
||||
's3_bucket' => 'test-bucket',
|
||||
's3_region' => 'us-east-1',
|
||||
's3_access_key' => 'test-key',
|
||||
's3_secret_key' => 'test-secret',
|
||||
's3_endpoint' => 'http://127.0.0.1:9',
|
||||
's3_use_path_style' => true,
|
||||
]);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertArrayHasKey('error', $result);
|
||||
$this->assertStringContainsString(
|
||||
'127.0.0.1',
|
||||
$result['error'],
|
||||
'아웃바운드가 커스텀 endpoint 를 향해야 함 — amazonaws.com 고정이면 endpoint 미반영 회귀'
|
||||
);
|
||||
$this->assertStringNotContainsString('amazonaws.com', $result['error']);
|
||||
}
|
||||
|
||||
/**
|
||||
* path-style 토글이 아웃바운드 주소 형태를 실제로 바꾸는지 테스트합니다.
|
||||
*
|
||||
* IP endpoint 는 SDK 가 path-style 을 자동 강제해 off↔on 이 구분되지 않으므로
|
||||
* hostname endpoint 로 검증한다: off = virtual-hosted(버킷이 호스트에 접두) /
|
||||
* on = path-style(버킷이 경로로 이동). off 상태로도 통과하던 endpoint 단언의
|
||||
* 사각을 메우는 회귀 가드다.
|
||||
*
|
||||
* @scenario region_shape=custom_minio,endpoint=path_style,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects endpoint_injected
|
||||
*/
|
||||
public function test_s3_path_style_toggle_changes_outbound_address_shape(): void
|
||||
{
|
||||
$config = [
|
||||
's3_bucket' => 'test-bucket',
|
||||
's3_region' => 'us-east-1',
|
||||
's3_access_key' => 'test-key',
|
||||
's3_secret_key' => 'test-secret',
|
||||
's3_endpoint' => 'http://s3-parity.invalid:9',
|
||||
];
|
||||
|
||||
$virtualHosted = $this->tester->testS3($config + ['s3_use_path_style' => false]);
|
||||
$pathStyle = $this->tester->testS3($config + ['s3_use_path_style' => true]);
|
||||
|
||||
$this->assertFalse($virtualHosted['success']);
|
||||
$this->assertFalse($pathStyle['success']);
|
||||
|
||||
// off: 버킷명이 호스트에 접두된 virtual-hosted 주소를 향해야 한다
|
||||
$this->assertStringContainsString(
|
||||
'test-bucket.s3-parity.invalid',
|
||||
$virtualHosted['error'],
|
||||
'path-style off 는 virtual-hosted 주소여야 함'
|
||||
);
|
||||
|
||||
// on: 버킷 접두 호스트가 사라져야 한다 — off 와 동일하면 path-style 주입 회귀
|
||||
$this->assertStringNotContainsString(
|
||||
'test-bucket.s3-parity.invalid',
|
||||
$pathStyle['error'],
|
||||
'path-style on 인데 버킷 접두 호스트가 남아 있으면 use_path_style_endpoint 미반영 회귀'
|
||||
);
|
||||
$this->assertStringContainsString('s3-parity.invalid', $pathStyle['error']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 어댑터 선검사 계약 — 어댑터가 설치된 환경에서는 missing 분기가 발동하지 않고,
|
||||
* 코드가 어댑터 존재를 검사하고 있는지(클래스 상수 참조) 검증합니다.
|
||||
*
|
||||
* 어댑터는 composer 필수 의존성이라 부재 상태를 만들 수 없으므로 구성 검증으로 대체한다
|
||||
* (계획서 A5 — "mock 불가 시 구성 검증으로 대체").
|
||||
*/
|
||||
public function test_s3_adapter_precheck_present_in_code(): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
class_exists(AwsS3V3Adapter::class),
|
||||
'어댑터는 코어 필수 의존성 (#99 A1)'
|
||||
);
|
||||
|
||||
$source = file_get_contents(app_path('Services/DriverConnectionTester.php'));
|
||||
$this->assertStringContainsString(
|
||||
'AwsS3V3Adapter::class',
|
||||
$source,
|
||||
'testS3 는 어댑터 존재 선검사를 유지해야 함 — 테스트(SDK 직접)와 실경로(Flysystem)의 분리가 결함을 가리는 구조 방지 (A5)'
|
||||
);
|
||||
$this->assertStringContainsString('s3_adapter_missing', $source);
|
||||
}
|
||||
|
||||
/**
|
||||
* testWebsocket 이 client 와 server endpoint 를 모두 probe 하는지 테스트합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects test_covers_server_websocket
|
||||
*/
|
||||
public function test_websocket_probes_both_client_and_server_endpoints(): void
|
||||
{
|
||||
Http::fake([
|
||||
'client.test:8443*' => Http::response('ok', 200),
|
||||
'server.test:8080*' => Http::response('ok', 200),
|
||||
]);
|
||||
|
||||
$result = $this->tester->testWebsocket([
|
||||
'websocket_host' => 'client.test',
|
||||
'websocket_port' => 8443,
|
||||
'websocket_scheme' => 'https',
|
||||
'websocket_server_host' => 'server.test',
|
||||
'websocket_server_port' => 8080,
|
||||
'websocket_server_scheme' => 'http',
|
||||
]);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://client.test'));
|
||||
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'http://server.test'));
|
||||
}
|
||||
|
||||
/**
|
||||
* server endpoint 실패 시 어느 쪽 실패인지 구분된 메시지를 반환하는지 테스트합니다.
|
||||
*/
|
||||
public function test_websocket_server_endpoint_failure_is_distinguished(): void
|
||||
{
|
||||
Http::fake([
|
||||
'client.test:8443*' => Http::response('ok', 200),
|
||||
'server.test:8080*' => Http::response('error', 500),
|
||||
]);
|
||||
|
||||
$result = $this->tester->testWebsocket([
|
||||
'websocket_host' => 'client.test',
|
||||
'websocket_port' => 8443,
|
||||
'websocket_scheme' => 'https',
|
||||
'websocket_server_host' => 'server.test',
|
||||
'websocket_server_port' => 8080,
|
||||
'websocket_server_scheme' => 'http',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertSame(
|
||||
__('settings.websocket_server_test_failed'),
|
||||
$result['message'],
|
||||
'백엔드 발송은 server endpoint 를 쓴다 — client 만 검사하면 "테스트 성공 + 실제 발송 실패" (A6)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* server 필드가 비어 있으면 client 값으로 폴백하며 중복 probe 를 생략하는지 테스트합니다.
|
||||
*/
|
||||
public function test_websocket_server_fields_fallback_to_client(): void
|
||||
{
|
||||
Http::fake([
|
||||
'client.test:8443*' => Http::response('ok', 200),
|
||||
]);
|
||||
|
||||
$result = $this->tester->testWebsocket([
|
||||
'websocket_host' => 'client.test',
|
||||
'websocket_port' => 8443,
|
||||
'websocket_scheme' => 'https',
|
||||
]);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
Http::assertSentCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* client endpoint 실패 시 기존 메시지 계약이 유지되는지 테스트합니다 (하위호환).
|
||||
*/
|
||||
public function test_websocket_client_failure_keeps_existing_contract(): void
|
||||
{
|
||||
Http::fake([
|
||||
'client.test:8443*' => Http::response('error', 500),
|
||||
]);
|
||||
|
||||
$result = $this->tester->testWebsocket([
|
||||
'websocket_host' => 'client.test',
|
||||
'websocket_port' => 8443,
|
||||
'websocket_scheme' => 'https',
|
||||
'websocket_server_host' => 'server.test',
|
||||
'websocket_server_port' => 8080,
|
||||
'websocket_server_scheme' => 'http',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertSame(__('settings.websocket_test_failed'), $result['message']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Services\DriverRegistryService;
|
||||
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
|
||||
use Predis\Client;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* DriverRegistryService 드라이버 가용성 판정 테스트 (공개 #99 / 내부 #563, A7)
|
||||
*
|
||||
* isDriverUsable()/usabilityFailureReason() 의 런타임 능력 판정과
|
||||
* a5(CONFIG_KEYS 로그 키 정정)·a6(websocket 설정 키 제외) 회귀를 검증합니다.
|
||||
*/
|
||||
class DriverRegistryServiceUsabilityTest extends TestCase
|
||||
{
|
||||
private DriverRegistryService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->service = new DriverRegistryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* s3 드라이버 가용성이 Flysystem 어댑터 존재와 일치하는지 테스트합니다.
|
||||
*
|
||||
* 어댑터는 composer require 로 코어에 포함되므로(#99 A1) 항상 true 여야 합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
||||
*
|
||||
* @effects s3_disk_resolves
|
||||
*/
|
||||
public function test_s3_usability_matches_adapter_presence(): void
|
||||
{
|
||||
$expected = class_exists(AwsS3V3Adapter::class);
|
||||
|
||||
$this->assertSame($expected, $this->service->isDriverUsable('storage', 's3'));
|
||||
$this->assertTrue(
|
||||
$expected,
|
||||
'league/flysystem-aws-s3-v3 는 코어 필수 의존성 — 부재 시 S3 선택이 즉사한다 (#99 A1)'
|
||||
);
|
||||
$this->assertNull($this->service->usabilityFailureReason('storage', 's3'));
|
||||
}
|
||||
|
||||
/**
|
||||
* redis 드라이버 가용성이 phpredis 확장 또는 predis 존재와 일치하는지 테스트합니다.
|
||||
*
|
||||
* predis 는 composer require 로 코어에 포함되므로(#99 A2) 항상 true 여야 합니다.
|
||||
*/
|
||||
public function test_redis_usability_matches_client_presence(): void
|
||||
{
|
||||
$expected = extension_loaded('redis') || class_exists(Client::class);
|
||||
|
||||
$this->assertSame($expected, $this->service->isDriverUsable('cache', 'redis'));
|
||||
$this->assertTrue(
|
||||
$expected,
|
||||
'predis/predis 는 코어 필수 의존성 — 확장 없는 서버에서 redis 선택 시 전면 다운 방어 (#99 A2)'
|
||||
);
|
||||
|
||||
// predis 명시 설정 분기 — 판정은 설정 클라이언트 기준 (predis 는 코어 의존성이라 참)
|
||||
config(['database.redis.client' => 'predis']);
|
||||
$this->assertTrue($this->service->isDriverUsable('cache', 'redis'));
|
||||
}
|
||||
|
||||
/**
|
||||
* memcached 드라이버 가용성이 PHP 확장 존재와 일치하는지 테스트합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_absent,attachment_disk=local_default
|
||||
*
|
||||
* @effects unusable_driver_rejected
|
||||
*/
|
||||
public function test_memcached_usability_matches_extension(): void
|
||||
{
|
||||
$expected = extension_loaded('memcached');
|
||||
|
||||
$this->assertSame($expected, $this->service->isDriverUsable('cache', 'memcached'));
|
||||
|
||||
if (! $expected) {
|
||||
$this->assertNotNull(
|
||||
$this->service->usabilityFailureReason('cache', 'memcached'),
|
||||
'사용 불능 드라이버는 사유 문자열을 제공해야 함 (422 메시지 파라미터)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 능력 요구가 없는 드라이버(local/file 등)와 미지정 드라이버는 항상 사용 가능해야 합니다.
|
||||
*/
|
||||
public function test_unknown_and_capability_free_drivers_are_usable(): void
|
||||
{
|
||||
$this->assertTrue($this->service->isDriverUsable('storage', 'local'));
|
||||
$this->assertTrue($this->service->isDriverUsable('cache', 'file'));
|
||||
$this->assertTrue($this->service->isDriverUsable('queue', 'database'));
|
||||
// 플러그인 등록 드라이버 — 가용성은 해당 플러그인 SP 가 보증하므로 코어는 통과
|
||||
$this->assertTrue($this->service->isDriverUsable('storage', 'plugin-custom-driver'));
|
||||
}
|
||||
|
||||
/**
|
||||
* a5 회귀 — log 카테고리 Config 키가 실제 적용 키(stack.channels)와 일치해야 합니다.
|
||||
*/
|
||||
public function test_log_config_key_matches_actual_applied_key(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'logging.channels.stack.channels',
|
||||
$this->service->getConfigKey('log'),
|
||||
'logging.default 는 어디에서도 읽지 않는 죽은 키 — applyLogConfig 실제 기록 키와 일치해야 폴백이 실효한다 (a5)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* log 카테고리의 Config 기록 값은 채널 배열 형태여야 합니다.
|
||||
*/
|
||||
public function test_log_config_value_is_channel_array(): void
|
||||
{
|
||||
$this->assertSame(['daily'], $this->service->getConfigValueForDriver('log', 'daily'));
|
||||
$this->assertSame('file', $this->service->getConfigValueForDriver('cache', 'file'));
|
||||
}
|
||||
|
||||
/**
|
||||
* a6 회귀 — websocket 은 드라이버 ID 선택 설정이 없으므로 설정 키 매핑에서 제외되어야 합니다.
|
||||
*/
|
||||
public function test_websocket_settings_key_excluded(): void
|
||||
{
|
||||
$this->assertNull(
|
||||
$this->service->getSettingsKey('websocket'),
|
||||
"websocket 은 불리언 websocket_enabled 뿐 — 종전 'websocket_driver' 는 어떤 저장 경로에도 없는 유령 키였다 (a6)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface;
|
||||
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateLayoutAttachment;
|
||||
use App\Services\TemplateLayoutAttachmentService;
|
||||
use Mockery;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 템플릿 레이아웃 첨부 디스크 인지 서빙 테스트 (공개 #99 / 내부 #563)
|
||||
*
|
||||
* PublicAttachmentController 이미지 서빙과 동형 결함 — getServableFilePath 가
|
||||
* getBasePath 로 로컬 절대 경로를 조립해 S3 디스크 행에서 fileResponse(filemtime)가
|
||||
* 500 이 나던 구조의 회귀 가드. 서빙은 StorageInterface::response() 스트림으로
|
||||
* 디스크 무관하게 성립해야 한다.
|
||||
*/
|
||||
class TemplateLayoutAttachmentServingTest extends TestCase
|
||||
{
|
||||
private TemplateLayoutAttachmentService $service;
|
||||
|
||||
private $templateRepository;
|
||||
|
||||
private $storage;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$repository = Mockery::mock(TemplateLayoutAttachmentRepositoryInterface::class);
|
||||
$this->templateRepository = Mockery::mock(TemplateRepositoryInterface::class);
|
||||
$this->storage = Mockery::mock(StorageInterface::class);
|
||||
|
||||
$this->service = new TemplateLayoutAttachmentService(
|
||||
$repository,
|
||||
$this->templateRepository,
|
||||
$this->storage
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 소속 템플릿 첨부를 만듭니다 (DB 미저장).
|
||||
*/
|
||||
private function makeAttachment(int $templateId, string $disk = 's3'): TemplateLayoutAttachment
|
||||
{
|
||||
$attachment = new TemplateLayoutAttachment([
|
||||
'layout_name' => 'home',
|
||||
'disk' => $disk,
|
||||
'path' => 'sirsoft-basic/home/bg.png',
|
||||
'original_name' => 'bg.png',
|
||||
'mime_type' => 'image/png',
|
||||
'size' => 70,
|
||||
]);
|
||||
$attachment->template_id = $templateId;
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* s3 디스크 행이 StorageInterface::response() 스트림으로 서빙 정보를 반환해야 합니다.
|
||||
*
|
||||
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=follow_s3
|
||||
*
|
||||
* @effects attachment_upload_follows_driver
|
||||
*/
|
||||
public function test_s3_disk_attachment_returns_streamed_response(): void
|
||||
{
|
||||
$template = new Template;
|
||||
$template->id = 7;
|
||||
$this->templateRepository->shouldReceive('findByIdentifier')
|
||||
->with('sirsoft-basic')->andReturn($template);
|
||||
|
||||
$stream = new StreamedResponse(function () {
|
||||
echo 'bytes';
|
||||
}, 200, ['Content-Type' => 'image/png']);
|
||||
|
||||
$this->storage->shouldReceive('withDisk')->with('s3')->andReturnSelf();
|
||||
$this->storage->shouldReceive('response')->andReturn($stream);
|
||||
|
||||
$result = $this->service->getServableResponse('sirsoft-basic', $this->makeAttachment(7));
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame($stream, $result['response']);
|
||||
$this->assertNotEmpty($result['etag_source']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로 템플릿과 첨부 소속이 다르면 null 이어야 합니다 (교차 템플릿 접근 차단 유지).
|
||||
*/
|
||||
public function test_cross_template_access_returns_null(): void
|
||||
{
|
||||
$template = new Template;
|
||||
$template->id = 7;
|
||||
$this->templateRepository->shouldReceive('findByIdentifier')
|
||||
->with('sirsoft-basic')->andReturn($template);
|
||||
|
||||
$result = $this->service->getServableResponse('sirsoft-basic', $this->makeAttachment(99));
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스토리지에 파일이 없으면(response null) null 이어야 합니다.
|
||||
*/
|
||||
public function test_missing_file_returns_null(): void
|
||||
{
|
||||
$template = new Template;
|
||||
$template->id = 7;
|
||||
$this->templateRepository->shouldReceive('findByIdentifier')
|
||||
->with('sirsoft-basic')->andReturn($template);
|
||||
|
||||
$this->storage->shouldReceive('withDisk')->with('s3')->andReturnSelf();
|
||||
$this->storage->shouldReceive('response')->andReturnNull();
|
||||
|
||||
$result = $this->service->getServableResponse('sirsoft-basic', $this->makeAttachment(7));
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
feature: S3 스토리지 드라이버 실동작 (공개 #99 / 내부 #563)
|
||||
|
||||
description: |
|
||||
관리자 > 환경설정 > 드라이버의 S3 선택이 실제 파일 저장으로 이어지게 하는 결함 수정.
|
||||
|
||||
핵심 동작:
|
||||
- league/flysystem-aws-s3-v3 / predis 코어 의존성 포함 (해석 시점 즉사 소멸)
|
||||
- s3_endpoint / s3_use_path_style 신설 → filesystems.disks.s3.{endpoint,use_path_style_endpoint} 주입
|
||||
(S3 호환 스토리지 R2/MinIO/NCP 연결 경로)
|
||||
- s3_region 3중 하드코딩 제거 → 형식 검증(regex ^[a-z0-9-]+$, max 64) 완화
|
||||
- 드라이버 가용성 서버 게이트: 어댑터 클래스/PHP 확장 부재 드라이버 저장·테스트 422
|
||||
- 코어 첨부 업로드 디스크: ATTACHMENT_DISK env > storage_driver=s3 → s3 > attachments
|
||||
(기존 행은 행 disk 서빙 — 혼재 안전)
|
||||
- 연결 테스트: 어댑터 선검사 + endpoint/path-style 반영, websocket 은 client/server
|
||||
양측 endpoint probe (백엔드 발송은 server endpoint 사용)
|
||||
|
||||
axes:
|
||||
region_shape: [aws_region, auto, custom_minio, invalid_format]
|
||||
endpoint: [absent, custom_url, path_style]
|
||||
driver_usability: [adapter_present, adapter_absent]
|
||||
attachment_disk: [env_override, follow_s3, local_default]
|
||||
|
||||
exclusions:
|
||||
- { region_shape: invalid_format, endpoint: custom_url, reason: "리전 검증이 선행 422 — endpoint 축 도달 전 차단" }
|
||||
- { region_shape: invalid_format, endpoint: path_style, reason: "동일" }
|
||||
- { region_shape: invalid_format, attachment_disk: env_override, reason: "저장 실패 시 디스크 전환 미발생" }
|
||||
- { region_shape: invalid_format, attachment_disk: follow_s3, reason: "동일" }
|
||||
- { driver_usability: adapter_absent, endpoint: custom_url, reason: "가용성 게이트가 선행 422 — 어댑터는 코어 필수 의존성이라 부재 상태는 memcached 확장 축으로 대변" }
|
||||
- { driver_usability: adapter_absent, endpoint: path_style, reason: "동일" }
|
||||
- { driver_usability: adapter_absent, attachment_disk: follow_s3, reason: "저장 차단 시 디스크 전환 미발생" }
|
||||
- { driver_usability: adapter_absent, attachment_disk: env_override, reason: "동일" }
|
||||
- { driver_usability: adapter_absent, region_shape: invalid_format, reason: "리전 검증이 선행 422 — 가용성 축 도달 전 차단" }
|
||||
- { driver_usability: adapter_absent, region_shape: auto, reason: "가용성 게이트는 드라이버 ID 만 판정 — 리전 값과 직교, aws_region 대표 조합으로 검증" }
|
||||
- { driver_usability: adapter_absent, region_shape: custom_minio, reason: "동일" }
|
||||
- { attachment_disk: env_override, region_shape: auto, reason: "디스크 전환 판정은 storage_driver 값만 본다 — 리전·endpoint 와 직교, aws_region×absent 대표 조합으로 검증" }
|
||||
- { attachment_disk: env_override, region_shape: custom_minio, reason: "동일" }
|
||||
- { attachment_disk: env_override, endpoint: custom_url, reason: "동일" }
|
||||
- { attachment_disk: env_override, endpoint: path_style, reason: "동일" }
|
||||
- { attachment_disk: follow_s3, region_shape: auto, reason: "디스크 전환 판정은 storage_driver 값만 본다 — aws_region×absent(단위)·custom_minio×path_style(Feature 왕복) 두 대표 조합으로 검증" }
|
||||
- { attachment_disk: follow_s3, endpoint: custom_url, reason: "동일" }
|
||||
- { attachment_disk: follow_s3, region_shape: aws_region, endpoint: path_style, reason: "동일" }
|
||||
- { attachment_disk: follow_s3, region_shape: custom_minio, endpoint: absent, reason: "동일" }
|
||||
- { region_shape: auto, endpoint: absent, reason: "R2 의 auto 리전은 커스텀 endpoint 전제 — endpoint 없는 auto 는 실사용 조합이 아님" }
|
||||
- { region_shape: auto, endpoint: path_style, reason: "R2 는 virtual-host 스타일 — path_style 조합은 custom_minio 축이 대표" }
|
||||
- { region_shape: custom_minio, endpoint: absent, reason: "MinIO 리전 값은 커스텀 endpoint 전제 — endpoint 없는 조합은 실사용이 아님" }
|
||||
- { region_shape: custom_minio, endpoint: custom_url, reason: "MinIO 는 path-style 대표 조합으로 검증 — virtual-host 모드도 endpoint 주입 경로는 동일" }
|
||||
|
||||
effects:
|
||||
- s3_disk_resolves
|
||||
- endpoint_injected
|
||||
- attachment_upload_follows_driver
|
||||
- unusable_driver_rejected
|
||||
- invalid_region_rejected
|
||||
- test_covers_server_websocket
|
||||
- unauthenticated_returns_401
|
||||
# E2E/렌더 표면 효과 (Playwright·Vitest 가 마킹)
|
||||
- region_free_input_mounted
|
||||
- endpoint_field_mounted
|
||||
- path_style_toggle_mounted
|
||||
- local_roundtrip_preserves_new_keys
|
||||
|
||||
# E2E 표면 명세 (문서화) — 커버리지 집계는 아래 test_files 의 @scenario/@effects 마킹이 담당
|
||||
e2e:
|
||||
surfaces: [region_free_input, endpoint_field, path_style_toggle, save_gating]
|
||||
|
||||
test_files:
|
||||
- tests/Playwright/specs/admin/settings-drivers-s3-endpoint.spec.ts
|
||||
- tests/Unit/Providers/SettingsServiceProviderS3ConfigTest.php
|
||||
- tests/Unit/Services/DriverConnectionTesterTest.php
|
||||
- tests/Unit/Services/DriverRegistryServiceUsabilityTest.php
|
||||
- tests/Feature/Api/Admin/SettingsControllerS3ValidationTest.php
|
||||
- tests/Feature/Api/Public/AttachmentS3DiskServingTest.php
|
||||
- tests/Unit/Services/TemplateLayoutAttachmentServingTest.php
|
||||
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-drivers-s3-fields.test.tsx
|
||||
+17
-7
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"generated_at": "2026-07-10T21:33:36+00:00",
|
||||
"generated_at": "2026-08-12T08:04:04+00:00",
|
||||
"generator": "g7 vendor-bundle:build",
|
||||
"target": "core",
|
||||
"composer_json_sha256": "76294945639dbbe4b6d054c0aea4b78377a55bcdffcd1c9136beb294bc7cfc90",
|
||||
"composer_lock_sha256": "b4d173c40088a78c9fe42b3ca6d23436bee6cc850a17bb653cbc1aca59ca3a81",
|
||||
"zip_sha256": "d5d21fdd930350332d2d58e5ab386ba9203795f72f7767eb3c214d4702152245",
|
||||
"zip_size": 14750349,
|
||||
"package_count": 104,
|
||||
"composer_json_sha256": "3c7e6e17966f09f12a0282b12b18eba46c5c4f06d63c2355a514a3160af1d77a",
|
||||
"composer_lock_sha256": "49bd7586c511e420bb76418579b8ca94676870d4940fdaf6bb6e0c769dc54ce0",
|
||||
"zip_sha256": "f193db3da359dfa2ce8f1da9ab1c4d9c090b22f4e607ae63bc1955c7c2317db2",
|
||||
"zip_size": 15212477,
|
||||
"package_count": 106,
|
||||
"php_requirement": "^8.2",
|
||||
"g7_version": "7.0.3",
|
||||
"g7_version": "7.0.7",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -171,6 +171,11 @@
|
||||
"version": "3.35.1",
|
||||
"type": "library"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
"version": "3.35.2",
|
||||
"type": "library"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-local",
|
||||
"version": "3.31.0",
|
||||
@@ -241,6 +246,11 @@
|
||||
"version": "1.9.5",
|
||||
"type": "library"
|
||||
},
|
||||
{
|
||||
"name": "predis/predis",
|
||||
"version": "v2.4.1",
|
||||
"type": "library"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
"version": "1.0.0",
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user