diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd9dbad..02270118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ ## [7.0.6] - 2026-07-19 +### Security + +- 보안 환경설정의 계정 잠금 시간을 `0`(무한대)으로 설정하면 실제로 무기한 잠기도록 수정했습니다. 화면 안내는 "0을 입력하면 무한대"였지만 실제로는 1분 뒤 자동 해제되어, 무차별 대입 시도를 영구 차단할 수 없었습니다. 무기한 잠긴 계정에는 남은 시간 대신 관리자 문의 안내가 표시됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 환경설정 > 보안에 비밀번호 최소 길이와 특수문자 필수 여부를 추가했습니다. 두 항목은 설정 화면에만 있고 실제로는 어디에도 적용되지 않던 값으로, 이제 비밀번호를 새로 정하는 모든 화면(회원가입·비밀번호 변경·재설정·프로필 수정·관리자의 회원 등록과 수정)에 적용됩니다. 로그인에는 적용하지 않습니다 — 정책을 올렸을 때 기존 비밀번호를 쓰는 회원이 자기 계정에 로그인조차 못 하게 되는 것을 막기 위함입니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 본인인증 메일의 인증번호 자리수 설정이 4~10 범위를 벗어나면 조용히 잘려 저장 값과 다르게 동작하던 문제를 수정했습니다. 이제 허용 범위가 설정 화면에 표시되고, 범위를 벗어난 값은 기본값(6자리)으로 처리하며 기록을 남깁니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 환경설정 > 보안의 로그인 시도 제한 항목에 IP 단위 요청 제한이 어떻게 계산되는지(설정값의 6배, 분당 최소 30회 보장) 안내를 추가했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 환경설정 > 업로드의 최대 파일 크기와 허용 파일 형식이 실제 업로드에 적용되도록 수정했습니다. 이전에는 설정을 바꿔도 어느 경로에서도 반영되지 않아, 허용 형식 목록에 없는 확장자(예: `.php`)도 업로드할 수 있었습니다. 허용 형식 목록을 비워 두면 종전처럼 형식을 제한하지 않습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 허용 파일 형식을 저장해도 업로드 제한에 반영되지 않던 문제를 수정했습니다. 입력 칸은 `jpg,png,pdf` 처럼 쉼표로 구분한 한 줄이지만 적용 단계가 그 형태를 받아들이지 못해, 업로드 탭을 한 번이라도 저장하면 형식 제한이 저장 이전 목록으로 남았습니다. 저장은 성공하고 화면에도 값이 보여 어긋난 것을 알 방법이 없었습니다. 대문자나 앞의 점(`.JPG`, `.png`)은 자동으로 정리되며, 이미 저장되어 있던 값도 그대로 적용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 업로드된 파일의 저장 확장자를 파일명이 아니라 파일 내용에서 판별하도록 바꿨습니다. 확장자만 바꿔 올린 파일이 원래 형식과 다르게 저장되던 문제가 해소됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 환경설정 > 본인인증의 '가입 후 본인확인' 정책을 켜면 회원가입 자체가 막혀 아무도 가입할 수 없던 문제를 수정했습니다. 이 정책은 가입은 받아들이고 계정을 '인증 대기' 상태로 둔 뒤 본인확인 안내를 보내는 설정인데, 안내를 보낸 직후 가입이 거절되어 '가입 전 본인확인' 정책과 구분이 없었습니다. 이제 설정한 대로 가입이 완료되고, 본인확인을 마치면 계정이 활성화됩니다. (안내 발송이 실패해도 가입은 유지되며 실패 사실은 기록에 남습니다.) +- 보안 환경설정의 '2단계 인증'이 실제로 동작합니다. 이전에는 항목만 있고 켜도 아무 일이 일어나지 않아, 관리자는 2단계 인증이 걸린 줄 알지만 비밀번호 하나로 로그인됐습니다. 이제 켜 두면 비밀번호 확인 뒤 인증번호를 메일로 보내고, 그 번호를 확인해야 로그인이 완료됩니다. 번호를 확인하기 전에는 로그인 상태가 만들어지지 않습니다. 안내 메일 문구는 환경설정 > 본인인증에서 '로그인 2단계 인증' 목적을 골라 따로 지정할 수 있습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 환경설정 > 업로드의 이미지 최대 가로/세로 크기와 품질이 실제 업로드에 적용됩니다. 이전에는 값을 저장해도 이미지를 줄이는 동작이 없어 원본이 그대로 저장됐습니다. 크기를 지정하지 않으면 종전처럼 줄이지 않으며, 지정한 크기 이내인 이미지는 다시 변환하지 않아 불필요한 화질 손실이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 회원 상세 화면에 계정 잠금 상태 표시와 잠금 해제 버튼을 추가했습니다. 무기한 잠긴 계정은 로그인 성공으로 풀 수 없으므로 관리자가 직접 해제할 수 있어야 합니다. 해제는 활동 로그에 남습니다. (사용자 수정 권한이 필요합니다.) (#81 @jiwonpapa 님께서 제보해주셨습니다.) + ### Added - 회원가입 시 휴대폰번호와 전화번호를 선택 항목으로 입력할 수 있습니다. 두 항목 모두 필수가 아니며, 입력하면 내 정보에서 확인·수정할 수 있습니다. @@ -35,6 +49,7 @@ ### Changed +- 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다. - 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다. ### Fixed @@ -59,6 +74,8 @@ - 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다. - 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다. - 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다. +- 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다. +- 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다. #### 설정 저장·입력 검증 @@ -72,6 +89,7 @@ #### 안내 문구·주소 처리 - 일부 안내가 실제 문장 대신 내부 식별자(`messages.auth.unauthenticated` 같은 문자열)로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때, 메뉴에 역할을 지정할 때, 확장 식별자를 잘못 입력했을 때, 활동 로그를 기간·유형으로 검색할 때가 해당됩니다. +- 환경설정 저장이 실패했을 때 안내 문구에 항목 이름이 `security.password min length` 같은 내부 식별자로 표시되던 문제를 수정했습니다. 보안·업로드·SEO·고급·드라이버·메일 등 모든 설정 항목이 화면에 보이는 한글 항목명으로 안내됩니다. - 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다. - 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다. - 주소 끝에 슬래시가 붙은 형태(예: `/admin/`)로 접속하면 화면이 "페이지 없음"(404)으로 뜨던 문제를 수정했습니다. 특히 로그인이 풀린 상태에서 이렇게 접속하면 로그인 화면으로 넘어가지 않고 404가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다. diff --git a/app/Contracts/Repositories/UserRepositoryInterface.php b/app/Contracts/Repositories/UserRepositoryInterface.php index 703811aa..a3fc7bbe 100644 --- a/app/Contracts/Repositories/UserRepositoryInterface.php +++ b/app/Contracts/Repositories/UserRepositoryInterface.php @@ -144,17 +144,21 @@ interface UserRepositoryInterface * `locked_until` 을 현재 시각 + $minutes 로 설정하고 `failed_login_attempts` 를 * 0 으로 리셋합니다 (다음 잠금 윈도우 시작점). 잠금 해제 시각을 반환합니다. * + * `$minutes <= 0` 은 보안 환경설정의 "0 = 무한대" 규약에 따라 영구 잠금으로 처리하며, + * 이때 `locked_until` 은 NULL, `locked_permanently` 는 true 가 되고 null 을 반환합니다. + * * @param User $user 잠글 사용자 - * @param int $minutes 잠금 유지 시간(분) - * @return Carbon 잠금 해제 시각 + * @param int $minutes 잠금 유지 시간(분). 0 이하는 무기한 + * @return Carbon|null 잠금 해제 시각 (영구 잠금은 null) */ - public function lockAccount(User $user, int $minutes): Carbon; + public function lockAccount(User $user, int $minutes): ?Carbon; /** * 사용자의 모든 로그인 시도 추적 컬럼을 초기화합니다. * - * 정상 로그인 성공 시 호출됩니다 (`failed_login_attempts=0`, - * `locked_until=null`, `last_failed_login_at=null`). + * 정상 로그인 성공 시 또는 관리자 수동 해제 시 호출됩니다 + * (`failed_login_attempts=0`, `locked_until=null`, `locked_permanently=false`, + * `last_failed_login_at=null`). * * @param User $user 대상 사용자 */ @@ -163,10 +167,44 @@ interface UserRepositoryInterface /** * 사용자의 계정이 현재 시점에 잠금 상태인지 판정합니다. * - * `locked_until` 이 NULL 이거나 현재 시각보다 과거이면 false 를 반환합니다. + * `locked_permanently` 가 true 면 항상 true 입니다. 그 외에는 `locked_until` 이 + * NULL 이거나 현재 시각보다 과거이면 false 를 반환합니다. * * @param User $user 대상 사용자 * @return bool 잠금 여부 */ public function isLocked(User $user): bool; + + /** + * UUID 로 사용자를 찾습니다. + * + * @param string $uuid 사용자 UUID + * @return User|null 찾은 사용자 모델 또는 null + */ + public function findByUuid(string $uuid): ?User; + + /** + * UUID 목록에 해당하는 사용자의 정수 ID 배열을 반환합니다. + * + * @param array $uuids 사용자 UUID 배열 + * @return array 사용자 ID 배열 + */ + public function getIdsByUuids(array $uuids): array; + + /** + * 사용자 ID 목록의 지정 컬럼을 일괄 갱신합니다. + * + * @param array $ids 사용자 ID 배열 + * @param array $data 갱신할 컬럼 값 + * @return int 갱신된 행 수 + */ + public function updateManyByIds(array $ids, array $data): int; + + /** + * 사용자 ID 목록의 인증 토큰을 모두 삭제합니다. + * + * @param array $ids 사용자 ID 배열 + * @return int 삭제된 토큰 수 + */ + public function deleteTokensByUserIds(array $ids): int; } diff --git a/app/Enums/IdentityVerificationPurpose.php b/app/Enums/IdentityVerificationPurpose.php index 32fc9a21..b720fe62 100644 --- a/app/Enums/IdentityVerificationPurpose.php +++ b/app/Enums/IdentityVerificationPurpose.php @@ -5,10 +5,15 @@ namespace App\Enums; /** * 본인인증 코어 purpose Enum. * - * 코어가 계약으로 보장하는 4종 — `MailIdentityProvider` 가 모두 지원합니다. + * 코어가 계약으로 보장하는 5종 — `MailIdentityProvider` 가 모두 지원합니다. * 모듈/플러그인은 `AbstractModule::getIdentityPurposes()` / `AbstractPlugin::getIdentityPurposes()` * 로 추가 purpose 를 선언할 수 있으며, 그 값은 `IdentityVerificationManager::declaredPurposes` - * 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 4종만 정의합니다. + * 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 5종만 정의합니다. + * + * case 를 추가하면 `IdentityVerificationManager::$corePurposes` 등록과 + * `lang/{ko,en}/identity.php` 의 `purposes.{value}` 라벨을 함께 추가해야 합니다. + * 하나라도 빠지면 `label()` 이 i18n 키 원문을 그대로 돌려주고, 관리자 화면의 + * 목적 목록에서 그 목적이 통째로 빠집니다. * * @since 7.0.0-beta.5 */ @@ -26,6 +31,15 @@ enum IdentityVerificationPurpose: string /** 민감 작업 (결제 등) */ case SensitiveAction = 'sensitive_action'; + /** + * 로그인 2단계 인증 + * + * 비밀번호 확인을 통과한 뒤 한 단계를 더 요구하는 용도입니다. 다른 purpose 와 달리 + * 이 challenge 는 **아직 로그인하지 않은 주체**를 대상으로 하므로, 검증을 마치기 + * 전까지 토큰이 발급되지 않습니다. + */ + case Login = 'login'; + /** * 코어 purpose 값 배열. * diff --git a/app/Exceptions/Auth/AccountLockedException.php b/app/Exceptions/Auth/AccountLockedException.php index 71ba0fec..192c7772 100644 --- a/app/Exceptions/Auth/AccountLockedException.php +++ b/app/Exceptions/Auth/AccountLockedException.php @@ -12,6 +12,10 @@ use Symfony\Component\HttpKernel\Exception\HttpException; * HTTP 423 Locked 응답으로 매핑되며, 프론트엔드 토스트는 다국어 키 * `auth.account_locked` 로 잔여 분(`minutes` 플레이스홀더)을 노출합니다. * + * `login_lockout_time = 0`(무한대) 으로 잠긴 계정은 해제 시각이 없으므로 + * `lockedUntil` / `remainingMinutes` 가 모두 null 입니다. 이 경우 프론트엔드는 + * 잔여 시간 대신 `auth.account_locked_permanently` 안내를 노출합니다. + * * 컨트롤러는 본 예외를 별도로 catch 하여 ResponseHelper 응답을 만들거나, * 글로벌 예외 핸들러가 자동으로 423 JSON 응답으로 변환합니다. * @@ -20,15 +24,26 @@ use Symfony\Component\HttpKernel\Exception\HttpException; class AccountLockedException extends HttpException { public function __construct( - public readonly Carbon $lockedUntil, - public readonly int $remainingMinutes, + public readonly ?Carbon $lockedUntil, + public readonly ?int $remainingMinutes, ?string $message = null, ) { parent::__construct( 423, - $message ?? 'auth.account_locked', + $message ?? ($lockedUntil === null ? 'auth.account_locked_permanently' : 'auth.account_locked'), null, - ['Retry-After' => max(1, $remainingMinutes * 60)] + // 영구 잠금은 재시도 시점을 제시할 수 없으므로 Retry-After 를 붙이지 않는다. + $remainingMinutes === null ? [] : ['Retry-After' => max(1, $remainingMinutes * 60)] ); } + + /** + * 영구 잠금 여부를 반환합니다. + * + * @return bool 해제 시각이 없는 무기한 잠금이면 true + */ + public function isPermanent(): bool + { + return $this->lockedUntil === null; + } } diff --git a/app/Extension/IdentityVerification/IdentityVerificationManager.php b/app/Extension/IdentityVerification/IdentityVerificationManager.php index 761dd6bc..05836573 100644 --- a/app/Extension/IdentityVerification/IdentityVerificationManager.php +++ b/app/Extension/IdentityVerification/IdentityVerificationManager.php @@ -38,9 +38,13 @@ class IdentityVerificationManager /** * 코어 기본 purpose 목록. * - * `signup` / `password_reset` / `self_update` / `sensitive_action` — 이 4종은 + * `signup` / `password_reset` / `self_update` / `sensitive_action` / `login` — 이 5종은 * 코어가 계약으로 보장하며 `MailIdentityProvider` 가 모두 지원합니다. * + * `IdentityVerificationPurpose` 에 case 를 추가하면 이 레지스트리에도 반드시 등록해야 + * 합니다. 등록하지 않으면 `getAllPurposes()` 에서 빠져 관리자가 그 목적의 메시지 템플릿· + * 정책을 만들 수 없고, `hasPurpose()` 도 false 가 됩니다. + * * @var array> */ protected array $corePurposes = [ @@ -76,6 +80,14 @@ class IdentityVerificationManager 'source_type' => IdentityPolicySourceType::Core->value, 'source_identifier' => 'core', ], + IdentityVerificationPurpose::Login->value => [ + 'label' => 'identity.purposes.login.label', + 'description' => 'identity.purposes.login.description', + 'default_provider' => null, + 'allowed_channels' => [IdentityVerificationChannel::Email->value], + 'source_type' => IdentityPolicySourceType::Core->value, + 'source_identifier' => 'core', + ], ]; /** @@ -87,7 +99,6 @@ class IdentityVerificationManager * 프로바이더를 등록합니다. * * @param IdentityVerificationInterface $provider IDV 프로바이더 인스턴스 - * @return void */ public function register(IdentityVerificationInterface $provider): void { @@ -98,7 +109,6 @@ class IdentityVerificationManager * 프로바이더 등록을 해제합니다. * * @param string $id 프로바이더 식별자 (예: g7:core.mail) - * @return void */ public function unregister(string $id): void { @@ -249,7 +259,6 @@ class IdentityVerificationManager * @param array> $purposes key => metadata 매핑 * @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin' 으로 마킹) * @param string|null $sourceIdentifier source 식별자 (module/plugin id; 미명시 시 'admin') - * @return void */ public function registerDeclaredPurposes(array $purposes, ?string $sourceType = null, ?string $sourceIdentifier = null): void { @@ -284,7 +293,6 @@ class IdentityVerificationManager * @param array $meta label/description/allowed_channels 등 메타데이터 * @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin') * @param string|null $sourceIdentifier source 식별자 (미명시 시 'admin') - * @return void */ public function registerPurpose(string $key, array $meta, ?string $sourceType = null, ?string $sourceIdentifier = null): void { diff --git a/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php b/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php index 968a8d9c..4a4ba975 100644 --- a/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php +++ b/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php @@ -29,6 +29,15 @@ use Illuminate\Support\Str; */ class MailIdentityProvider implements IdentityVerificationInterface { + /** @var int 인증코드 최소 길이 */ + public const MIN_CODE_LENGTH = 4; + + /** @var int 인증코드 최대 길이 */ + public const MAX_CODE_LENGTH = 10; + + /** @var int 인증코드 기본 길이 (경계 위반 시 폴백) */ + public const DEFAULT_CODE_LENGTH = 6; + public const ID = 'g7:core.mail'; /** @@ -314,7 +323,11 @@ class MailIdentityProvider implements IdentityVerificationInterface 'code_length' => [ 'label' => __('identity.providers.mail.settings.code_length'), 'type' => 'integer', - 'default' => 6, + 'default' => self::DEFAULT_CODE_LENGTH, + // 경계는 스키마가 선언한다 — 서비스가 자체 상수로 조용히 클램프하면 + // 관리자가 설정한 값이 화면 안내와 다르게 동작한다. + 'min' => self::MIN_CODE_LENGTH, + 'max' => self::MAX_CODE_LENGTH, 'help' => __('identity.providers.mail.settings.code_length_help'), ], 'from_address' => [ @@ -345,9 +358,30 @@ class MailIdentityProvider implements IdentityVerificationInterface return $purpose === 'password_reset' ? 'link' : 'text_code'; } + /** + * 지정 길이의 숫자 인증코드를 생성합니다. + * + * 경계를 벗어난 값은 조용히 클램프하지 않습니다 — 클램프는 관리자가 설정한 값과 + * 실제 동작을 어긋나게 만듭니다. 다만 런타임 인증 흐름을 중단시키는 것은 위험하므로 + * 예외 대신 경고 로그 + 스키마 기본값 폴백으로 처리합니다. + * + * @param int $length 요청 코드 길이 + * @return string 생성된 숫자 코드 + */ protected function generateNumericCode(int $length): string { - $length = max(4, min(10, $length)); + if ($length < self::MIN_CODE_LENGTH || $length > self::MAX_CODE_LENGTH) { + Log::warning('IDV 인증코드 길이가 허용 범위를 벗어나 기본값으로 대체됨', [ + 'provider_id' => self::ID, + 'requested' => $length, + 'min' => self::MIN_CODE_LENGTH, + 'max' => self::MAX_CODE_LENGTH, + 'applied' => self::DEFAULT_CODE_LENGTH, + ]); + + $length = self::DEFAULT_CODE_LENGTH; + } + $code = ''; for ($i = 0; $i < $length; $i++) { $code .= (string) random_int(0, 9); diff --git a/app/Extension/ModuleManager.php b/app/Extension/ModuleManager.php index f599414a..6bbe445e 100644 --- a/app/Extension/ModuleManager.php +++ b/app/Extension/ModuleManager.php @@ -30,6 +30,7 @@ use App\Extension\Helpers\GithubHelper; use App\Extension\Helpers\IdentityMessageSyncHelper; use App\Extension\Helpers\IdentityPolicySyncHelper; use App\Extension\Helpers\NotificationSyncHelper; +use App\Extension\Testing\ExtensionTestAllowlist; use App\Extension\Vendor\Exceptions\VendorInstallException; use App\Extension\Vendor\VendorInstallContext; use App\Extension\Vendor\VendorInstallResult; @@ -2993,6 +2994,13 @@ class ModuleManager implements ModuleManagerInterface return; } + // 테스트 allowlist 확인 — allowlist 밖 모듈은 ServiceProvider 가 등록되지 않으므로 + // 리스너만 등록하면 훅 발화 시 Repository 바인딩이 없어 컨테이너 해석이 실패한다. + // (모듈 등록 행은 테스트 프로세스 간 DB 에 남을 수 있어 활성 판정만으로는 부족하다) + if (ExtensionTestAllowlist::isActive() && ! ExtensionTestAllowlist::isAllowed('module', $module->getIdentifier())) { + return; + } + // 모듈 활성화 상태 확인 (비활성화된 모듈의 훅은 등록하지 않음) $activeIdentifiers = self::getActiveModuleIdentifiers(); if (! in_array($module->getIdentifier(), $activeIdentifiers, true)) { @@ -4546,6 +4554,11 @@ class ModuleManager implements ModuleManagerInterface 'updated_at' => now(), ]); + // 상태 캐시 무효화 (성공 경로와 동일) — updating 창에서 누군가 활성 목록을 읽었다면 + // 그 목록에는 이 모듈이 빠져 있다. 여기서 비우지 않으면 상태를 되돌려 놓고도 + // 캐시 TTL(기본 하루) 동안 이 모듈의 화면이 계속 404 로 남는다. + self::invalidateModuleStatusCache(); + throw new \RuntimeException( __('modules.errors.update_failed', [ 'module' => $identifier, diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php index 54fdce84..e33da04e 100644 --- a/app/Extension/PluginManager.php +++ b/app/Extension/PluginManager.php @@ -29,6 +29,7 @@ use App\Extension\Helpers\GithubHelper; use App\Extension\Helpers\IdentityMessageSyncHelper; use App\Extension\Helpers\IdentityPolicySyncHelper; use App\Extension\Helpers\NotificationSyncHelper; +use App\Extension\Testing\ExtensionTestAllowlist; use App\Extension\Vendor\Exceptions\VendorInstallException; use App\Extension\Vendor\VendorInstallContext; use App\Extension\Vendor\VendorInstallResult; @@ -2828,6 +2829,13 @@ class PluginManager implements PluginManagerInterface protected function registerPluginHookListeners(PluginInterface $plugin): void { + // 테스트 allowlist 확인 — allowlist 밖 플러그인은 ServiceProvider 가 등록되지 않으므로 + // 리스너만 등록하면 훅 발화 시 의존 바인딩이 없어 컨테이너 해석이 실패한다. + // (플러그인 등록 행은 테스트 프로세스 간 DB 에 남을 수 있어 활성 판정만으로는 부족하다) + if (ExtensionTestAllowlist::isActive() && ! ExtensionTestAllowlist::isAllowed('plugin', $plugin->getIdentifier())) { + return; + } + // 플러그인 활성화 상태 확인 (비활성화된 플러그인의 훅은 등록하지 않음) $activeIdentifiers = self::getActivePluginIdentifiers(); if (! in_array($plugin->getIdentifier(), $activeIdentifiers, true)) { @@ -4726,6 +4734,11 @@ class PluginManager implements PluginManagerInterface 'updated_at' => now(), ]); + // 상태 캐시 무효화 (성공 경로와 동일) — updating 창에서 누군가 활성 목록을 읽었다면 + // 그 목록에는 이 플러그인이 빠져 있다. 여기서 비우지 않으면 상태를 되돌려 놓고도 + // 캐시 TTL(기본 하루) 동안 이 플러그인의 화면이 계속 404 로 남는다. + self::invalidatePluginStatusCache(); + throw new \RuntimeException( __('plugins.errors.update_failed', [ 'plugin' => $identifier, diff --git a/app/Extension/Testing/ExtensionTestAllowlist.php b/app/Extension/Testing/ExtensionTestAllowlist.php index 7efb9564..f803ca09 100644 --- a/app/Extension/Testing/ExtensionTestAllowlist.php +++ b/app/Extension/Testing/ExtensionTestAllowlist.php @@ -2,6 +2,9 @@ namespace App\Extension\Testing; +use App\Extension\HookListenerRegistrar; +use App\Extension\HookManager; + /** * 테스트 환경 확장 로딩 allowlist * @@ -50,6 +53,8 @@ class ExtensionTestAllowlist */ public static function set(array $extensions): void { + $previous = self::signature(); + self::$plugins = []; self::$modules = []; self::$configured = true; @@ -63,6 +68,27 @@ class ExtensionTestAllowlist self::$modules[] = basename($extension); } } + + if (self::signature() !== $previous) { + self::forgetRegisteredHooks(); + } + } + + /** + * allowlist 가 바뀌었을 때 남아 있는 훅 등록을 비웁니다. + * + * `HookManager` 의 훅/필터 맵과 `HookListenerRegistrar` 의 등록 이력은 static 이라 + * 테스트 클래스가 바뀌어도 프로세스에 그대로 남습니다. 앞 클래스가 허용했던 확장의 + * 리스너가 남아 있으면, 그 확장을 배선하지 않은 다음 클래스의 앱에서 훅이 발화할 때 + * `app($listenerClass)` 해석이 실패합니다 (Filter 훅은 동기 실행이라 그대로 500 이 된다). + * + * 비운 직후 새 앱이 부팅되며 코어·허용 확장 리스너를 다시 등록하므로, 이 초기화는 + * "이번 allowlist 에 맞는 등록만 남긴다" 는 의미가 됩니다. + */ + private static function forgetRegisteredHooks(): void + { + HookManager::resetAll(); + HookListenerRegistrar::clear(); } /** @@ -77,6 +103,25 @@ class ExtensionTestAllowlist self::$configured = false; } + /** + * 현재 allowlist 를 비교 가능한 문자열로 반환합니다. + * + * @return string allowlist 서명 (미설정이면 빈 문자열) + */ + private static function signature(): string + { + if (! self::$configured) { + return ''; + } + + $modules = self::$modules; + $plugins = self::$plugins; + sort($modules); + sort($plugins); + + return 'm:'.implode(',', $modules).'|p:'.implode(',', $plugins); + } + /** * 가드 활성 여부를 반환합니다. * diff --git a/app/Extension/Traits/CachesModuleStatus.php b/app/Extension/Traits/CachesModuleStatus.php index 73a1b88e..dcb3051c 100644 --- a/app/Extension/Traits/CachesModuleStatus.php +++ b/app/Extension/Traits/CachesModuleStatus.php @@ -29,16 +29,49 @@ trait CachesModuleStatus return []; } - return self::resolveStatusCache()->remember( + return self::rememberNonEmpty( 'ext.modules.active_identifiers', fn () => Module::where('status', ExtensionStatus::Active->value) ->pluck('identifier') - ->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.modules'] + ->toArray() ); } + /** + * 목록 캐시를 조회하되, 빈 결과는 캐시에 남기지 않습니다. + * + * 확장 상태는 install/activate/update 도중 잠시 active 가 아닌 값으로 바뀐다. 그 창에서 + * 누가 이 목록을 읽으면 빈 배열이 TTL(기본 하루) 동안 굳어, 작업이 끝난 뒤에도 모든 확장이 + * 꺼진 것처럼 동작한다 — 관리자 화면이 통째로 404 가 되고 스스로 회복되지 않는다. + * 빈 결과는 재계산 비용이 사실상 없는 단순 조회이므로 캐시하지 않는 편이 안전하다. + * + * @param string $key 캐시 키 + * @param \Closure $resolver 목록 계산 클로저 + * @return array 조회된 identifier 배열 + */ + private static function rememberNonEmpty(string $key, \Closure $resolver): array + { + $cache = self::resolveStatusCache(); + $cached = $cache->get($key); + + if (is_array($cached) && $cached !== []) { + return $cached; + } + + $fresh = $resolver(); + + if ($fresh !== []) { + $cache->put( + $key, + $fresh, + (int) g7_core_settings('cache.extension_status_ttl', 86400), + ['ext.status', 'ext.modules'] + ); + } + + return $fresh; + } + /** * 설치된 모듈 (active + inactive) identifier 목록을 조회합니다. * @@ -50,14 +83,12 @@ trait CachesModuleStatus return []; } - return self::resolveStatusCache()->remember( + return self::rememberNonEmpty( 'ext.modules.installed_identifiers', fn () => Module::whereIn('status', [ ExtensionStatus::Active->value, ExtensionStatus::Inactive->value, - ])->pluck('identifier')->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.modules'] + ])->pluck('identifier')->toArray() ); } diff --git a/app/Extension/Traits/CachesPluginStatus.php b/app/Extension/Traits/CachesPluginStatus.php index de1caa82..14ce9b16 100644 --- a/app/Extension/Traits/CachesPluginStatus.php +++ b/app/Extension/Traits/CachesPluginStatus.php @@ -29,16 +29,49 @@ trait CachesPluginStatus return []; } - return self::resolvePluginStatusCache()->remember( + return self::rememberNonEmptyPluginList( 'ext.plugins.active_identifiers', fn () => Plugin::where('status', ExtensionStatus::Active->value) ->pluck('identifier') - ->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.plugins'] + ->toArray() ); } + /** + * 목록 캐시를 조회하되, 빈 결과는 캐시에 남기지 않습니다. + * + * 확장 상태는 install/activate/update 도중 잠시 active 가 아닌 값으로 바뀐다. 그 창에서 + * 누가 이 목록을 읽으면 빈 배열이 TTL(기본 하루) 동안 굳어, 작업이 끝난 뒤에도 모든 + * 플러그인이 꺼진 것처럼 동작하며 스스로 회복되지 않는다. 빈 결과는 재계산 비용이 사실상 + * 없는 단순 조회이므로 캐시하지 않는 편이 안전하다. + * + * @param string $key 캐시 키 + * @param \Closure $resolver 목록 계산 클로저 + * @return array 조회된 identifier 배열 + */ + private static function rememberNonEmptyPluginList(string $key, \Closure $resolver): array + { + $cache = self::resolvePluginStatusCache(); + $cached = $cache->get($key); + + if (is_array($cached) && $cached !== []) { + return $cached; + } + + $fresh = $resolver(); + + if ($fresh !== []) { + $cache->put( + $key, + $fresh, + (int) g7_core_settings('cache.extension_status_ttl', 86400), + ['ext.status', 'ext.plugins'] + ); + } + + return $fresh; + } + /** * 설치된 플러그인 (active + inactive) identifier 목록을 조회합니다. * @@ -50,14 +83,12 @@ trait CachesPluginStatus return []; } - return self::resolvePluginStatusCache()->remember( + return self::rememberNonEmptyPluginList( 'ext.plugins.installed_identifiers', fn () => Plugin::whereIn('status', [ ExtensionStatus::Active->value, ExtensionStatus::Inactive->value, - ])->pluck('identifier')->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.plugins'] + ])->pluck('identifier')->toArray() ); } diff --git a/app/Extension/Traits/CachesTemplateStatus.php b/app/Extension/Traits/CachesTemplateStatus.php index 746e06cd..e581ec7b 100644 --- a/app/Extension/Traits/CachesTemplateStatus.php +++ b/app/Extension/Traits/CachesTemplateStatus.php @@ -29,13 +29,11 @@ trait CachesTemplateStatus return []; } - return self::resolveTemplateStatusCache()->remember( + return self::rememberNonEmptyTemplateList( 'ext.templates.active_identifiers', fn () => Template::where('status', ExtensionStatus::Active->value) ->pluck('identifier') - ->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.templates'] + ->toArray() ); } @@ -51,14 +49,12 @@ trait CachesTemplateStatus return []; } - return self::resolveTemplateStatusCache()->remember( + return self::rememberNonEmptyTemplateList( "ext.templates.active_identifiers_{$type}", fn () => Template::where('status', ExtensionStatus::Active->value) ->where('type', $type) ->pluck('identifier') - ->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.templates'] + ->toArray() ); } @@ -73,17 +69,50 @@ trait CachesTemplateStatus return []; } - return self::resolveTemplateStatusCache()->remember( + return self::rememberNonEmptyTemplateList( 'ext.templates.installed_identifiers', fn () => Template::whereIn('status', [ ExtensionStatus::Active->value, ExtensionStatus::Inactive->value, - ])->pluck('identifier')->toArray(), - (int) g7_core_settings('cache.extension_status_ttl', 86400), - ['ext.status', 'ext.templates'] + ])->pluck('identifier')->toArray() ); } + /** + * 목록 캐시를 조회하되, 빈 결과는 캐시에 남기지 않습니다. + * + * 확장 상태는 install/activate/update 도중 잠시 active 가 아닌 값으로 바뀐다. 그 창에서 + * 누가 이 목록을 읽으면 빈 배열이 TTL(기본 하루) 동안 굳어, 작업이 끝난 뒤에도 모든 + * 템플릿이 꺼진 것처럼 동작하며 스스로 회복되지 않는다. 빈 결과는 재계산 비용이 사실상 + * 없는 단순 조회이므로 캐시하지 않는 편이 안전하다. + * + * @param string $key 캐시 키 + * @param \Closure $resolver 목록 계산 클로저 + * @return array 조회된 identifier 배열 + */ + private static function rememberNonEmptyTemplateList(string $key, \Closure $resolver): array + { + $cache = self::resolveTemplateStatusCache(); + $cached = $cache->get($key); + + if (is_array($cached) && $cached !== []) { + return $cached; + } + + $fresh = $resolver(); + + if ($fresh !== []) { + $cache->put( + $key, + $fresh, + (int) g7_core_settings('cache.extension_status_ttl', 86400), + ['ext.status', 'ext.templates'] + ); + } + + return $fresh; + } + /** * 템플릿 상태 캐시를 무효화합니다. * 템플릿 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다. diff --git a/app/Helpers/PermissionHelper.php b/app/Helpers/PermissionHelper.php index b8c4c32c..de3656c4 100644 --- a/app/Helpers/PermissionHelper.php +++ b/app/Helpers/PermissionHelper.php @@ -2,7 +2,6 @@ namespace App\Helpers; -use App\Enums\ScopeType; use App\Models\Permission; use App\Models\User; use App\Providers\AuthServiceProvider; @@ -26,6 +25,20 @@ class PermissionHelper */ protected static array $permissionCache = []; + /** + * 권한 스코프 캐시를 무효화합니다. + * + * 캐시는 프로세스 수명 동안 유지되므로, 권한 행이 다시 만들어지는 시점 + * (권한 재시드 · 코어 업데이트 · 확장 install) 에 비워 주어야 한다. + * 테스트에서는 매 케이스마다 DB 가 초기화되는데 캐시만 남아, 앞 케이스가 심은 + * `resource_route_key = null` 이 뒤 케이스로 새어 스코프 검사가 통째로 건너뛰어진다 + * (개별 실행은 통과하고 스위트에서만 어긋나는 형태로 드러난다). + */ + public static function clearPermissionScopeCache(): void + { + self::$permissionCache = []; + } + /** * 단일 권한 체크 * @@ -104,7 +117,6 @@ class PermissionHelper * @param Builder $query Eloquent 쿼리 빌더 * @param string $permission 권한 식별자 * @param User|null $user 사용자 (null이면 현재 인증 사용자) - * @return void */ public static function applyPermissionScope(Builder $query, string $permission, ?User $user = null): void { diff --git a/app/Http/Controllers/Api/Admin/AuthController.php b/app/Http/Controllers/Api/Admin/AuthController.php index 90fbbec6..439f274d 100644 --- a/app/Http/Controllers/Api/Admin/AuthController.php +++ b/app/Http/Controllers/Api/Admin/AuthController.php @@ -4,11 +4,11 @@ namespace App\Http\Controllers\Api\Admin; use App\Exceptions\Auth\AccountLockedException; use App\Http\Controllers\Api\Base\AdminBaseController; +use App\Http\Requests\Auth\AuthenticatedRequest; use App\Http\Requests\Auth\LoginRequest; use App\Http\Resources\UserResource; use App\Services\AuthService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; class AuthController extends AdminBaseController @@ -22,7 +22,7 @@ class AuthController extends AdminBaseController /** * 관리자를 로그인시킵니다. * - * @param LoginRequest $request 로그인 요청 데이터 + * @param LoginRequest $request 로그인 요청 데이터 * @return JsonResponse 로그인 결과와 관리자 정보, 토큰을 포함한 JSON 응답 */ public function login(LoginRequest $request): JsonResponse @@ -36,7 +36,7 @@ class AuthController extends AdminBaseController $user = $data['user']; // 관리자 권한 확인 - if (!$user->isAdmin()) { + if (! $user->isAdmin()) { return $this->forbidden('auth.admin_required'); } @@ -45,10 +45,17 @@ class AuthController extends AdminBaseController return $this->success('auth.admin_login_success', $data); } catch (AccountLockedException $e) { - return $this->error('auth.account_locked', 423, [ - 'locked_until' => $e->lockedUntil->toIso8601String(), - 'retry_after_seconds' => $e->remainingMinutes * 60, - ], ['minutes' => $e->remainingMinutes]); + // 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출. + return $this->error( + $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked', + 423, + [ + 'locked_until' => $e->lockedUntil?->toIso8601String(), + 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60, + 'permanent' => $e->isPermanent(), + ], + ['minutes' => $e->remainingMinutes] + ); } catch (ValidationException $e) { return $this->unauthorized('auth.login_failed'); } @@ -57,10 +64,10 @@ class AuthController extends AdminBaseController /** * 관리자를 로그아웃시킵니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 로그아웃 성공 메시지 */ - public function logout(Request $request): JsonResponse + public function logout(AuthenticatedRequest $request): JsonResponse { $this->authService->logout($request->user()); @@ -70,10 +77,10 @@ class AuthController extends AdminBaseController /** * 현재 로그인된 관리자의 정보를 반환합니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 관리자 정보를 포함한 JSON 응답 */ - public function user(Request $request): JsonResponse + public function user(AuthenticatedRequest $request): JsonResponse { $user = $request->user(); @@ -89,10 +96,10 @@ class AuthController extends AdminBaseController /** * 관리자의 인증 토큰을 갱신합니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 새로운 토큰과 관리자 정보를 포함한 JSON 응답 */ - public function refresh(Request $request): JsonResponse + public function refresh(AuthenticatedRequest $request): JsonResponse { try { $data = $this->authService->refreshToken($request->user()); @@ -101,6 +108,7 @@ class AuthController extends AdminBaseController if (isset($data['user'])) { $data['user'] = new UserResource($data['user']); } + return $this->success('common.success', $data); } catch (ValidationException $e) { return $this->unauthorized('auth.unauthenticated'); diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php index f3f3fc9e..c3e6d14f 100644 --- a/app/Http/Controllers/Api/Admin/SettingsController.php +++ b/app/Http/Controllers/Api/Admin/SettingsController.php @@ -42,6 +42,7 @@ class SettingsController extends AdminBaseController try { $settings = $this->settingsService->getAllSettings(); $settings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers(); + $settings['_meta'] = ['limits' => config('core.settings_limits', [])]; return $this->success('settings.fetch_success', (new SettingsResource($settings))->toArray(request()) @@ -69,6 +70,7 @@ class SettingsController extends AdminBaseController // 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용) $allSettings = $this->settingsService->getAllSettings(); $allSettings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers(); + $allSettings['_meta'] = ['limits' => config('core.settings_limits', [])]; return $this->success('settings.save_success', [ 'settings' => $allSettings, diff --git a/app/Http/Controllers/Api/Admin/UserController.php b/app/Http/Controllers/Api/Admin/UserController.php index b399270d..65baa1b1 100644 --- a/app/Http/Controllers/Api/Admin/UserController.php +++ b/app/Http/Controllers/Api/Admin/UserController.php @@ -129,6 +129,30 @@ class UserController extends AdminBaseController } } + /** + * 사용자의 계정 잠금을 해제합니다. + * + * 로그인 실패 누적으로 잠긴 계정(특히 잠금 시간 0 = 무한대 설정으로 영구 잠긴 계정)을 + * 관리자가 수동으로 풀어 줍니다. 이 경로가 없으면 영구 잠금 계정은 성공 로그인 자체가 + * 불가하므로 복구 수단이 없습니다. + * + * @param User $user 잠금을 해제할 사용자 모델 + * @return JsonResponse 갱신된 사용자 정보를 포함한 JSON 응답 + */ + public function unlock(User $user): JsonResponse + { + try { + $unlocked = $this->userService->unlockAccount($user); + + return $this->successWithResource( + 'auth.account_unlocked', + new UserResource($unlocked) + ); + } catch (Exception $e) { + return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]); + } + } + /** * 사용자를 삭제합니다. * diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php index 4f5e0d40..d481397e 100644 --- a/app/Http/Controllers/Api/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Auth/AuthController.php @@ -4,15 +4,16 @@ namespace App\Http\Controllers\Api\Auth; use App\Exceptions\Auth\AccountLockedException; use App\Http\Controllers\Api\Base\AuthBaseController; +use App\Http\Requests\Auth\AuthenticatedRequest; use App\Http\Requests\Auth\ForgotPasswordRequest; use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\RegisterRequest; use App\Http\Requests\Auth\ResetPasswordRequest; +use App\Http\Requests\Auth\TwoFactorChallengeRequest; use App\Http\Requests\Auth\ValidateResetTokenRequest; use App\Http\Resources\UserResource; use App\Services\AuthService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; class AuthController extends AuthBaseController @@ -26,6 +27,8 @@ class AuthController extends AuthBaseController // 공개 인증 엔드포인트를 제외한 나머지에만 인증 미들웨어 적용 $this->middleware('auth:sanctum')->except([ 'login', + // 2단계 인증 확인은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다 + 'verifyTwoFactor', 'register', 'forgotPassword', 'resetPassword', @@ -47,20 +50,59 @@ class AuthController extends AuthBaseController $request->validated()['password'] ); + // 2단계 인증이 켜져 있으면 아직 토큰이 없다 — 인증 코드 확인 단계로 안내한다 + if ($data['two_factor_required'] ?? false) { + return $this->success('auth.two_factor_required', $data); + } + // 사용자 정보는 Resource로, 토큰은 그대로 $data['user'] = new UserResource($data['user']); return $this->success('auth.login_success', $data); } catch (AccountLockedException $e) { - return $this->error('auth.account_locked', 423, [ - 'locked_until' => $e->lockedUntil->toIso8601String(), - 'retry_after_seconds' => $e->remainingMinutes * 60, - ], ['minutes' => $e->remainingMinutes]); + // 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출. + return $this->error( + $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked', + 423, + [ + 'locked_until' => $e->lockedUntil?->toIso8601String(), + 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60, + 'permanent' => $e->isPermanent(), + ], + ['minutes' => $e->remainingMinutes] + ); } catch (ValidationException $e) { return $this->unauthorized('auth.login_failed'); } } + /** + * 2단계 인증 코드를 확인하고 로그인을 완료합니다. + * + * 비밀번호 확인 단계(`login`)는 토큰 대신 challenge 를 돌려주며, 이 엔드포인트가 + * 코드 확인에 성공해야 비로소 토큰이 발급됩니다. + * + * @param TwoFactorChallengeRequest $request challenge 확인 요청 + * @return JsonResponse 로그인 결과와 사용자 정보, 토큰을 포함한 JSON 응답 + */ + public function verifyTwoFactor(TwoFactorChallengeRequest $request): JsonResponse + { + $validated = $request->validated(); + + try { + $data = $this->authService->completeTwoFactor( + $validated['challenge_id'], + ['code' => $validated['code']] + ); + + $data['user'] = new UserResource($data['user']); + + return $this->success('auth.login_success', $data); + } catch (ValidationException $e) { + return $this->unauthorized('auth.two_factor_failed'); + } + } + /** * 새로운 사용자를 등록시킵니다. * @@ -84,10 +126,10 @@ class AuthController extends AuthBaseController /** * 사용자를 로그아웃시킵니다. (현재 디바이스만) * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 로그아웃 성공 메시지 */ - public function logout(Request $request): JsonResponse + public function logout(AuthenticatedRequest $request): JsonResponse { $this->authService->logout($request->user()); @@ -97,10 +139,10 @@ class AuthController extends AuthBaseController /** * 모든 디바이스에서 사용자를 로그아웃시킵니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 로그아웃 성공 메시지 */ - public function logoutFromAllDevices(Request $request): JsonResponse + public function logoutFromAllDevices(AuthenticatedRequest $request): JsonResponse { $this->authService->logoutFromAllDevices($request->user()); @@ -110,10 +152,10 @@ class AuthController extends AuthBaseController /** * 현재 로그인된 사용자의 정보를 반환합니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 사용자 정보를 포함한 JSON 응답 */ - public function user(Request $request): JsonResponse + public function user(AuthenticatedRequest $request): JsonResponse { $user = $request->user(); @@ -131,10 +173,10 @@ class AuthController extends AuthBaseController /** * 사용자의 인증 토큰을 갱신합니다. * - * @param Request $request HTTP 요청 + * @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음) * @return JsonResponse 새로운 토큰과 사용자 정보를 포함한 JSON 응답 */ - public function refresh(Request $request): JsonResponse + public function refresh(AuthenticatedRequest $request): JsonResponse { $data = $this->authService->refreshToken($request->user()); diff --git a/app/Http/Controllers/Api/Public/PublicTemplateController.php b/app/Http/Controllers/Api/Public/PublicTemplateController.php index 82f5b0ab..13d39d9e 100644 --- a/app/Http/Controllers/Api/Public/PublicTemplateController.php +++ b/app/Http/Controllers/Api/Public/PublicTemplateController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\Public; +use App\Contracts\Extension\CacheInterface; use App\Enums\ExtensionStatus; use App\Extension\Helpers\EditorSpecAssembler; use App\Extension\Traits\ClearsTemplateCaches; @@ -12,6 +13,7 @@ use App\Services\TemplateLayoutAttachmentService; use App\Services\TemplateService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\BinaryFileResponse; /** @@ -40,22 +42,36 @@ class PublicTemplateController extends PublicBaseController // 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화 $cacheVersion = request()->query('v', 0); - // 캐싱된 응답 반환 (1시간 유효) - $routesData = $this->cached( - "template.routes.{$identifier}.v{$cacheVersion}", - function () use ($identifier) { - // Service에서 템플릿 + 모듈 routes 데이터 병합 조회 - $result = $this->templateService->getRoutesDataWithModules($identifier); + // 확장 업데이트 중에는 활성 디렉토리가 잠시 비어 그 모듈의 라우트가 통째로 빠진다. + // 그 순간의 응답을 버전 키에 캐시하면 업데이트가 끝난 뒤에도 캐시가 만료될 때까지 + // 해당 모듈의 모든 화면이 404 로 남는다 — 버전은 이미 올라간 뒤라 스스로 회복되지 않는다. + // 따라서 열화 스냅샷은 그대로 응답하되 캐시에 남기지 않는다. + $buildRoutes = function () use ($identifier) { + $result = $this->templateService->getRoutesDataWithModules($identifier); - // 에러 처리 - if (! $result['success']) { - return ['error' => $result['error']]; - } + if (! $result['success']) { + return ['error' => $result['error']]; + } - return ['success' => true, 'data' => $result['data']]; - }, - 3600 - ); + return ['success' => true, 'data' => $result['data']]; + }; + + $cacheKey = "template.routes.{$identifier}.v{$cacheVersion}"; + $cache = app(CacheInterface::class); + $routesData = $cache->get($cacheKey); + + if ($routesData === null) { + $routesData = $buildRoutes(); + + if ($this->templateService->lastRouteMergeWasDegraded()) { + Log::warning('라우트 병합이 열화 상태여서 캐시하지 않습니다 (확장 업데이트 진행 중 추정)', [ + 'template' => $identifier, + 'cache_version' => $cacheVersion, + ]); + } else { + $cache->put($cacheKey, $routesData, 3600); + } + } // 에러 응답 처리 if (isset($routesData['error'])) { diff --git a/app/Http/Requests/Attachment/UploadAttachmentRequest.php b/app/Http/Requests/Attachment/UploadAttachmentRequest.php index 88bdc844..0acb70f7 100644 --- a/app/Http/Requests/Attachment/UploadAttachmentRequest.php +++ b/app/Http/Requests/Attachment/UploadAttachmentRequest.php @@ -29,10 +29,18 @@ class UploadAttachmentRequest extends FormRequest */ public function rules(): array { - $maxSize = config('attachment.max_size', 10) * 1024; // MB to KB + // config/attachment.max_file_size 는 KB 단위 (관리자 설정 MB → KB 변환은 SettingsServiceProvider 담당) + $maxSize = (int) config('attachment.max_file_size', 10240); + $extensions = $this->allowedExtensions(); + + $fileRules = ['required', 'file', 'max:'.$maxSize]; + // 목록이 비면 확장자 제한 없음 (탈출구) — 빈 규칙을 붙이면 전 파일이 거부된다 + if ($extensions !== []) { + $fileRules[] = 'extensions:'.implode(',', $extensions); + } $rules = [ - 'file' => ['required', 'file', 'max:' . $maxSize], + 'file' => $fileRules, 'attachmentable_type' => ['nullable', 'string', 'max:255'], 'attachmentable_id' => ['nullable', 'integer', 'min:1'], 'collection' => ['sometimes', 'string', 'max:100'], @@ -54,7 +62,8 @@ class UploadAttachmentRequest extends FormRequest return [ 'file.required' => __('attachment.validation.file_required'), 'file.file' => __('attachment.validation.file_invalid'), - 'file.max' => __('attachment.validation.file_max', ['max' => config('attachment.max_size', 10)]), + 'file.max' => __('attachment.validation.file_max', ['max' => (int) config('attachment.max_file_size', 10240) / 1024]), + 'file.extensions' => __('attachment.validation.file_extension_invalid', ['extensions' => implode(', ', $this->allowedExtensions())]), 'attachmentable_type.string' => __('attachment.validation.type_invalid'), 'attachmentable_id.integer' => __('attachment.validation.id_invalid'), ]; @@ -62,8 +71,6 @@ class UploadAttachmentRequest extends FormRequest /** * 검증 전 데이터 준비 - * - * @return void */ protected function prepareForValidation(): void { @@ -72,4 +79,25 @@ class UploadAttachmentRequest extends FormRequest 'source_type' => $this->source_type ?? AttachmentSourceType::Core->value, ]); } + + /** + * 허용 확장자 목록을 반환합니다 (소문자 정규화 + 확장 훅 적용). + * + * 빈 배열은 "확장자 제한 없음" 을 의미합니다. + * + * @return array 허용 확장자 목록 + */ + protected function allowedExtensions(): array + { + $extensions = (array) config('attachment.allowed_extensions', []); + + $extensions = array_values(array_filter(array_map( + static fn ($ext) => strtolower(trim(ltrim((string) $ext, '.'))), + $extensions + ))); + + return array_values(array_unique( + (array) HookManager::applyFilters('core.attachment.allowed_extensions', $extensions, $this) + )); + } } diff --git a/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php b/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php index 32af6a4b..ef985e99 100644 --- a/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php +++ b/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php @@ -29,11 +29,19 @@ class UploadBatchAttachmentRequest extends FormRequest */ public function rules(): array { - $maxSize = config('attachment.max_size', 10) * 1024; // MB to KB + // config/attachment.max_file_size 는 KB 단위 (관리자 설정 MB → KB 변환은 SettingsServiceProvider 담당) + $maxSize = (int) config('attachment.max_file_size', 10240); + $extensions = $this->allowedExtensions(); + + $fileRules = ['file', 'max:'.$maxSize]; + // 목록이 비면 확장자 제한 없음 (탈출구) — 빈 규칙을 붙이면 전 파일이 거부된다 + if ($extensions !== []) { + $fileRules[] = 'extensions:'.implode(',', $extensions); + } $rules = [ 'files' => ['required', 'array', 'min:1'], - 'files.*' => ['file', 'max:' . $maxSize], + 'files.*' => $fileRules, 'attachmentable_type' => ['nullable', 'string', 'max:255'], 'attachmentable_id' => ['nullable', 'integer', 'min:1'], 'collection' => ['sometimes', 'string', 'max:100'], @@ -57,7 +65,8 @@ class UploadBatchAttachmentRequest extends FormRequest 'files.array' => __('attachment.validation.files_array'), 'files.min' => __('attachment.validation.files_min'), 'files.*.file' => __('attachment.validation.file_invalid'), - 'files.*.max' => __('attachment.validation.file_max', ['max' => config('attachment.max_size', 10)]), + 'files.*.max' => __('attachment.validation.file_max', ['max' => (int) config('attachment.max_file_size', 10240) / 1024]), + 'files.*.extensions' => __('attachment.validation.file_extension_invalid', ['extensions' => implode(', ', $this->allowedExtensions())]), 'attachmentable_type.string' => __('attachment.validation.type_invalid'), 'attachmentable_id.integer' => __('attachment.validation.id_invalid'), ]; @@ -65,8 +74,6 @@ class UploadBatchAttachmentRequest extends FormRequest /** * 검증 전 데이터 준비 - * - * @return void */ protected function prepareForValidation(): void { @@ -75,4 +82,25 @@ class UploadBatchAttachmentRequest extends FormRequest 'source_type' => $this->source_type ?? AttachmentSourceType::Core->value, ]); } + + /** + * 허용 확장자 목록을 반환합니다 (소문자 정규화 + 확장 훅 적용). + * + * 빈 배열은 "확장자 제한 없음" 을 의미합니다. + * + * @return array 허용 확장자 목록 + */ + protected function allowedExtensions(): array + { + $extensions = (array) config('attachment.allowed_extensions', []); + + $extensions = array_values(array_filter(array_map( + static fn ($ext) => strtolower(trim(ltrim((string) $ext, '.'))), + $extensions + ))); + + return array_values(array_unique( + (array) HookManager::applyFilters('core.attachment.allowed_extensions', $extensions, $this) + )); + } } diff --git a/app/Http/Requests/Auth/AuthenticatedRequest.php b/app/Http/Requests/Auth/AuthenticatedRequest.php new file mode 100644 index 00000000..6d67f246 --- /dev/null +++ b/app/Http/Requests/Auth/AuthenticatedRequest.php @@ -0,0 +1,38 @@ + 빈 배열 (본문 입력 없음) + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index 2926d4a8..aed4f22b 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -3,12 +3,15 @@ namespace App\Http\Requests\Auth; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; class LoginRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -18,13 +21,15 @@ class LoginRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { $rules = [ 'email' => 'required|email', - 'password' => 'required|string|min:6', + // 로그인은 기존 비밀번호를 대조하는 행위이지 새 정책을 강제하는 지점이 아니다. + // 길이 규칙을 두면 정책 미달 비밀번호에 422 를 주어 미인증 공격자에게 그 사실을 누설한다. + 'password' => 'required|string', ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 @@ -42,7 +47,6 @@ class LoginRequest extends FormRequest 'email.required' => __('validation.auth.email.required'), 'email.email' => __('validation.auth.email.email'), 'password.required' => __('validation.auth.password.required'), - 'password.min' => __('validation.auth.password.min'), ]; } } diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php index fa6face9..5e119ba9 100644 --- a/app/Http/Requests/Auth/RegisterRequest.php +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Auth; use App\Extension\HookManager; use App\Models\User; +use App\Rules\PasswordPolicy; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -31,7 +32,7 @@ class RegisterRequest extends FormRequest 'name' => 'required|string|max:255', 'nickname' => 'nullable|string|max:50', 'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)], - 'password' => 'required|string|min:8|confirmed', + 'password' => ['required', 'string', 'confirmed', PasswordPolicy::rule()], 'mobile' => ['nullable', 'string', 'max:20', 'regex:/^[0-9\-\+\(\)\s]+$/'], 'phone' => ['nullable', 'string', 'max:20', 'regex:/^[0-9\-\+\(\)\s]+$/'], 'language' => 'nullable|string|in:'.implode(',', config('app.supported_locales', ['ko', 'en'])), diff --git a/app/Http/Requests/Auth/ResetPasswordRequest.php b/app/Http/Requests/Auth/ResetPasswordRequest.php index 8975ec1a..4f83b208 100644 --- a/app/Http/Requests/Auth/ResetPasswordRequest.php +++ b/app/Http/Requests/Auth/ResetPasswordRequest.php @@ -4,6 +4,8 @@ namespace App\Http\Requests\Auth; use App\Extension\HookManager; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -11,6 +13,8 @@ class ResetPasswordRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -20,14 +24,14 @@ class ResetPasswordRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { $rules = [ 'token' => 'required|string', 'email' => ['required', 'email', Rule::exists(User::class, 'email')], - 'password' => 'required|string|min:8|confirmed', + 'password' => ['required', 'string', 'confirmed', PasswordPolicy::rule()], ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 diff --git a/app/Http/Requests/Auth/TwoFactorChallengeRequest.php b/app/Http/Requests/Auth/TwoFactorChallengeRequest.php new file mode 100644 index 00000000..f1043207 --- /dev/null +++ b/app/Http/Requests/Auth/TwoFactorChallengeRequest.php @@ -0,0 +1,58 @@ + 검증 규칙 + */ + public function rules(): array + { + $rules = [ + 'challenge_id' => ['required', 'string', 'uuid'], + // 코드 자리수는 프로바이더 설정에 따라 달라지므로 상·하한만 느슨하게 두고 + // 정확한 일치 판정은 프로바이더가 수행한다. + 'code' => ['required', 'string', 'min:4', 'max:16'], + ]; + + // 확장이 자체 2단계 수단(앱 OTP·SMS 등)을 붙일 때 입력 필드를 추가할 수 있도록 개방한다 + return HookManager::applyFilters('core.auth.two_factor_validation_rules', $rules, $this); + } + + /** + * 검증 실패 메시지를 반환합니다. + * + * @return array 검증 메시지 + */ + public function messages(): array + { + return [ + 'challenge_id.required' => __('validation.auth.two_factor.challenge_required'), + 'challenge_id.uuid' => __('validation.auth.two_factor.challenge_invalid'), + 'code.required' => __('validation.auth.two_factor.code_required'), + ]; + } +} diff --git a/app/Http/Requests/Identity/PurposesIndexRequest.php b/app/Http/Requests/Identity/PurposesIndexRequest.php index f17ff486..1d937e55 100644 --- a/app/Http/Requests/Identity/PurposesIndexRequest.php +++ b/app/Http/Requests/Identity/PurposesIndexRequest.php @@ -7,14 +7,14 @@ use Illuminate\Foundation\Http\FormRequest; /** * 등록된 IDV purpose 목록 조회 요청 (GET /api/identity/purposes). * - * 코어 4종 + 활성 모듈/플러그인 declarative purpose + filter 훅 확장 결과를 반환. + * 코어 5종 + 활성 모듈/플러그인 declarative purpose + filter 훅 확장 결과를 반환. */ class PurposesIndexRequest extends FormRequest { /** * 요청 권한 — 공개 엔드포인트. * - * @return bool + * @return bool 항상 true (인증 불필요) */ public function authorize(): bool { diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php index c5859eec..b176c9c5 100644 --- a/app/Http/Requests/Settings/SaveSettingsRequest.php +++ b/app/Http/Requests/Settings/SaveSettingsRequest.php @@ -5,6 +5,7 @@ namespace App\Http\Requests\Settings; use App\Extension\HookManager; use App\Models\Attachment; use App\Search\Engines\DatabaseFulltextEngine; +use App\Support\AllowedExtensions; use App\Support\AssetUrl; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\Validator; @@ -106,6 +107,28 @@ class SaveSettingsRequest extends FormRequest 'advanced.geoip_enabled', 'advanced.geoip_auto_update_enabled', ]); + + $this->normalizeAllowedExtensions(); + } + + /** + * 허용 확장자를 저장 형태(소문자 확장자 배열)로 정규화합니다. + * + * 화면 입력 칸은 콤마로 구분한 한 줄 텍스트라 이 값은 문자열로 들어온다. 문자열 그대로 + * 저장하면 업로드 제한을 적용하는 지점이 그 값을 쓰지 못해, 저장은 성공하는데 제한은 + * 종전 목록으로 남는다. 형태 판정은 저장 경로 진입 전 이 한 곳에서만 수행한다. + */ + private function normalizeAllowedExtensions(): void + { + $upload = $this->input('upload', []); + + if (! is_array($upload) || ! array_key_exists('allowed_extensions', $upload)) { + return; + } + + $upload['allowed_extensions'] = AllowedExtensions::normalize($upload['allowed_extensions']); + + $this->merge(['upload' => $upload]); } /** @@ -202,11 +225,11 @@ class SaveSettingsRequest extends FormRequest 'mail.from_name' => $this->getTabRules($tab, 'mail', 'string|max:255'), // 업로드 설정 - 'upload.max_file_size' => $this->getTabRules($tab, 'upload', 'integer|min:1|max:1024'), + 'upload.max_file_size' => $this->getTabRules($tab, 'upload', 'integer|min:'.config('core.settings_limits.upload_max_file_size_min', 1).'|max:'.config('core.settings_limits.upload_max_file_size_max', 1024)), 'upload.allowed_extensions' => $this->getAllowedExtensionsRules($tab), - 'upload.image_max_width' => ['nullable', 'integer', 'min:100', 'max:10000'], - 'upload.image_max_height' => ['nullable', 'integer', 'min:100', 'max:10000'], - 'upload.image_quality' => ['nullable', 'integer', 'min:1', 'max:100'], + 'upload.image_max_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_width_min', 100), 'max:'.config('core.settings_limits.upload_image_max_width_max', 10000)], + 'upload.image_max_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_height_min', 100), 'max:'.config('core.settings_limits.upload_image_max_height_max', 10000)], + 'upload.image_quality' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_quality_min', 1), 'max:'.config('core.settings_limits.upload_image_quality_max', 100)], // SEO 설정 'seo.meta_title_suffix' => ['nullable', 'string', 'max:100'], @@ -220,16 +243,16 @@ class SaveSettingsRequest extends FormRequest 'seo.bot_detection_enabled' => ['nullable', 'boolean'], 'seo.bot_detection_library_enabled' => ['nullable', 'boolean'], 'seo.og_default_site_name' => ['nullable', 'string', 'max:200'], - 'seo.og_image_default_width' => ['nullable', 'integer', 'min:0', 'max:8000'], - 'seo.og_image_default_height' => ['nullable', 'integer', 'min:0', 'max:8000'], + 'seo.og_image_default_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_width_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_width_max', 8000)], + 'seo.og_image_default_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_height_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_height_max', 8000)], 'seo.twitter_default_card' => ['nullable', 'string', Rule::in(['summary', 'summary_large_image', 'app', 'player', ''])], 'seo.twitter_default_site' => ['nullable', 'string', 'max:50'], 'seo.cache_enabled' => ['nullable', 'boolean'], - 'seo.cache_ttl' => ['nullable', 'integer', 'min:60', 'max:86400'], + 'seo.cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_cache_ttl_min', 60), 'max:'.config('core.settings_limits.seo_cache_ttl_max', 86400)], 'seo.sitemap_enabled' => ['nullable', 'boolean'], - 'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'], + 'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_sitemap_cache_ttl_min', 3600), 'max:'.config('core.settings_limits.seo_sitemap_cache_ttl_max', 604800)], // 분할 기준. 상한 50000 은 sitemaps.org 프로토콜 제한이며 SitemapWriter 가 동일 값으로 클램프합니다. - 'seo.sitemap_urls_per_file' => ['nullable', 'integer', 'min:1000', 'max:50000'], + 'seo.sitemap_urls_per_file' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_sitemap_urls_per_file_min', 1000), 'max:'.config('core.settings_limits.seo_sitemap_urls_per_file_max', 50000)], 'seo.sitemap_gzip' => ['nullable', 'boolean'], 'seo.sitemap_serve_stale_on_miss' => ['nullable', 'boolean'], 'seo.sitemap_max_urls_per_contributor' => ['nullable', 'integer', 'min:0'], @@ -243,8 +266,18 @@ class SaveSettingsRequest extends FormRequest 'security.force_https' => $this->getTabRules($tab, 'security', 'boolean'), 'security.login_attempt_enabled' => $this->getTabRules($tab, 'security', 'boolean'), 'security.auth_token_lifetime' => $this->getSecurityTabAuthTokenRules($tab), - 'security.max_login_attempts' => ['nullable', 'integer', 'min:0', 'max:100'], - 'security.login_lockout_time' => ['nullable', 'integer', 'min:0', 'max:1440'], + 'security.max_login_attempts' => ['nullable', 'integer', 'min:'.config('core.settings_limits.security_max_login_attempts_min', 0), 'max:'.config('core.settings_limits.security_max_login_attempts_max', 100)], + 'security.login_lockout_time' => ['nullable', 'integer', 'min:'.config('core.settings_limits.security_login_lockout_time_min', 0), 'max:'.config('core.settings_limits.security_login_lockout_time_max', 1440)], + // 비밀번호 정책 — 저장 규칙이 없어 whitelist 에서 탈락하던 고아 설정을 배선. + // 경계값은 config('core.settings_limits') 가 SSoT — 설정 화면도 같은 값을 받아 쓴다. + 'security.password_min_length' => [ + 'nullable', + 'integer', + 'min:'.config('core.settings_limits.security_password_min_length_min', 6), + 'max:'.config('core.settings_limits.security_password_min_length_max', 64), + ], + 'security.require_password_special_char' => ['nullable', 'boolean'], + 'security.two_factor_auth' => ['nullable', 'boolean'], // 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송 시 기본값 false 유지 'security.allow_internal_outbound_urls' => ['nullable', 'boolean'], @@ -258,7 +291,7 @@ class SaveSettingsRequest extends FormRequest 'advanced.seo_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab), // 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송/미지정 시 코드 기본값 유지. // 범위는 SEO 탭의 오버라이드 칸(seo.sitemap_cache_ttl)과 동일하게 맞춘다. - 'advanced.seo_sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'], + 'advanced.seo_sitemap_cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_seo_sitemap_cache_ttl_min', 3600), 'max:'.config('core.settings_limits.advanced_seo_sitemap_cache_ttl_max', 604800)], // 디버그 설정 (advanced 탭) 'advanced.debug_mode' => $this->getTabRules($tab, 'advanced', 'boolean'), @@ -284,35 +317,35 @@ class SaveSettingsRequest extends FormRequest 'drivers.s3_url' => ['nullable', 'url', 'max:500'], 'drivers.cache_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_CACHE_DRIVERS)]), 'drivers.redis_host' => ['nullable', 'string', 'max:255'], - 'drivers.redis_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + '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:0', 'max:15'], + '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:1', 'max:65535'], + '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_lifetime' => ['nullable', 'integer', 'min:1', 'max:43200'], // 1분 ~ 30일 + '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.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'], 'drivers.websocket_app_secret' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'], 'drivers.websocket_host' => ['nullable', 'string', 'max:255'], - 'drivers.websocket_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'drivers.websocket_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_websocket_port_min', 1), 'max:'.config('core.settings_limits.drivers_websocket_port_max', 65535)], 'drivers.websocket_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)], 'drivers.websocket_verify_ssl' => ['nullable', 'boolean'], 'drivers.websocket_server_host' => ['nullable', 'string', 'max:255'], - 'drivers.websocket_server_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'drivers.websocket_server_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_websocket_server_port_min', 1), 'max:'.config('core.settings_limits.drivers_websocket_server_port_max', 65535)], 'drivers.websocket_server_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)], 'drivers.search_engine_driver' => ['nullable', Rule::in($this->getSupportedSearchEngineDrivers())], 'drivers.log_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_DRIVERS)]), 'drivers.log_level' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_LEVELS)]), - 'drivers.log_days' => ['nullable', 'integer', 'min:1', 'max:365'], + 'drivers.log_days' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_log_days_min', 1), 'max:'.config('core.settings_limits.drivers_log_days_max', 365)], // 본인인증(IDV) provider 기술 파라미터 — 정책 분기는 IdentityPolicy 로 흡수됨 'identity.default_provider' => ['nullable', 'string', 'max:100'], 'identity.purpose_providers' => ['sometimes', 'array'], - 'identity.challenge_ttl_minutes' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:1440'), - 'identity.max_attempts' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:20'), + 'identity.challenge_ttl_minutes' => $this->getTabRules($tab, 'identity', 'integer|min:'.config('core.settings_limits.identity_challenge_ttl_minutes_min', 1).'|max:'.config('core.settings_limits.identity_challenge_ttl_minutes_max', 1440)), + 'identity.max_attempts' => $this->getTabRules($tab, 'identity', 'integer|min:'.config('core.settings_limits.identity_max_attempts_min', 1).'|max:'.config('core.settings_limits.identity_max_attempts_max', 20)), ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 @@ -374,8 +407,8 @@ class SaveSettingsRequest extends FormRequest return [ 'nullable', 'integer', - 'min:0', - 'max:3600', + 'min:'.config('core.settings_limits.security_auth_token_lifetime_min', 0), + 'max:'.config('core.settings_limits.security_auth_token_lifetime_max', 3600), function ($attribute, $value, $fail) { // 0은 무한대를 의미하므로 허용, 그 외에는 30 이상이어야 함 if ($value !== null && $value !== 0 && $value < 30) { @@ -436,8 +469,8 @@ class SaveSettingsRequest extends FormRequest return array_merge($baseRules, [ 'integer', - 'min:0', - 'max:14400', + 'min:'.config('core.settings_limits.advanced_cache_ttl_min', 0), + 'max:'.config('core.settings_limits.advanced_cache_ttl_max', 14400), ]); } @@ -780,6 +813,120 @@ class SaveSettingsRequest extends FormRequest 'identity.purpose_providers' => __('validation.attributes.identity_purpose_providers'), 'identity.challenge_ttl_minutes' => __('validation.attributes.identity_challenge_ttl_minutes'), 'identity.max_attempts' => __('validation.attributes.identity_max_attempts'), + // notifications + 'notifications.channels' => __('validation.attributes.channels'), + // general + 'general.currency' => __('validation.attributes.currency'), + 'general.maintenance_mode' => __('validation.attributes.maintenance_mode'), + 'general.asset_url_mode' => __('validation.attributes.asset_url_mode'), + 'general.site_logo' => __('validation.attributes.site_logo'), + // mail + 'mail.mailer' => __('validation.attributes.mailer'), + 'mail.host' => __('validation.attributes.host'), + 'mail.port' => __('validation.attributes.port'), + 'mail.username' => __('validation.attributes.username'), + 'mail.password' => __('validation.attributes.password'), + 'mail.encryption' => __('validation.attributes.encryption'), + 'mail.mailgun_domain' => __('validation.attributes.mailgun_domain'), + 'mail.mailgun_secret' => __('validation.attributes.mailgun_secret'), + 'mail.mailgun_endpoint' => __('validation.attributes.mailgun_endpoint'), + 'mail.ses_key' => __('validation.attributes.ses_key'), + 'mail.ses_secret' => __('validation.attributes.ses_secret'), + 'mail.ses_region' => __('validation.attributes.ses_region'), + 'mail.from_address' => __('validation.attributes.from_address'), + 'mail.from_name' => __('validation.attributes.from_name'), + // upload + 'upload.max_file_size' => __('validation.attributes.max_file_size'), + 'upload.allowed_extensions' => __('validation.attributes.allowed_extensions'), + 'upload.image_max_width' => __('validation.attributes.image_max_width'), + 'upload.image_max_height' => __('validation.attributes.image_max_height'), + 'upload.image_quality' => __('validation.attributes.image_quality'), + // seo + 'seo.meta_title_suffix' => __('validation.attributes.meta_title_suffix'), + 'seo.meta_description' => __('validation.attributes.meta_description'), + 'seo.meta_keywords' => __('validation.attributes.meta_keywords'), + 'seo.google_analytics_id' => __('validation.attributes.google_analytics_id'), + 'seo.google_site_verification' => __('validation.attributes.google_site_verification'), + 'seo.naver_site_verification' => __('validation.attributes.naver_site_verification'), + 'seo.bot_user_agents' => __('validation.attributes.bot_user_agents'), + 'seo.bot_detection_enabled' => __('validation.attributes.bot_detection_enabled'), + 'seo.bot_detection_library_enabled' => __('validation.attributes.bot_detection_library_enabled'), + 'seo.og_default_site_name' => __('validation.attributes.og_default_site_name'), + 'seo.og_image_default_width' => __('validation.attributes.og_image_default_width'), + 'seo.og_image_default_height' => __('validation.attributes.og_image_default_height'), + 'seo.twitter_default_card' => __('validation.attributes.twitter_default_card'), + 'seo.twitter_default_site' => __('validation.attributes.twitter_default_site'), + 'seo.cache_enabled' => __('validation.attributes.seo_page_cache_enabled'), + 'seo.cache_ttl' => __('validation.attributes.cache_ttl'), + 'seo.sitemap_enabled' => __('validation.attributes.sitemap_enabled'), + 'seo.sitemap_cache_ttl' => __('validation.attributes.sitemap_cache_ttl'), + 'seo.sitemap_urls_per_file' => __('validation.attributes.sitemap_urls_per_file'), + 'seo.sitemap_gzip' => __('validation.attributes.sitemap_gzip'), + 'seo.sitemap_serve_stale_on_miss' => __('validation.attributes.sitemap_serve_stale_on_miss'), + 'seo.sitemap_max_urls_per_contributor' => __('validation.attributes.sitemap_max_urls_per_contributor'), + 'seo.sitemap_hreflang_enabled' => __('validation.attributes.sitemap_hreflang_enabled'), + 'seo.sitemap_schedule' => __('validation.attributes.sitemap_schedule'), + 'seo.sitemap_schedule_time' => __('validation.attributes.sitemap_schedule_time'), + 'seo.generator_enabled' => __('validation.attributes.generator_enabled'), + 'seo.generator_content' => __('validation.attributes.generator_content'), + // security + 'security.force_https' => __('validation.attributes.force_https'), + 'security.login_attempt_enabled' => __('validation.attributes.login_attempt_enabled'), + 'security.auth_token_lifetime' => __('validation.attributes.auth_token_lifetime'), + 'security.max_login_attempts' => __('validation.attributes.max_login_attempts'), + 'security.login_lockout_time' => __('validation.attributes.login_lockout_time'), + 'security.password_min_length' => __('validation.attributes.password_min_length'), + 'security.require_password_special_char' => __('validation.attributes.require_password_special_char'), + 'security.two_factor_auth' => __('validation.attributes.two_factor_auth'), + 'security.allow_internal_outbound_urls' => __('validation.attributes.allow_internal_outbound_urls'), + // advanced + 'advanced.cache_enabled' => __('validation.attributes.advanced_cache_enabled'), + 'advanced.layout_cache_enabled' => __('validation.attributes.layout_cache_enabled'), + 'advanced.layout_cache_ttl' => __('validation.attributes.layout_cache_ttl'), + 'advanced.stats_cache_enabled' => __('validation.attributes.stats_cache_enabled'), + 'advanced.stats_cache_ttl' => __('validation.attributes.stats_cache_ttl'), + 'advanced.seo_cache_enabled' => __('validation.attributes.seo_cache_enabled'), + 'advanced.seo_cache_ttl' => __('validation.attributes.seo_cache_ttl'), + 'advanced.seo_sitemap_cache_ttl' => __('validation.attributes.seo_sitemap_cache_ttl'), + 'advanced.debug_mode' => __('validation.attributes.debug_mode'), + 'advanced.sql_query_log' => __('validation.attributes.sql_query_log'), + 'advanced.core_update_github_url' => __('validation.attributes.core_update_github_url'), + 'advanced.core_update_github_token' => __('validation.attributes.core_update_github_token'), + 'advanced.geoip_enabled' => __('validation.attributes.geoip_enabled'), + 'advanced.geoip_license_key' => __('validation.attributes.geoip_license_key'), + 'advanced.geoip_auto_update_enabled' => __('validation.attributes.geoip_auto_update_enabled'), + // drivers + 'drivers.storage_driver' => __('validation.attributes.storage_driver'), + 'drivers.s3_bucket' => __('validation.attributes.s3_bucket'), + 'drivers.s3_region' => __('validation.attributes.s3_region'), + '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.cache_driver' => __('validation.attributes.cache_driver'), + 'drivers.redis_host' => __('validation.attributes.redis_host'), + 'drivers.redis_port' => __('validation.attributes.redis_port'), + 'drivers.redis_password' => __('validation.attributes.redis_password'), + 'drivers.redis_database' => __('validation.attributes.redis_database'), + 'drivers.memcached_host' => __('validation.attributes.memcached_host'), + 'drivers.memcached_port' => __('validation.attributes.memcached_port'), + 'drivers.session_driver' => __('validation.attributes.session_driver'), + 'drivers.session_lifetime' => __('validation.attributes.session_lifetime'), + 'drivers.queue_driver' => __('validation.attributes.queue_driver'), + 'drivers.websocket_enabled' => __('validation.attributes.websocket_enabled'), + 'drivers.websocket_app_id' => __('validation.attributes.websocket_app_id'), + 'drivers.websocket_app_key' => __('validation.attributes.websocket_app_key'), + 'drivers.websocket_app_secret' => __('validation.attributes.websocket_app_secret'), + 'drivers.websocket_host' => __('validation.attributes.websocket_host'), + 'drivers.websocket_port' => __('validation.attributes.websocket_port'), + 'drivers.websocket_scheme' => __('validation.attributes.websocket_scheme'), + 'drivers.websocket_verify_ssl' => __('validation.attributes.websocket_verify_ssl'), + 'drivers.websocket_server_host' => __('validation.attributes.websocket_server_host'), + 'drivers.websocket_server_port' => __('validation.attributes.websocket_server_port'), + 'drivers.websocket_server_scheme' => __('validation.attributes.websocket_server_scheme'), + 'drivers.search_engine_driver' => __('validation.attributes.search_engine_driver'), + 'drivers.log_driver' => __('validation.attributes.log_driver'), + 'drivers.log_level' => __('validation.attributes.log_level'), + 'drivers.log_days' => __('validation.attributes.log_days'), ]; } } diff --git a/app/Http/Requests/User/ChangePasswordRequest.php b/app/Http/Requests/User/ChangePasswordRequest.php index ad81dcdc..af3bd322 100644 --- a/app/Http/Requests/User/ChangePasswordRequest.php +++ b/app/Http/Requests/User/ChangePasswordRequest.php @@ -3,8 +3,9 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rules\Password; /** * 비밀번호 변경 요청 클래스 @@ -15,6 +16,8 @@ class ChangePasswordRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -24,13 +27,13 @@ class ChangePasswordRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { $rules = [ 'current_password' => ['required', 'string', 'current_password:sanctum'], - 'password' => ['required', 'string', 'confirmed', Password::min(8)], + 'password' => ['required', 'string', 'confirmed', PasswordPolicy::rule()], 'password_confirmation' => ['required', 'string'], ]; @@ -54,4 +57,4 @@ class ChangePasswordRequest extends FormRequest 'password_confirmation.required' => __('user.validation.password_confirmation_required'), ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/User/CreateUserRequest.php b/app/Http/Requests/User/CreateUserRequest.php index e0d5f595..e59cac4d 100644 --- a/app/Http/Requests/User/CreateUserRequest.php +++ b/app/Http/Requests/User/CreateUserRequest.php @@ -5,14 +5,17 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; use App\Models\Role; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\Password; class CreateUserRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -22,7 +25,7 @@ class CreateUserRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -33,7 +36,7 @@ class CreateUserRequest extends FormRequest 'name' => 'required|string|max:255', 'nickname' => 'nullable|string|max:50', 'email' => ['required', 'email', Rule::unique(User::class, 'email'), 'max:255'], - 'password' => ['required', 'confirmed', Password::min(8)], + 'password' => ['required', 'confirmed', PasswordPolicy::rule()], 'language' => 'nullable|string|in:'.$supportedLocales, 'country' => 'nullable|string|size:2|alpha', 'timezone' => ['nullable', 'string', 'timezone'], @@ -122,8 +125,6 @@ class CreateUserRequest extends FormRequest * 유효성 검사를 위해 데이터를 준비합니다. * * role_ids와 roles가 동시에 전송될 경우 role_ids를 우선하여 roles를 제거합니다. - * - * @return void */ protected function prepareForValidation(): void { diff --git a/app/Http/Requests/User/UpdateProfileRequest.php b/app/Http/Requests/User/UpdateProfileRequest.php index 3db5feee..fd7cdbf2 100644 --- a/app/Http/Requests/User/UpdateProfileRequest.php +++ b/app/Http/Requests/User/UpdateProfileRequest.php @@ -4,6 +4,8 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\Rule; @@ -19,6 +21,8 @@ class UpdateProfileRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -28,7 +32,7 @@ class UpdateProfileRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -40,7 +44,7 @@ class UpdateProfileRequest extends FormRequest 'name' => ['required', 'string', 'max:255'], 'nickname' => ['nullable', 'string', 'max:50'], 'email' => ['required', 'email', 'max:255', Rule::unique(User::class, 'email')->ignore($userId)], - 'password' => ['nullable', 'confirmed', Password::min(8)], + 'password' => ['nullable', 'confirmed', PasswordPolicy::rule()], 'current_password' => ['required_with:password', 'current_password'], 'language' => ['nullable', 'string', Rule::in($supportedLocales)], 'country' => ['nullable', 'string', 'size:2', 'alpha'], @@ -124,5 +128,4 @@ class UpdateProfileRequest extends FormRequest ]); } } - } diff --git a/app/Http/Requests/User/UpdateUserRequest.php b/app/Http/Requests/User/UpdateUserRequest.php index 677e2fae..ff4b5f4d 100644 --- a/app/Http/Requests/User/UpdateUserRequest.php +++ b/app/Http/Requests/User/UpdateUserRequest.php @@ -5,14 +5,17 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; use App\Models\Role; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\Password; class UpdateUserRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 permission 미들웨어에서 검증) */ public function authorize(): bool { @@ -24,8 +27,6 @@ class UpdateUserRequest extends FormRequest * * API 응답에서 로드된 roles(객체 배열)와 사용자가 선택한 role_ids가 * 동시에 전송될 수 있으므로, role_ids가 있으면 roles를 제거합니다. - * - * @return void */ protected function prepareForValidation(): void { @@ -37,7 +38,7 @@ class UpdateUserRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -54,7 +55,7 @@ class UpdateUserRequest extends FormRequest 'max:255', Rule::unique(User::class, 'email')->ignore($userId), ], - 'password' => ['nullable', 'confirmed', Password::min(8)], + 'password' => ['nullable', 'confirmed', PasswordPolicy::rule()], 'language' => 'nullable|string|in:'.$supportedLocales, 'country' => 'nullable|string|size:2|alpha', 'timezone' => ['nullable', 'string', 'timezone'], diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 6abc8929..3c397b3b 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -260,6 +260,14 @@ class UserResource extends BaseApiResource 'blocked_at' => $this->getValue('blocked_at') ? $this->formatDateTimeStringForUser($this->getValue('blocked_at')) : null, + // 로그인 실패 누적 잠금 상태 — 관리자만 확인·해제할 수 있어야 하므로 관리자 응답에만 노출 + 'failed_login_attempts' => (int) ($this->getValue('failed_login_attempts') ?? 0), + 'locked_permanently' => (bool) $this->getValue('locked_permanently'), + 'locked_until' => $this->getValue('locked_until') + ? $this->formatDateTimeStringForUser($this->getValue('locked_until')) + : null, + 'is_locked' => (bool) $this->getValue('locked_permanently') + || ($this->getValue('locked_until') !== null && $this->resource->locked_until?->isFuture()), ]); // Filter 훅: 모듈이 자신의 데이터를 응답에 병합 diff --git a/app/Jobs/DispatchHookListenerJob.php b/app/Jobs/DispatchHookListenerJob.php index 3bcee7a6..9371090f 100644 --- a/app/Jobs/DispatchHookListenerJob.php +++ b/app/Jobs/DispatchHookListenerJob.php @@ -1,10 +1,14 @@ context); + // 큐 드라이버가 sync 면 이 Job 은 원 HTTP 요청과 **같은 컨테이너**에서 실행된다. + // restore() 가 재구성 Request 를 바인딩하면 그 뒤로 이어지는 같은 요청의 코드는 + // 헤더·입력·메서드가 전부 지워진 가짜 GET 요청을 보게 된다 — 뒤 순위 훅 리스너가 + // 전송 헤더를 읽지 못해 조용히 건너뛰는 사고가 여기서 난다. + // 워커 프로세스에서는 원래 바인딩이 없어 아래 원복이 아무 일도 하지 않는다. + $previousRequest = app()->bound('request') ? app('request') : null; - $args = HookArgumentSerializer::deserialize($this->serializedArgs); - app($this->listenerClass)->{$this->method}(...$args); + try { + HookContextCapture::restore($this->context); + + $args = HookArgumentSerializer::deserialize($this->serializedArgs); + + try { + $listener = app($this->listenerClass); + } catch (BindingResolutionException $e) { + // 리스너를 만들 수 없다는 것은 그 확장이 이 런타임에 배선되지 않았다는 뜻이다. + // Action 훅은 부가 작업이므로 호출자의 작업까지 함께 실패시키지 않고 기록만 남긴다 + // (큐 드라이버가 sync 인 환경에서 원 요청이 500 이 되는 것을 막는다). + Log::warning('훅 리스너를 해석할 수 없어 건너뜁니다 (확장 미배선)', [ + 'listener' => $this->listenerClass, + 'method' => $this->method, + 'exception' => $e->getMessage(), + ]); + + return; + } + + $listener->{$this->method}(...$args); + } finally { + if ($previousRequest !== null) { + app()->instance('request', $previousRequest); + } + } } /** * Job 실패 시 로그를 기록합니다. * * @param \Throwable $exception 발생한 예외 - * @return void */ public function failed(\Throwable $exception): void { @@ -76,8 +102,6 @@ class DispatchHookListenerJob implements ShouldQueue /** * Job의 표시 이름을 반환합니다. - * - * @return string */ public function displayName(): string { diff --git a/app/Listeners/CoreActivityLogListener.php b/app/Listeners/CoreActivityLogListener.php index 4717b492..ef1fbdcf 100644 --- a/app/Listeners/CoreActivityLogListener.php +++ b/app/Listeners/CoreActivityLogListener.php @@ -8,8 +8,10 @@ use App\Contracts\Extension\HookListenerInterface; use App\Contracts\Repositories\ActivityLogRepositoryInterface; use App\Contracts\Repositories\ScheduleRepositoryInterface; use App\Contracts\Repositories\UserRepositoryInterface; -use App\Models\ActivityLog; +use App\Extension\IdentityVerification\DTO\VerificationChallenge; +use App\Extension\IdentityVerification\DTO\VerificationResult; use App\Models\Attachment; +use App\Models\IdentityVerificationLog; use App\Models\Schedule; use App\Models\User; use Illuminate\Database\Eloquent\Model; @@ -65,6 +67,7 @@ class CoreActivityLogListener implements HookListenerInterface 'core.auth.record_consents' => ['method' => 'handleAuthRecordConsents', 'priority' => 20], 'core.auth.login_failed' => ['method' => 'handleAuthLoginFailed', 'priority' => 20], 'core.auth.account_locked' => ['method' => 'handleAuthAccountLocked', 'priority' => 20], + 'core.auth.account_unlocked' => ['method' => 'handleAuthAccountUnlocked', 'priority' => 20], // ─── IdentityVerification (IDV) ─── // DTO(VerificationChallenge/VerificationResult) 를 인자로 받으므로 sync 실행 필수 @@ -145,7 +148,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 훅 이벤트 처리 (기본 핸들러) * - * @param mixed ...$args 훅에서 전달된 인수들 + * @param mixed ...$args 훅에서 전달된 인수들 */ public function handle(...$args): void { @@ -159,8 +162,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 생성 후 로그 기록 * - * @param User $user 생성된 사용자 - * @param array $originalData 원본 요청 데이터 + * @param User $user 생성된 사용자 + * @param array $originalData 원본 요청 데이터 */ public function handleUserAfterCreate(User $user, array $originalData): void { @@ -175,9 +178,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 수정 후 로그 기록 * - * @param User $user 수정된 사용자 - * @param array $originalData 원본 요청 데이터 - * @param array|null $snapshot 수정 전 스냅샷 + * @param User $user 수정된 사용자 + * @param array $originalData 원본 요청 데이터 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleUserAfterUpdate(User $user, array $originalData, ?array $snapshot = null): void { @@ -194,7 +197,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 삭제 후 로그 기록 * - * @param array $userData 삭제된 사용자 데이터 + * @param array $userData 삭제된 사용자 데이터 */ public function handleUserAfterDelete(array $userData): void { @@ -213,7 +216,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 탈퇴 후 로그 기록 * - * @param User $user 탈퇴한 사용자 + * @param User $user 탈퇴한 사용자 */ public function handleUserAfterWithdraw(User $user): void { @@ -227,7 +230,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 상세 조회 후 로그 기록 * - * @param User $user 조회된 사용자 + * @param User $user 조회된 사용자 */ public function handleUserAfterShow(User $user): void { @@ -241,7 +244,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 목록 조회 후 로그 기록 * - * @param int $count 조회된 총 사용자 수 + * @param int $count 조회된 총 사용자 수 */ public function handleUserAfterList(int $count): void { @@ -254,8 +257,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 검색 후 로그 기록 * - * @param string $query 검색어 - * @param int $count 검색 결과 수 + * @param string $query 검색어 + * @param int $count 검색 결과 수 */ public function handleUserAfterSearch(string $query, int $count): void { @@ -268,10 +271,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 일괄 상태 변경 후 로그 기록 * - * @param array $uuids 대상 UUID 목록 - * @param string $status 변경된 상태 - * @param int $updatedCount 변경된 수 - * @param array $snapshots 변경 전 스냅샷 (uuid => snapshot) + * @param array $uuids 대상 UUID 목록 + * @param string $status 변경된 상태 + * @param int $updatedCount 변경된 수 + * @param array $snapshots 변경 전 스냅샷 (uuid => snapshot) */ public function handleUserAfterBulkUpdate(array $uuids, string $status, int $updatedCount, array $snapshots = []): void { @@ -303,8 +306,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 관리자 로그인 후 로그 기록 * - * @param User $user 로그인한 사용자 - * @param array $loginData 로그인 데이터 + * @param User $user 로그인한 사용자 + * @param array $loginData 로그인 데이터 */ public function handleAuthAfterLogin(User $user, array $loginData): void { @@ -318,7 +321,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 로그아웃 로그 기록 * - * @param User $user 로그아웃한 사용자 + * @param User $user 로그아웃한 사용자 */ public function handleAuthLogout(User $user): void { @@ -332,8 +335,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 회원가입 후 로그 기록 * - * @param User $user 등록된 사용자 - * @param array $registrationData 등록 데이터 + * @param User $user 등록된 사용자 + * @param array $registrationData 등록 데이터 */ public function handleAuthRegister(User $user, array $registrationData): void { @@ -347,7 +350,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 비밀번호 찾기 요청 로그 기록 * - * @param User $user 요청한 사용자 + * @param User $user 요청한 사용자 */ public function handleAuthForgotPassword(User $user): void { @@ -361,7 +364,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 비밀번호 재설정 로그 기록 * - * @param User $user 재설정한 사용자 + * @param User $user 재설정한 사용자 */ public function handleAuthResetPassword(User $user): void { @@ -375,10 +378,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 이용약관 동의 기록 로그 * - * @param User $user 동의한 사용자 - * @param array $data 동의 데이터 - * @param string|null $agreedAt 동의 시각 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) - * @param string|null $ip IP 주소 + * @param User $user 동의한 사용자 + * @param array $data 동의 데이터 + * @param string|null $agreedAt 동의 시각 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) + * @param string|null $ip IP 주소 */ public function handleAuthRecordConsents(User $user, array $data, ?string $agreedAt = null, ?string $ip = null): void { @@ -393,8 +396,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 로그인 실패 로그 기록 (사용자 존재 여부와 무관) * - * @param string $email 시도된 이메일 - * @param array $context IP/UA/시각 등 부가 정보 + * @param string $email 시도된 이메일 + * @param array $context IP/UA/시각 등 부가 정보 */ public function handleAuthLoginFailed(string $email, array $context = []): void { @@ -408,14 +411,19 @@ class CoreActivityLogListener implements HookListenerInterface /** * 계정 잠금 로그 기록 * - * @param User $user 잠긴 사용자 - * @param array $context attempts/locked_until/lockout_minutes/IP + * @param User $user 잠긴 사용자 + * @param array $context attempts/locked_until/lockout_minutes/IP */ public function handleAuthAccountLocked(User $user, array $context = []): void { + // 영구 잠금(무한대)은 차단 분(minutes)이 없으므로 별도 문구를 사용한다. + $permanent = ($context['permanent'] ?? false) || ($context['locked_until'] ?? null) === null; + $this->logActivity('auth.account_locked', [ 'loggable' => $user, - 'description_key' => 'activity_log.description.auth_account_locked', + 'description_key' => $permanent + ? 'activity_log.description.auth_account_locked_permanently' + : 'activity_log.description.auth_account_locked', 'description_params' => [ 'attempts' => $context['attempts'] ?? null, 'minutes' => $context['lockout_minutes'] ?? null, @@ -425,6 +433,22 @@ class CoreActivityLogListener implements HookListenerInterface ]); } + /** + * 계정 잠금 해제 로그 기록 (관리자 수동 해제) + * + * @param User $user 잠금이 해제된 사용자 + * @param array $context IP 등 부가 정보 + */ + public function handleAuthAccountUnlocked(User $user, array $context = []): void + { + $this->logActivity('auth.account_unlocked', [ + 'loggable' => $user, + 'description_key' => 'activity_log.description.auth_account_unlocked', + 'description_params' => ['email' => $user->email], + 'ip_address' => $context['ip_address'] ?? null, + ]); + } + // ═══════════════════════════════════════════ // Role 핸들러 // ═══════════════════════════════════════════ @@ -432,7 +456,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 생성 후 로그 기록 * - * @param Model $role 생성된 역할 + * @param Model $role 생성된 역할 */ public function handleRoleAfterCreate(Model $role): void { @@ -447,8 +471,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 수정 후 로그 기록 * - * @param Model $role 수정된 역할 - * @param array|null $snapshot 수정 전 스냅샷 + * @param Model $role 수정된 역할 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleRoleAfterUpdate(Model $role, ?array $snapshot = null): void { @@ -465,7 +489,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 삭제 후 로그 기록 * - * @param int $roleId 삭제된 역할 ID + * @param int $roleId 삭제된 역할 ID */ public function handleRoleAfterDelete(int $roleId): void { @@ -479,9 +503,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 권한 동기화 후 로그 기록 * - * @param Model $role 역할 - * @param array $previousPermissions 이전 권한 목록 - * @param array $currentPermissions 현재 권한 목록 + * @param Model $role 역할 + * @param array $previousPermissions 이전 권한 목록 + * @param array $currentPermissions 현재 권한 목록 */ public function handleRoleAfterSyncPermissions(Model $role, array $previousPermissions, array $currentPermissions): void { @@ -499,7 +523,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 상태 전환 후 로그 기록 * - * @param Model $role 역할 + * @param Model $role 역할 */ public function handleRoleAfterToggleStatus(Model $role): void { @@ -517,7 +541,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 생성 후 로그 기록 * - * @param Model $menu 생성된 메뉴 + * @param Model $menu 생성된 메뉴 */ public function handleMenuAfterCreate(Model $menu): void { @@ -532,8 +556,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 수정 후 로그 기록 * - * @param Model $menu 수정된 메뉴 - * @param array|null $snapshot 수정 전 스냅샷 + * @param Model $menu 수정된 메뉴 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleMenuAfterUpdate(Model $menu, ?array $snapshot = null): void { @@ -550,7 +574,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 삭제 후 로그 기록 * - * @param int $menuId 삭제된 메뉴 ID + * @param int $menuId 삭제된 메뉴 ID */ public function handleMenuAfterDelete(int $menuId): void { @@ -564,7 +588,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 순서 변경 후 로그 기록 * - * @param array $orderData 순서 데이터 + * @param array $orderData 순서 데이터 */ public function handleMenuAfterUpdateOrder(array $orderData): void { @@ -576,7 +600,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 상태 전환 후 로그 기록 * - * @param Model $menu 메뉴 + * @param Model $menu 메뉴 */ public function handleMenuAfterToggleStatus(Model $menu): void { @@ -590,9 +614,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 역할 동기화 후 로그 기록 * - * @param Model $menu 메뉴 - * @param array $previousRoles 이전 역할 목록 - * @param array $currentRoles 현재 역할 목록 + * @param Model $menu 메뉴 + * @param array $previousRoles 이전 역할 목록 + * @param array $currentRoles 현재 역할 목록 */ public function handleMenuAfterSyncRoles(Model $menu, array $previousRoles, array $currentRoles): void { @@ -614,9 +638,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 설정 저장 후 로그 기록 * - * @param string $tab 설정 탭 - * @param array $settings 저장된 설정 - * @param bool $result 저장 결과 + * @param string $tab 설정 탭 + * @param array $settings 저장된 설정 + * @param bool $result 저장 결과 */ public function handleSettingsAfterSave(string $tab, array $settings, bool $result): void { @@ -629,9 +653,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 개별 설정 변경 후 로그 기록 * - * @param string $key 설정 키 - * @param mixed $value 설정 값 - * @param bool $result 저장 결과 + * @param string $key 설정 키 + * @param mixed $value 설정 값 + * @param bool $result 저장 결과 */ public function handleSettingsAfterSet(string $key, mixed $value, bool $result): void { @@ -648,7 +672,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 생성 후 로그 기록 * - * @param Model $schedule 생성된 스케줄 + * @param Model $schedule 생성된 스케줄 */ public function handleScheduleAfterCreate(Model $schedule): void { @@ -662,8 +686,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 수정 후 로그 기록 * - * @param Model $schedule 수정된 스케줄 - * @param array|null $snapshot 수정 전 스냅샷 + * @param Model $schedule 수정된 스케줄 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleScheduleAfterUpdate(Model $schedule, ?array $snapshot = null): void { @@ -680,7 +704,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 삭제 후 로그 기록 * - * @param int $scheduleId 삭제된 스케줄 ID + * @param int $scheduleId 삭제된 스케줄 ID */ public function handleScheduleAfterDelete(int $scheduleId): void { @@ -694,8 +718,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 수동 실행 후 로그 기록 * - * @param Model $schedule 실행된 스케줄 - * @param Model $history 실행 이력 + * @param Model $schedule 실행된 스케줄 + * @param Model $history 실행 이력 */ public function handleScheduleAfterRun(Model $schedule, Model $history): void { @@ -709,10 +733,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 일괄 상태 변경 후 로그 기록 * - * @param array $ids 대상 ID 목록 - * @param bool $isActive 활성화 여부 - * @param int $updatedCount 변경된 수 - * @param array $snapshots 변경 전 스냅샷 (id => snapshot) + * @param array $ids 대상 ID 목록 + * @param bool $isActive 활성화 여부 + * @param int $updatedCount 변경된 수 + * @param array $snapshots 변경 전 스냅샷 (id => snapshot) */ public function handleScheduleAfterBulkUpdate(array $ids, bool $isActive, int $updatedCount, array $snapshots = []): void { @@ -740,9 +764,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 일괄 삭제 후 로그 기록 * - * @param array $ids 대상 ID 목록 - * @param int $deletedCount 삭제된 수 - * @param array $snapshots 삭제 전 스냅샷 (id => snapshot) + * @param array $ids 대상 ID 목록 + * @param int $deletedCount 삭제된 수 + * @param array $snapshots 삭제 전 스냅샷 (id => snapshot) */ public function handleScheduleAfterBulkDelete(array $ids, int $deletedCount, array $snapshots = []): void { @@ -769,7 +793,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 첨부파일 업로드 후 로그 기록 * - * @param Model $attachment 업로드된 첨부파일 + * @param Model $attachment 업로드된 첨부파일 */ public function handleAttachmentAfterUpload(Model $attachment): void { @@ -782,7 +806,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 첨부파일 삭제 후 로그 기록 * - * @param Model|null $attachment 삭제된 첨부파일 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) + * @param Model|null $attachment 삭제된 첨부파일 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) */ public function handleAttachmentAfterDelete(?Model $attachment = null): void { @@ -794,10 +818,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 첨부파일 일괄 삭제 후 로그 기록 (per-item) * - * @param string $identifier 식별자 - * @param int $count 삭제된 수 - * @param array $ids 삭제된 첨부파일 ID 목록 - * @param array $snapshots 삭제 전 스냅샷 (keyBy id) + * @param string $identifier 식별자 + * @param int $count 삭제된 수 + * @param array $ids 삭제된 첨부파일 ID 목록 + * @param array $snapshots 삭제 전 스냅샷 (keyBy id) */ public function handleAttachmentAfterBulkDelete(string $identifier, int $count, array $ids = [], array $snapshots = []): void { @@ -825,8 +849,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 설치 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $moduleInfo 모듈 정보 + * @param string $moduleName 모듈 식별자 + * @param array $moduleInfo 모듈 정보 */ public function handleModuleAfterInstall(string $moduleName, array $moduleInfo): void { @@ -840,8 +864,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 활성화 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $moduleInfo 모듈 정보 + * @param string $moduleName 모듈 식별자 + * @param array $moduleInfo 모듈 정보 */ public function handleModuleAfterActivate(string $moduleName, array $moduleInfo): void { @@ -854,7 +878,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 비활성화 후 로그 기록 * - * @param string $moduleName 모듈 식별자 + * @param string $moduleName 모듈 식별자 */ public function handleModuleAfterDeactivate(string $moduleName): void { @@ -867,9 +891,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 제거 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $moduleInfo 모듈 정보 - * @param bool $deleteData 데이터 삭제 여부 + * @param string $moduleName 모듈 식별자 + * @param array $moduleInfo 모듈 정보 + * @param bool $deleteData 데이터 삭제 여부 */ public function handleModuleAfterUninstall(string $moduleName, array $moduleInfo, bool $deleteData): void { @@ -883,9 +907,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 업데이트 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) - * @param array $moduleInfo 모듈 정보 + * @param string $moduleName 모듈 식별자 + * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) + * @param array $moduleInfo 모듈 정보 */ public function handleModuleAfterUpdate(string $moduleName, array $result, array $moduleInfo): void { @@ -899,8 +923,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 레이아웃 갱신 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $result 갱신 결과 + * @param string $moduleName 모듈 식별자 + * @param array $result 갱신 결과 */ public function handleModuleAfterRefreshLayouts(string $moduleName, array $result): void { @@ -917,8 +941,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 설치 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param array $pluginInfo 플러그인 정보 + * @param string $pluginName 플러그인 식별자 + * @param array $pluginInfo 플러그인 정보 */ public function handlePluginAfterInstall(string $pluginName, array $pluginInfo): void { @@ -932,8 +956,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 활성화 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param array $pluginInfo 플러그인 정보 + * @param string $pluginName 플러그인 식별자 + * @param array $pluginInfo 플러그인 정보 */ public function handlePluginAfterActivate(string $pluginName, array $pluginInfo): void { @@ -946,7 +970,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 비활성화 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 + * @param string $pluginName 플러그인 식별자 */ public function handlePluginAfterDeactivate(string $pluginName): void { @@ -959,9 +983,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 제거 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param bool $deleteData 데이터 삭제 여부 - * @param bool $result 제거 결과 + * @param string $pluginName 플러그인 식별자 + * @param bool $deleteData 데이터 삭제 여부 + * @param bool $result 제거 결과 */ public function handlePluginAfterUninstall(string $pluginName, bool $deleteData, bool $result): void { @@ -975,9 +999,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 업데이트 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) - * @param array $pluginInfo 플러그인 정보 + * @param string $pluginName 플러그인 식별자 + * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) + * @param array $pluginInfo 플러그인 정보 */ public function handlePluginAfterUpdate(string $pluginName, array $result, array $pluginInfo): void { @@ -995,8 +1019,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 설치 후 로그 기록 * - * @param string $identifier 템플릿 식별자 - * @param array $templateInfo 템플릿 정보 + * @param string $identifier 템플릿 식별자 + * @param array $templateInfo 템플릿 정보 */ public function handleTemplateAfterInstall(string $identifier, array $templateInfo): void { @@ -1010,7 +1034,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 활성화 후 로그 기록 * - * @param array $templateInfo 템플릿 정보 + * @param array $templateInfo 템플릿 정보 */ public function handleTemplateAfterActivate(array $templateInfo): void { @@ -1023,7 +1047,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 비활성화 후 로그 기록 * - * @param string $identifier 템플릿 식별자 + * @param string $identifier 템플릿 식별자 */ public function handleTemplateAfterDeactivate(string $identifier): void { @@ -1036,9 +1060,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 제거 후 로그 기록 * - * @param string $identifier 템플릿 식별자 - * @param array $templateInfo 템플릿 정보 - * @param bool $deleteData 데이터 삭제 여부 + * @param string $identifier 템플릿 식별자 + * @param array $templateInfo 템플릿 정보 + * @param bool $deleteData 데이터 삭제 여부 */ public function handleTemplateAfterUninstall(string $identifier, array $templateInfo, bool $deleteData): void { @@ -1052,9 +1076,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 버전 업데이트 후 로그 기록 * - * @param string $templateName 템플릿 식별자 - * @param array $result 업데이트 결과 - * @param array $templateInfo 템플릿 정보 + * @param string $templateName 템플릿 식별자 + * @param array $result 업데이트 결과 + * @param array $templateInfo 템플릿 정보 */ public function handleTemplateAfterVersionUpdate(string $templateName, array $result, array $templateInfo): void { @@ -1068,8 +1092,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 레이아웃 갱신 후 로그 기록 * - * @param string $identifier 템플릿 식별자 - * @param array $result 갱신 결과 + * @param string $identifier 템플릿 식별자 + * @param array $result 갱신 결과 */ public function handleTemplateAfterRefreshLayouts(string $identifier, array $result): void { @@ -1086,10 +1110,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 레이아웃 수정 후 로그 기록 * - * @param Model $layout 수정된 레이아웃 - * @param int $templateId 템플릿 ID - * @param string $name 레이아웃 이름 - * @param array $data 수정 데이터 + * @param Model $layout 수정된 레이아웃 + * @param int $templateId 템플릿 ID + * @param string $name 레이아웃 이름 + * @param array $data 수정 데이터 */ public function handleLayoutAfterUpdate(Model $layout, int $templateId, string $name, array $data): void { @@ -1103,10 +1127,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 레이아웃 버전 복원 후 로그 기록 * - * @param Model $newVersion 복원된 새 버전 - * @param int $templateId 템플릿 ID - * @param string $name 레이아웃 이름 - * @param int $versionId 복원 대상 버전 ID + * @param Model $newVersion 복원된 새 버전 + * @param int $templateId 템플릿 ID + * @param string $name 레이아웃 이름 + * @param int $versionId 복원 대상 버전 ID */ public function handleLayoutAfterVersionRestore(Model $newVersion, int $templateId, string $name, int $versionId): void { @@ -1124,9 +1148,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 설정 저장 후 로그 기록 * - * @param string $identifier 모듈 식별자 - * @param array $settings 저장된 설정 - * @param bool $result 저장 결과 + * @param string $identifier 모듈 식별자 + * @param array $settings 저장된 설정 + * @param bool $result 저장 결과 */ public function handleModuleSettingsAfterSave(string $identifier, array $settings, bool $result): void { @@ -1140,7 +1164,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 설정 초기화 후 로그 기록 * - * @param string $identifier 모듈 식별자 + * @param string $identifier 모듈 식별자 */ public function handleModuleSettingsAfterReset(string $identifier): void { @@ -1157,9 +1181,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 설정 저장 후 로그 기록 * - * @param string $identifier 플러그인 식별자 - * @param array $settings 저장된 설정 - * @param bool $result 저장 결과 + * @param string $identifier 플러그인 식별자 + * @param array $settings 저장된 설정 + * @param bool $result 저장 결과 */ public function handlePluginSettingsAfterSave(string $identifier, array $settings, bool $result): void { @@ -1173,7 +1197,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 설정 초기화 후 로그 기록 * - * @param string $identifier 플러그인 식별자 + * @param string $identifier 플러그인 식별자 */ public function handlePluginSettingsAfterReset(string $identifier): void { @@ -1190,10 +1214,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 본인인증 challenge 요청 후 로그 기록. * - * @param \App\Extension\IdentityVerification\DTO\VerificationChallenge $challenge - * @param string $purpose - * @param \App\Models\User|array $target - * @param array $context + * @param VerificationChallenge $challenge 발급된 본인인증 challenge + * @param string $purpose 인증 목적 (IdentityVerificationPurpose 값) + * @param User|array $target 인증 대상 (회원 모델 또는 이메일/전화 배열) + * @param array $context 부가 컨텍스트 (IP 등) */ public function handleIdentityRequested($challenge, string $purpose, $target, array $context = []): void { @@ -1213,9 +1237,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 본인인증 검증 후 로그 기록 (성공·실패 공통 디스패치). * - * @param \App\Extension\IdentityVerification\DTO\VerificationResult $result - * @param \App\Models\IdentityVerificationLog|null $log - * @param array $context + * @param VerificationResult $result 검증 결과 DTO + * @param IdentityVerificationLog|null $log 검증 로그 (없을 수 있음) + * @param array $context 부가 컨텍스트 (IP 등) */ public function handleIdentityVerified($result, $log, array $context = []): void { @@ -1238,7 +1262,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 본인인증 challenge 만료 로그 기록. * - * @param \App\Models\IdentityVerificationLog $log + * @param IdentityVerificationLog $log */ public function handleIdentityExpired($log): void { @@ -1251,5 +1275,4 @@ class CoreActivityLogListener implements HookListenerInterface ], ]); } - } diff --git a/app/Listeners/Identity/EnforceIdentityPolicyListener.php b/app/Listeners/Identity/EnforceIdentityPolicyListener.php index e40317cc..b8adb26f 100644 --- a/app/Listeners/Identity/EnforceIdentityPolicyListener.php +++ b/app/Listeners/Identity/EnforceIdentityPolicyListener.php @@ -90,9 +90,13 @@ class EnforceIdentityPolicyListener implements HookListenerInterface // 2) 코어 hook 은 getSubscribedHooks() 가 소유하므로 동적 등록 대상에서 제외 $coreHooks = array_fill_keys(static::coreHookTargets(), true); - // 3) 현재 hook scope 정책 target 으로 재구독 + // 3) 전담 리스너가 소유하는 target 은 일반 enforce 대상에서 제외 + $delegated = array_fill_keys(static::delegatedHookTargets(), true); + + // 4) 현재 hook scope 정책 target 으로 재구독 foreach (static::loadDynamicHookTargets() as $hookName) { - if (! is_string($hookName) || $hookName === '' || isset($coreHooks[$hookName])) { + if (! is_string($hookName) || $hookName === '' + || isset($coreHooks[$hookName]) || isset($delegated[$hookName])) { continue; } if (isset(self::$dynamicCallbacks[$hookName])) { @@ -145,6 +149,27 @@ class EnforceIdentityPolicyListener implements HookListenerInterface ]; } + /** + * 전담 리스너가 강제를 소유하는 hook target 목록. + * + * 이 리스너는 "행위 직전(before_*)" 훅을 막아 세우는 가드다. 행위가 이미 끝난 뒤 발화하는 + * 훅에서 예외를 던지면 되돌릴 것이 없는 상태에서 응답만 깨진다. + * + * `core.auth.after_register` 가 그 경우다. 이 훅은 `InitiateIdentityChallengeAfterRegister` + * 가 소유하며, `core.auth.signup_after_create` 정책이 켜져 있으면 계정을 '인증 대기' 로 두고 + * 본인확인 challenge 를 발행한다(가입 자체는 성립). 여기서 다시 enforce 하면 가입이 428 로 + * 막혀 '가입 전 본인확인'(`core.auth.signup_before_submit`) 정책과 동작이 같아지고, + * '가입 후' 정책은 켜는 순간 아무도 가입할 수 없게 된다. + * + * @return list 전담 소유 hook target 목록 + */ + protected static function delegatedHookTargets(): array + { + return [ + 'core.auth.after_register', + ]; + } + /** * identity_policies 테이블에서 scope='hook' 정책의 target 목록을 추출합니다. * @@ -175,6 +200,12 @@ class EnforceIdentityPolicyListener implements HookListenerInterface return; } + // 전담 리스너가 소유하는 훅은 구독 단계에서 제외되지만, 다른 경로로 호출되더라도 + // 이중 강제가 생기지 않도록 여기서도 막는다 (delegatedHookTargets 주석 참조). + if (in_array($hookName, static::delegatedHookTargets(), true)) { + return; + } + $policies = $this->policyRepository->resolveByScopeTarget('hook', $hookName); if ($policies->isEmpty()) { return; diff --git a/app/Listeners/UserLogin/HandleFailedLoginListener.php b/app/Listeners/UserLogin/HandleFailedLoginListener.php index d761a733..02a98d2d 100644 --- a/app/Listeners/UserLogin/HandleFailedLoginListener.php +++ b/app/Listeners/UserLogin/HandleFailedLoginListener.php @@ -47,7 +47,7 @@ class HandleFailedLoginListener implements HookListenerInterface * 로그인 실패 시 카운트 증가 및 임계 도달 시 잠금 처리. * * @param string $email 실패한 로그인 이메일 - * @param array $context IP/UA/시각 등 부가 정보 + * @param array $context IP/UA/시각 등 부가 정보 */ public function handleFailed(string $email, array $context = []): void { @@ -79,12 +79,14 @@ class HandleFailedLoginListener implements HookListenerInterface $user ); + // 잠금 시간 0(= 무한대) 은 영구 잠금이라 해제 시각이 null 이다. $lockedUntil = $this->userRepository->lockAccount($user, $lockoutMinutes); HookManager::doAction('core.auth.account_locked', $user, array_merge($context, [ 'attempts' => $newCount, 'locked_until' => $lockedUntil, 'lockout_minutes' => $lockoutMinutes, + 'permanent' => $lockedUntil === null, ])); } } diff --git a/app/Models/User.php b/app/Models/User.php index 0e5832ee..7a7dfd2b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,24 +3,25 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Contracts\UniqueIdServiceInterface; use App\Enums\MenuPermissionType; use App\Enums\PermissionType; use App\Enums\ScopeType; use App\Enums\UserStatus; +use Database\Factories\UserFactory; +use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphOne; -use App\Contracts\UniqueIdServiceInterface; -use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements HasLocalePreference { - /** @use HasFactory<\Database\Factories\UserFactory> */ + /** @use HasFactory */ use HasApiTokens, HasFactory, Notifiable; /** @var array 활동 로그 추적 필드 */ @@ -31,7 +32,7 @@ class User extends Authenticatable implements HasLocalePreference 'language' => ['label_key' => 'activity_log.fields.language', 'type' => 'text'], 'timezone' => ['label_key' => 'activity_log.fields.timezone', 'type' => 'text'], 'country' => ['label_key' => 'activity_log.fields.country', 'type' => 'text'], - 'status' => ['label_key' => 'activity_log.fields.status', 'type' => 'enum', 'enum' => \App\Enums\UserStatus::class], + 'status' => ['label_key' => 'activity_log.fields.status', 'type' => 'enum', 'enum' => UserStatus::class], 'is_super' => ['label_key' => 'activity_log.fields.is_super', 'type' => 'boolean'], 'homepage' => ['label_key' => 'activity_log.fields.homepage', 'type' => 'text'], 'mobile' => ['label_key' => 'activity_log.fields.mobile', 'type' => 'text'], @@ -108,6 +109,7 @@ class User extends Authenticatable implements HasLocalePreference 'mobile_verified_at', 'failed_login_attempts', 'locked_until', + 'locked_permanently', 'last_failed_login_at', ]; @@ -140,6 +142,7 @@ class User extends Authenticatable implements HasLocalePreference 'is_super' => 'boolean', 'failed_login_attempts' => 'integer', 'locked_until' => 'datetime', + 'locked_permanently' => 'boolean', 'last_failed_login_at' => 'datetime', ]; } @@ -196,7 +199,7 @@ class User extends Authenticatable implements HasLocalePreference /** * 사용자가 생성한 모듈들과의 관계를 정의합니다. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function modules() { @@ -206,7 +209,7 @@ class User extends Authenticatable implements HasLocalePreference /** * 사용자가 생성한 플러그인들과의 관계를 정의합니다. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function plugins() { @@ -388,8 +391,8 @@ class User extends Authenticatable implements HasLocalePreference /** * 슈퍼 관리자만 조회합니다. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder + * @param Builder $query + * @return Builder */ public function scopeSuperAdmins($query) { @@ -445,7 +448,7 @@ class User extends Authenticatable implements HasLocalePreference /** * 사용자가 생성한 메뉴들과의 관계를 정의합니다. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function menus() { diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 4a9ac0b4..94a49f6d 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Repositories\JsonConfigRepository; +use App\Support\AllowedExtensions; use Illuminate\Support\Facades\Config; use Illuminate\Support\ServiceProvider; @@ -652,12 +653,27 @@ class SettingsServiceProvider extends ServiceProvider Config::set('filesystems.default', $uploadSettings['disk']); } - if (! empty($uploadSettings['max_size'])) { - Config::set('g7.upload.max_size', (int) $uploadSettings['max_size']); + // 관리자 설정은 MB, config/attachment.* 는 KB — 변환은 이 지점 단 한 곳에서만 수행한다. + // (기존에는 존재하지 않는 키 `max_size` 를 읽어 설정이 어디에도 반영되지 않았다) + if (! empty($uploadSettings['max_file_size'])) { + Config::set('attachment.max_file_size', (int) $uploadSettings['max_file_size'] * 1024); } - if (! empty($uploadSettings['allowed_extensions'])) { - Config::set('g7.upload.allowed_extensions', $uploadSettings['allowed_extensions']); + // 화면은 콤마 문자열을 보내고 API 직접 호출은 배열을 보낸다. 배열만 받아들이면 + // 문자열로 저장된 설정이 조용히 버려져 관리자가 확장자를 바꿔도 반영되지 않는다. + if (isset($uploadSettings['allowed_extensions'])) { + Config::set( + 'attachment.allowed_extensions', + AllowedExtensions::normalize($uploadSettings['allowed_extensions']) + ); + } + + // 이미지 축소 한계값·품질 — 픽셀/퍼센트라 단위 변환이 없다. + // 미설정(빈 값)은 "축소하지 않음" 이므로 그대로 넘겨 ImageResizer 가 판정하게 둔다. + foreach (['image_max_width', 'image_max_height', 'image_quality'] as $key) { + if (isset($uploadSettings[$key]) && $uploadSettings[$key] !== '') { + Config::set("attachment.{$key}", (int) $uploadSettings[$key]); + } } } } diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php index cf24e644..c07b2f8b 100644 --- a/app/Repositories/UserRepository.php +++ b/app/Repositories/UserRepository.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Carbon; +use Laravel\Sanctum\PersonalAccessToken; class UserRepository implements UserRepositoryInterface { @@ -294,16 +295,22 @@ class UserRepository implements UserRepositoryInterface /** * 사용자의 계정을 지정된 분만큼 잠급니다. * + * `$minutes <= 0` 은 보안 환경설정의 "0 = 무한대" 규약에 따라 영구 잠금으로 처리합니다. + * 영구 잠금은 해제 시각이 없으므로 `locked_until` 을 NULL 로 두고 + * `locked_permanently` 플래그로 상태를 표현합니다 (NULL 은 이미 "잠금 없음" 을 의미). + * * @param User $user 잠글 사용자 - * @param int $minutes 잠금 유지 시간(분) - * @return Carbon 잠금 해제 시각 + * @param int $minutes 잠금 유지 시간(분). 0 이하는 무기한 + * @return Carbon|null 잠금 해제 시각 (영구 잠금은 null) */ - public function lockAccount(User $user, int $minutes): Carbon + public function lockAccount(User $user, int $minutes): ?Carbon { - $lockedUntil = now()->addMinutes(max(1, $minutes)); + $permanent = $minutes <= 0; + $lockedUntil = $permanent ? null : now()->addMinutes($minutes); $user->forceFill([ 'locked_until' => $lockedUntil, + 'locked_permanently' => $permanent, 'failed_login_attempts' => 0, ])->save(); @@ -321,15 +328,18 @@ class UserRepository implements UserRepositoryInterface { $needsReset = ($user->failed_login_attempts ?? 0) > 0 || $user->locked_until !== null + || (bool) $user->locked_permanently || $user->last_failed_login_at !== null; if (! $needsReset) { return; } + // locked_permanently 를 빠뜨리면 성공 로그인·관리자 해제 후에도 영구 잠금이 잔존한다. $user->forceFill([ 'failed_login_attempts' => 0, 'locked_until' => null, + 'locked_permanently' => false, 'last_failed_login_at' => null, ])->save(); } @@ -342,10 +352,74 @@ class UserRepository implements UserRepositoryInterface */ public function isLocked(User $user): bool { + // 영구 잠금 판정이 먼저 — 해제 시각이 없으므로 NULL 체크에 먼저 걸리면 안 된다. + if ((bool) $user->locked_permanently) { + return true; + } + if ($user->locked_until === null) { return false; } return $user->locked_until->isFuture(); } + + /** + * UUID 로 사용자를 찾습니다. + * + * @param string $uuid 사용자 UUID + * @return User|null 찾은 사용자 모델 또는 null + */ + public function findByUuid(string $uuid): ?User + { + return User::where('uuid', $uuid)->first(); + } + + /** + * UUID 목록에 해당하는 사용자의 정수 ID 배열을 반환합니다. + * + * @param array $uuids 사용자 UUID 배열 + * @return array 사용자 ID 배열 + */ + public function getIdsByUuids(array $uuids): array + { + if (empty($uuids)) { + return []; + } + + return User::whereIn('uuid', $uuids)->pluck('id')->all(); + } + + /** + * 사용자 ID 목록의 지정 컬럼을 일괄 갱신합니다. + * + * @param array $ids 사용자 ID 배열 + * @param array $data 갱신할 컬럼 값 + * @return int 갱신된 행 수 + */ + public function updateManyByIds(array $ids, array $data): int + { + if (empty($ids)) { + return 0; + } + + return User::whereIn('id', $ids)->update($data); + } + + /** + * 사용자 ID 목록의 인증 토큰을 모두 삭제합니다. + * + * @param array $ids 사용자 ID 배열 + * @return int 삭제된 토큰 수 + */ + public function deleteTokensByUserIds(array $ids): int + { + if (empty($ids)) { + return 0; + } + + return PersonalAccessToken::where('tokenable_type', User::class) + ->whereIn('tokenable_id', $ids) + ->delete(); + } } diff --git a/app/Rules/PasswordPolicy.php b/app/Rules/PasswordPolicy.php new file mode 100644 index 00000000..a6a9e55f --- /dev/null +++ b/app/Rules/PasswordPolicy.php @@ -0,0 +1,46 @@ + 0 ? $minLength : self::DEFAULT_MIN_LENGTH; + + $rule = Password::min($minLength); + + if ((bool) g7_core_settings('security.require_password_special_char', false)) { + $rule = $rule->symbols(); + } + + return $rule; + } +} diff --git a/app/Services/AttachmentService.php b/app/Services/AttachmentService.php index 3ac265c0..540a4c46 100644 --- a/app/Services/AttachmentService.php +++ b/app/Services/AttachmentService.php @@ -7,6 +7,8 @@ use App\Contracts\Repositories\AttachmentRepositoryInterface; use App\Enums\AttachmentSourceType; use App\Extension\HookManager; use App\Models\Attachment; +use App\Models\User; +use App\Support\ImageResizer; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; @@ -27,10 +29,12 @@ class AttachmentService * * @param AttachmentRepositoryInterface $repository 첨부파일 리포지토리 * @param StorageInterface $storage 스토리지 드라이버 + * @param ImageResizer $imageResizer 업로드 이미지 축소기 */ public function __construct( private AttachmentRepositoryInterface $repository, - private StorageInterface $storage + private StorageInterface $storage, + private ImageResizer $imageResizer ) {} /** @@ -58,8 +62,15 @@ class AttachmentService // 필터 훅 - 파일 데이터 변형 (압축, 리사이즈 등 확장 포인트) $file = HookManager::applyFilters('core.attachment.filter_upload_file', $file); - // 저장 경로 생성 (날짜별 디렉토리) - $storedFilename = Str::uuid().'.'.$file->getClientOriginalExtension(); + // 환경설정 > 업로드의 최대 가로/세로·품질을 실제로 적용한다. + // 임시 파일을 제자리에서 줄이므로 아래의 저장·크기·메타 계산이 모두 축소본을 본다. + $this->imageResizer->resizeInPlace($file->getRealPath(), $file->getMimeType()); + + // 저장 경로 생성 (날짜별 디렉토리). + // 확장자는 클라이언트가 보낸 파일명이 아니라 MIME 추론값을 우선 사용한다 — + // 파일명 확장자는 사용자가 임의로 바꿀 수 있어 저장 확장자의 근거가 될 수 없다. + $extension = strtolower($file->extension() ?: $file->getClientOriginalExtension()); + $storedFilename = Str::uuid().($extension !== '' ? '.'.$extension : ''); $datePath = date('Y/m/d'); $path = "{$datePath}/{$storedFilename}"; @@ -217,7 +228,7 @@ class AttachmentService * 기능 레벨 권한 체크 (permission_hooks) - 다운로드 기능 자체에 대한 접근 권한 * * @param string $hash 첨부파일 해시 - * @param \App\Models\User|null $user 요청한 사용자 (null이면 비로그인) + * @param User|null $user 요청한 사용자 (null이면 비로그인) * @return StreamedResponse|null 다운로드 응답 또는 null * * @throws AuthorizationException 기능 레벨 권한이 없는 경우 @@ -263,7 +274,7 @@ class AttachmentService * 컨트롤러에서 fileResponse()로 캐싱 헤더와 함께 응답할 수 있습니다. * * @param string $hash 첨부파일 해시 - * @param \App\Models\User|null $user 요청한 사용자 (null이면 비로그인) + * @param User|null $user 요청한 사용자 (null이면 비로그인) * @return array{path: string, mime_type: string, filename: string}|null 파일 정보 또는 null * * @throws AuthorizationException 기능 레벨 권한이 없는 경우 diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php index 0bedfaed..3bc9e0ab 100644 --- a/app/Services/AuthService.php +++ b/app/Services/AuthService.php @@ -7,6 +7,8 @@ use App\Contracts\Repositories\RoleRepositoryInterface; use App\Contracts\Repositories\UserConsentRepositoryInterface; use App\Contracts\Repositories\UserRepositoryInterface; use App\Enums\ConsentType; +use App\Enums\IdentityVerificationPurpose; +use App\Enums\IdentityVerificationStatus; use App\Enums\UserStatus; use App\Exceptions\Auth\AccountLockedException; use App\Extension\HookManager; @@ -14,6 +16,7 @@ use App\Models\User; use Carbon\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Laravel\Sanctum\PersonalAccessToken; @@ -99,7 +102,11 @@ class AuthService if ((bool) g7_core_settings('security.login_attempt_enabled', true)) { $candidate = $this->userRepository->findByEmail($email); if ($candidate !== null && $this->userRepository->isLocked($candidate)) { - $remaining = max(1, (int) ceil(now()->diffInSeconds($candidate->locked_until, false) / 60)); + // 영구 잠금은 해제 시각이 없다 — diffInSeconds(null) 로 폭발하지 않도록 분기. + $remaining = $candidate->locked_until === null + ? null + : max(1, (int) ceil(now()->diffInSeconds($candidate->locked_until, false) / 60)); + throw new AccountLockedException( lockedUntil: $candidate->locked_until, remainingMinutes: $remaining, @@ -138,6 +145,142 @@ class AuthService ]); } + // 2단계 인증이 켜져 있으면 여기서 토큰을 발급하지 않는다. + // 비밀번호 확인만으로 세션이 열리면 2단계가 없는 것과 같으므로, 인증 코드를 확인할 + // 때까지 로그인 상태를 만들지 않고 challenge 만 돌려준다. + if ($this->isTwoFactorRequired($user)) { + Auth::logout(); + + return $this->startTwoFactorChallenge($user, $email); + } + + return $this->issueLoginSession($user, $email); + } + + /** + * 이 사용자에게 2단계 인증을 요구해야 하는지 판정합니다. + * + * @param User $user 로그인 시도 사용자 + * @return bool 2단계 인증 필요 여부 + */ + private function isTwoFactorRequired(User $user): bool + { + if (! (bool) g7_core_settings('security.two_factor_auth', false)) { + return false; + } + + // 코드를 받을 수단이 없으면 요구할 수 없다 — 요구하면 그 계정은 영구히 잠긴다. + return filled($user->email); + } + + /** + * 2단계 인증 challenge 를 발행합니다. + * + * @param User $user 대상 사용자 + * @param string $email 로그인에 사용한 이메일 + * @return array{two_factor_required: bool, challenge_id: string, provider_id: string, expires_at: mixed} challenge 정보 + */ + private function startTwoFactorChallenge(User $user, string $email): array + { + $identity = app(IdentityVerificationService::class); + + $challenge = $identity->start( + IdentityVerificationPurpose::Login->value, + $user, + [ + 'origin_type' => 'route', + 'origin_identifier' => 'auth.login', + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ] + ); + + // 코드를 보내지 못하면 사용자는 완료할 수 없는 challenge 를 들고 막다른 길에 선다. + // "인증번호를 보냈습니다" 로 안내하면 원인을 알 방법이 없으므로, 발송 실패는 실패로 알린다. + // 이때 2단계 인증을 건너뛰고 로그인시키지는 않는다 — 보안 통제가 조용히 열리면 + // 메일 설정이 깨진 동안 2단계 인증이 없는 것과 같아진다. + if (($identity->getStatus($challenge->id)['status'] ?? null) === IdentityVerificationStatus::Failed->value) { + Log::error('2단계 인증 코드 발송 실패 — 로그인을 차단합니다', [ + 'user_id' => $user->id, + 'challenge_id' => $challenge->id, + 'provider_id' => $challenge->providerId, + ]); + + throw ValidationException::withMessages([ + 'email' => [__('auth.two_factor_delivery_failed')], + ]); + } + + HookManager::doAction('core.auth.two_factor_requested', $user, [ + 'email' => $email, + 'challenge_id' => $challenge->id, + 'ip_address' => request()->ip(), + ]); + + return [ + 'two_factor_required' => true, + 'challenge_id' => $challenge->id, + 'provider_id' => $challenge->providerId, + 'expires_at' => $challenge->expiresAt ?? null, + ]; + } + + /** + * 2단계 인증 코드를 확인하고 로그인을 완료합니다. + * + * @param string $challengeId challenge UUID + * @param array $input 프로바이더 입력 (코드 등) + * @return array{user: User, token: string, token_type: string} 로그인 결과 + * + * @throws ValidationException 검증 실패 또는 challenge 불일치 + */ + public function completeTwoFactor(string $challengeId, array $input): array + { + $identity = app(IdentityVerificationService::class); + + $status = $identity->getStatus($challengeId); + + // 이 흐름 전용 challenge 인지 먼저 확인한다. purpose 를 검사하지 않으면 다른 용도로 + // 발급된 challenge(가입·비밀번호 재설정 등)를 들고 와 로그인할 수 있다. + if (($status['purpose'] ?? null) !== IdentityVerificationPurpose::Login->value) { + throw ValidationException::withMessages([ + 'challenge_id' => [__('auth.two_factor_invalid_challenge')], + ]); + } + + $result = $identity->verify($challengeId, $input, [ + 'origin_type' => 'route', + 'origin_identifier' => 'auth.login', + ]); + + if (! $result->success) { + throw ValidationException::withMessages([ + 'code' => [__('auth.two_factor_failed')], + ]); + } + + $user = $identity->resolveVerifiedUser($challengeId, IdentityVerificationPurpose::Login->value); + + if (! $user || $user->status !== UserStatus::Active->value) { + throw ValidationException::withMessages([ + 'challenge_id' => [__('auth.two_factor_invalid_challenge')], + ]); + } + + Auth::login($user); + + return $this->issueLoginSession($user, (string) $user->email); + } + + /** + * 토큰을 발급하고 로그인 완료 훅을 실행합니다. + * + * @param User $user 로그인 사용자 + * @param string $email 로그인에 사용한 이메일 + * @return array{user: User, token: string, token_type: string} 로그인 결과 + */ + private function issueLoginSession(User $user, string $email): array + { $token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken; // 세션에 사용자 저장 (/dev 대시보드 인증용) diff --git a/app/Services/IdentityVerificationService.php b/app/Services/IdentityVerificationService.php index 0b25714b..4a7cee70 100644 --- a/app/Services/IdentityVerificationService.php +++ b/app/Services/IdentityVerificationService.php @@ -182,6 +182,7 @@ class IdentityVerificationService * * @param string $challengeId Challenge UUID * @return array|null 공개 상태 또는 null (없는 경우) + * * @since engine-v1.46.0 */ public function getStatus(string $challengeId): ?array @@ -205,12 +206,43 @@ class IdentityVerificationService 'purpose' => $log->purpose, 'render_hint' => $log->render_hint, 'expires_at' => optional($log->expires_at)->toIso8601String(), - 'attempts' => (int) $log->attempts, + // 시도 횟수(attempts)는 공개 폴링 응답에 싣지 않는다 — challenge id 만 알면 누구나 + // 조회할 수 있는 경로라, 남의 인증 시도가 몇 번 실패했는지가 그대로 드러난다. + // 화면의 '남은 시도 횟수' 는 모달이 자기 시도를 세어 표시하므로 영향이 없다. 'max_attempts' => (int) $log->max_attempts, 'public_payload' => $publicPayload, ]; } + /** + * 지정 purpose 로 검증을 마친 challenge 의 대상 사용자를 반환합니다. + * + * 로그인 2단계 인증처럼 **아직 인증되지 않은 주체**를 challenge 로만 식별해야 하는 흐름에서 + * 사용합니다. `getStatus()` 는 challenge id 만 알면 누구나 조회할 수 있는 공개 폴링 경로라 + * `user_id` 를 싣지 않으므로, 주체 해석은 이 메서드로 분리합니다. + * + * purpose 를 인자로 받아 대조하는 이유: 다른 용도(가입·비밀번호 재설정)로 발급된 challenge 를 + * 들고 와 로그인하는 것을 막기 위함입니다. + * + * @param string $challengeId Challenge UUID + * @param string $purpose 기대하는 purpose + * @return User|null 검증 완료된 대상 사용자 (조건 불일치 시 null) + */ + public function resolveVerifiedUser(string $challengeId, string $purpose): ?User + { + $log = $this->logRepository->findById($challengeId); + + if (! $log || $log->purpose !== $purpose || $log->verified_at === null) { + return null; + } + + if ($log->user_id === null) { + return null; + } + + return $this->userRepository->findById((int) $log->user_id); + } + /** * 외부 IDV provider 의 redirect 콜백을 처리합니다. * @@ -222,6 +254,7 @@ class IdentityVerificationService * @param array $input provider 가 보낸 페이로드 (code/token/state 등) * @param array $context origin 정보 (origin_type=callback) * @return VerificationResult provider 의 검증 결과 (challenge mismatch 시 failure) + * * @since engine-v1.46.0 */ public function handleProviderCallback(string $providerId, string $challengeId, array $input, array $context = []): VerificationResult diff --git a/app/Services/TemplateLayoutAttachmentService.php b/app/Services/TemplateLayoutAttachmentService.php index d9596476..3506d6d0 100644 --- a/app/Services/TemplateLayoutAttachmentService.php +++ b/app/Services/TemplateLayoutAttachmentService.php @@ -6,6 +6,7 @@ use App\Contracts\Extension\StorageInterface; use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface; use App\Contracts\Repositories\TemplateRepositoryInterface; use App\Models\TemplateLayoutAttachment; +use App\Support\ImageResizer; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Auth; @@ -56,6 +57,11 @@ class TemplateLayoutAttachmentService $relativePath = "{$templateIdentifier}/".date('Y/m/d')."/{$storedFilename}"; $disk = config('attachment.disk', 'attachments'); + + // 환경설정 > 업로드의 최대 가로/세로·품질 적용 (코어 설정이 모든 업로드 경로에 동일 적용). + // 임시 파일을 제자리에서 줄이므로 아래의 저장·크기 기록이 모두 축소본을 본다. + app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType()); + $stored = $this->storage ->withDisk($disk) ->put(self::STORAGE_CATEGORY, $relativePath, file_get_contents($file->getRealPath())); diff --git a/app/Services/TemplateService.php b/app/Services/TemplateService.php index eb52e067..0e73bd19 100644 --- a/app/Services/TemplateService.php +++ b/app/Services/TemplateService.php @@ -24,6 +24,11 @@ class TemplateService { use ResolvesLanguageFragments; + /** + * 직전 라우트 병합에서 활성 모듈 디렉토리가 비어 라우트가 누락됐는지 여부 + */ + private bool $routeMergeDegraded = false; + public function __construct( private TemplateRepositoryInterface $templateRepository, private TemplateManagerInterface $templateManager, @@ -1074,6 +1079,8 @@ class TemplateService */ public function getRoutesDataWithModules(string $identifier): array { + $this->routeMergeDegraded = false; + // 1. 템플릿 DB 조회 및 활성화 여부 확인 $template = $this->templateRepository->findByIdentifier($identifier); if (! $template || $template->status !== ExtensionStatus::Active->value) { @@ -1172,9 +1179,22 @@ class TemplateService 'success' => true, 'data' => $resultData, 'error' => null, + 'degraded' => $this->routeMergeDegraded, ]; } + /** + * 직전 라우트 병합이 열화 상태였는지 여부를 반환합니다. + * + * 활성 모듈의 디렉토리가 없어 그 모듈 라우트가 통째로 빠진 경우 true. + * + * @return bool 열화 여부 + */ + public function lastRouteMergeWasDegraded(): bool + { + return $this->routeMergeDegraded; + } + /** * 레이아웃 편집기용 routes 데이터를 source 태깅과 함께 반환합니다. * @@ -1653,6 +1673,15 @@ class TemplateService } if ($routesFilePath === null) { + // 활성 모듈의 디렉토리 자체가 없다면 업데이트 중 활성 디렉토리가 잠시 비운 + // 상태다(정상적으로 admin 라우트가 없는 모듈과 구분된다). 이때의 병합 결과는 + // 그 모듈의 화면이 통째로 빠진 열화 스냅샷이므로 호출자가 캐시하지 않도록 + // 표시한다. 표시 없이 캐시되면 업데이트가 끝난 뒤에도 캐시가 만료될 때까지 + // 해당 모듈의 모든 화면이 404 로 남는다. + if (! is_dir(base_path("modules/{$moduleIdentifier}"))) { + $this->routeMergeDegraded = true; + } + continue; } diff --git a/app/Services/UserService.php b/app/Services/UserService.php index f0624912..4075254e 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -2,18 +2,17 @@ namespace App\Services; +use App\Contracts\Repositories\RoleRepositoryInterface; use App\Contracts\Repositories\UserRepositoryInterface; use App\Enums\UserStatus; use App\Exceptions\CannotDeleteSuperAdminException; use App\Extension\HookManager; use App\Helpers\PermissionHelper; -use App\Models\ActivityLog; use App\Helpers\TimezoneHelper; -use App\Models\Role; +use App\Models\ActivityLog; use App\Models\User; use Exception; use Illuminate\Contracts\Pagination\LengthAwarePaginator; -use Laravel\Sanctum\PersonalAccessToken; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -24,6 +23,7 @@ class UserService { public function __construct( private UserRepositoryInterface $userRepository, + private RoleRepositoryInterface $roleRepository, private AttachmentService $attachmentService ) {} @@ -79,7 +79,7 @@ class UserService // 역할 할당 권한 체크: core.permissions.update 권한 없으면 기본 역할 자동 할당 if (! PermissionHelper::check('core.permissions.update')) { - $defaultRoleId = Role::where('identifier', 'user')->value('id'); + $defaultRoleId = $this->roleRepository->findByIdentifier('user')?->id; $roleIds = $defaultRoleId ? [$defaultRoleId] : null; } @@ -156,7 +156,7 @@ class UserService // 자기잠금 방지: 마지막 admin 역할 사용자가 자기 admin 역할을 제거하려는 경우 차단 if ($roleIds !== null && $authUser && $authUser->id === $user->id) { - $adminRole = Role::where('identifier', 'admin')->first(); + $adminRole = $this->roleRepository->findByIdentifier('admin'); if ($adminRole && $user->roles->contains('id', $adminRole->id) && ! in_array($adminRole->id, $roleIds)) { // admin 역할을 가진 다른 사용자가 있는지 확인 $otherAdminCount = $adminRole->users()->where('users.id', '!=', $user->id)->count(); @@ -372,7 +372,7 @@ class UserService */ public function getUserByUuid(string $uuid): ?User { - $user = User::where('uuid', $uuid)->first(); + $user = $this->userRepository->findByUuid($uuid); if ($user) { HookManager::doAction('core.user.after_show', $user); @@ -486,9 +486,9 @@ class UserService /** * 사용자의 활동 로그를 조회합니다. * - * @param int $userId 사용자 ID - * @param int $limit 조회 건수 - * @return \Illuminate\Support\Collection + * @param int $userId 사용자 ID + * @param int $limit 조회 건수 + * @return \Illuminate\Support\Collection 활동 로그 요약 컬렉션 */ public function getUserActivityLogs(int $userId, int $limit = 50): \Illuminate\Support\Collection { @@ -506,6 +506,31 @@ class UserService ]); } + /** + * 사용자의 계정 잠금을 해제합니다 (관리자 수동 해제). + * + * 로그인 시도 추적 컬럼을 모두 초기화합니다. 영구 잠금(`locked_permanently`)도 함께 + * 해제되며, 이 경로가 없으면 잠금 시간 0(무한대) 으로 잠긴 계정은 성공 로그인 자체가 + * 불가하므로 DB 직접 조작 외에 복구 수단이 없습니다. + * + * @param User $user 잠금을 해제할 사용자 + * @return User 갱신된 사용자 모델 + */ + public function unlockAccount(User $user): User + { + HookManager::doAction('core.user.before_unlock', $user); + + $this->userRepository->resetLoginAttempts($user); + + $user = $user->fresh(); + + HookManager::doAction('core.auth.account_unlocked', $user, [ + 'ip_address' => request()->ip(), + ]); + + return $user; + } + /** * 사용자의 마지막 로그인 시간을 현재 시간으로 업데이트합니다. * @@ -552,7 +577,7 @@ class UserService $statusEnum = UserStatus::from($status); // UUID → 정수 ID 변환 (내부 쿼리용) - $userIds = User::whereIn('uuid', $uuids)->pluck('id')->toArray(); + $userIds = $this->userRepository->getIdsByUuids($uuids); // DB 트랜잭션으로 일괄 업데이트 $updatedCount = DB::transaction(function () use ($userIds, $statusEnum) { @@ -565,13 +590,11 @@ class UserService UserStatus::Inactive => $updateData, }; - $count = User::whereIn('id', $userIds)->update($updateData); + $count = $this->userRepository->updateManyByIds($userIds, $updateData); // Active 외 상태로 변경 시 해당 사용자들의 토큰 전체 삭제 if ($statusEnum !== UserStatus::Active) { - PersonalAccessToken::where('tokenable_type', User::class) - ->whereIn('tokenable_id', $userIds) - ->delete(); + $this->userRepository->deleteTokensByUserIds($userIds); } return $count; diff --git a/app/Support/AllowedExtensions.php b/app/Support/AllowedExtensions.php new file mode 100644 index 00000000..7fdd9708 --- /dev/null +++ b/app/Support/AllowedExtensions.php @@ -0,0 +1,49 @@ + 정규화된 확장자 목록 (빈 배열 = 제한 없음) + */ + public static function normalize(mixed $value): array + { + if (is_string($value)) { + $value = explode(',', $value); + } + + if (! is_array($value)) { + return []; + } + + $normalized = []; + + foreach ($value as $extension) { + if (! is_string($extension) && ! is_numeric($extension)) { + continue; + } + + $extension = strtolower(ltrim(trim((string) $extension), '.')); + + if ($extension === '' || in_array($extension, $normalized, true)) { + continue; + } + + $normalized[] = $extension; + } + + return $normalized; + } +} diff --git a/app/Support/ImageResizer.php b/app/Support/ImageResizer.php new file mode 100644 index 00000000..77d345a9 --- /dev/null +++ b/app/Support/ImageResizer.php @@ -0,0 +1,187 @@ + 업로드) + * + * `upload.image_max_width` / `image_max_height` / `image_quality` 는 저장·검증만 되고 + * 실제로 이미지를 줄이는 코드가 없었습니다. 관리자는 값을 바꾸면 적용된다고 인지하지만 + * 원본이 그대로 저장돼, 설정 화면과 실제 동작이 어긋나 있었습니다. + * + * 단위 규약: config/attachment.image_* 는 픽셀, `image_quality` 는 1~100 입니다. + * MB↔KB 처럼 변환이 필요한 값이 아니므로 `SettingsServiceProvider` 가 그대로 옮깁니다. + * + * 한계값이 없으면(설정 미지정) 아무 것도 하지 않습니다 — 기존 동작을 유지해, + * 이 기능이 도입됐다는 이유만으로 기존 사이트의 이미지가 갑자기 줄어들지 않도록 합니다. + */ +class ImageResizer +{ + /** + * 축소를 지원하는 MIME 타입 → GD 로더/세이버 매핑 + */ + private const SUPPORTED = [ + 'image/jpeg' => 'jpeg', + 'image/jpg' => 'jpeg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + ]; + + /** + * 기본 품질 (설정 미지정 시) + */ + private const DEFAULT_QUALITY = 85; + + /** + * 이미지가 설정 상한을 넘으면 비율을 유지한 채 파일을 제자리에서 축소합니다. + * + * 상한 이내이거나 지원하지 않는 형식이면 파일을 건드리지 않습니다 — 상한 이내인데도 + * 다시 인코딩하면 아무 이득 없이 화질만 떨어집니다. + * + * @param string $path 이미지 파일 절대 경로 (제자리 수정) + * @param string|null $mimeType 파일 MIME 타입 + * @return bool 실제로 축소했으면 true + */ + public function resizeInPlace(string $path, ?string $mimeType): bool + { + $format = self::SUPPORTED[strtolower((string) $mimeType)] ?? null; + + if ($format === null || ! extension_loaded('gd') || ! is_file($path)) { + return false; + } + + $maxWidth = $this->limit('image_max_width'); + $maxHeight = $this->limit('image_max_height'); + + if ($maxWidth === null && $maxHeight === null) { + return false; + } + + $size = @getimagesize($path); + + if ($size === false) { + return false; + } + + [$width, $height] = [(int) $size[0], (int) $size[1]]; + + if ($width <= 0 || $height <= 0) { + return false; + } + + $scale = min( + $maxWidth !== null ? $maxWidth / $width : 1.0, + $maxHeight !== null ? $maxHeight / $height : 1.0, + 1.0 + ); + + if ($scale >= 1.0) { + return false; + } + + // 반올림으로 0 이 되는 극단 비율에서도 최소 1px 은 남긴다 + $targetWidth = max(1, (int) round($width * $scale)); + $targetHeight = max(1, (int) round($height * $scale)); + + try { + return $this->write($path, $format, $targetWidth, $targetHeight); + } catch (\Throwable $e) { + // 축소 실패가 업로드 자체를 막지는 않는다 — 원본이 그대로 저장된다 + Log::warning('업로드 이미지 축소 실패 (원본을 그대로 저장합니다)', [ + 'path' => $path, + 'mime_type' => $mimeType, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * 설정된 픽셀 한계값을 반환합니다. + * + * @param string $key 설정 키 (image_max_width / image_max_height) + * @return int|null 한계값 (미설정이면 null) + */ + private function limit(string $key): ?int + { + $value = config("attachment.{$key}"); + + if ($value === null || $value === '') { + return null; + } + + $value = (int) $value; + + return $value > 0 ? $value : null; + } + + /** + * 축소한 이미지를 원본 경로에 다시 씁니다. + * + * @param string $path 대상 경로 + * @param string $format GD 포맷 (jpeg/png/gif/webp) + * @param int $targetWidth 결과 가로 + * @param int $targetHeight 결과 세로 + * @return bool 성공 여부 + */ + private function write(string $path, string $format, int $targetWidth, int $targetHeight): bool + { + $source = match ($format) { + 'jpeg' => @imagecreatefromjpeg($path), + 'png' => @imagecreatefrompng($path), + 'gif' => @imagecreatefromgif($path), + 'webp' => @imagecreatefromwebp($path), + }; + + if (! $source) { + return false; + } + + $target = imagecreatetruecolor($targetWidth, $targetHeight); + + // PNG·GIF·WebP 의 투명도를 보존한다 (미처리 시 검은 배경으로 채워진다) + if (in_array($format, ['png', 'gif', 'webp'], true)) { + imagealphablending($target, false); + imagesavealpha($target, true); + imagefill($target, 0, 0, imagecolorallocatealpha($target, 0, 0, 0, 127)); + } + + imagecopyresampled( + $target, $source, + 0, 0, 0, 0, + $targetWidth, $targetHeight, + imagesx($source), imagesy($source) + ); + + $quality = $this->quality(); + + $written = match ($format) { + 'jpeg' => imagejpeg($target, $path, $quality), + // PNG 압축 레벨은 0~9 이고 값이 클수록 더 압축된다 — 품질(1~100)과 방향이 반대다 + 'png' => imagepng($target, $path, (int) round((100 - $quality) / 100 * 9)), + 'gif' => imagegif($target, $path), + 'webp' => imagewebp($target, $path, $quality), + }; + + imagedestroy($source); + imagedestroy($target); + + return (bool) $written; + } + + /** + * 설정된 이미지 품질을 반환합니다. + * + * @return int 1~100 범위의 품질 + */ + private function quality(): int + { + $quality = (int) config('attachment.image_quality', self::DEFAULT_QUALITY); + + return max(1, min(100, $quality > 0 ? $quality : self::DEFAULT_QUALITY)); + } +} diff --git a/config/attachment.php b/config/attachment.php index c59ba559..75e64a1a 100644 --- a/config/attachment.php +++ b/config/attachment.php @@ -32,8 +32,49 @@ return [ | 최대 파일 크기 |-------------------------------------------------------------------------- | - | 업로드 가능한 최대 파일 크기 (KB 단위) + | 업로드 가능한 최대 파일 크기 (KB 단위 — Laravel `max:` 규칙과 동일 단위). + | + | 단위 규약: config/attachment.* 는 KB, 관리자 환경설정 upload.* 는 MB 다. + | MB → KB 변환은 SettingsServiceProvider::applyUploadConfig() 단 한 곳에서만 수행하며, + | 소비처(FormRequest 등)는 이 값을 변환 없이 그대로 사용한다. | */ 'max_file_size' => env('ATTACHMENT_MAX_FILE_SIZE', 10240), + + /* + |-------------------------------------------------------------------------- + | 허용 확장자 + |-------------------------------------------------------------------------- + | + | 업로드를 허용할 파일 확장자 목록 (소문자, 점 없이). + | 관리자 환경설정의 `upload.allowed_extensions` 가 저장되면 그 값으로 대체된다. + | + | 빈 배열은 "확장자 제한 없음" 을 의미한다 — 운영 중 예상치 못한 확장자로 업로드가 + | 막히는 상황의 탈출구다. 확장은 `core.attachment.allowed_extensions` 필터 훅으로 + | 목록을 조정할 수 있다. + | + */ + 'allowed_extensions' => [ + 'jpg', 'jpeg', 'png', 'gif', 'webp', + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'zip', + ], + + /* + |-------------------------------------------------------------------------- + | 이미지 축소 + |-------------------------------------------------------------------------- + | + | 업로드된 이미지가 이 크기(픽셀)를 넘으면 비율을 유지한 채 축소한다. + | 관리자 환경설정의 `upload.image_max_width` / `image_max_height` / `image_quality` + | 가 저장되면 그 값으로 대체된다. + | + | null = 축소하지 않음. 기본값을 null 로 두는 이유는, 이 기능이 도입됐다는 이유만으로 + | 기존 사이트의 업로드 이미지가 갑자기 줄어들지 않게 하기 위함이다. + | + | image_quality 는 1~100 이며 JPEG/WebP 에 그대로, PNG 에는 압축 레벨로 환산해 쓴다. + | + */ + 'image_max_width' => env('ATTACHMENT_IMAGE_MAX_WIDTH'), + 'image_max_height' => env('ATTACHMENT_IMAGE_MAX_HEIGHT'), + 'image_quality' => env('ATTACHMENT_IMAGE_QUALITY', 85), ]; diff --git a/config/core.php b/config/core.php index 561bff61..79349ccb 100644 --- a/config/core.php +++ b/config/core.php @@ -1,6 +1,82 @@ [ + // 업로드 + 'upload_max_file_size_min' => 1, + 'upload_max_file_size_max' => 1024, + 'upload_image_max_width_min' => 100, + 'upload_image_max_width_max' => 10000, + 'upload_image_max_height_min' => 100, + 'upload_image_max_height_max' => 10000, + 'upload_image_quality_min' => 1, + 'upload_image_quality_max' => 100, + + // SEO + 'seo_og_image_default_width_min' => 0, + 'seo_og_image_default_width_max' => 8000, + 'seo_og_image_default_height_min' => 0, + 'seo_og_image_default_height_max' => 8000, + 'seo_cache_ttl_min' => 60, + 'seo_cache_ttl_max' => 86400, + 'seo_sitemap_cache_ttl_min' => 3600, + 'seo_sitemap_cache_ttl_max' => 604800, + 'seo_sitemap_urls_per_file_min' => 1000, + 'seo_sitemap_urls_per_file_max' => 50000, + + // 보안 + 'security_password_min_length_min' => 6, + 'security_password_min_length_max' => 64, + 'security_auth_token_lifetime_min' => 0, + 'security_auth_token_lifetime_max' => 3600, + 'security_max_login_attempts_min' => 0, + 'security_max_login_attempts_max' => 100, + 'security_login_lockout_time_min' => 0, + 'security_login_lockout_time_max' => 1440, + + // 고급 (캐시 TTL) + 'advanced_cache_ttl_min' => 0, + 'advanced_cache_ttl_max' => 14400, + 'advanced_seo_sitemap_cache_ttl_min' => 3600, + 'advanced_seo_sitemap_cache_ttl_max' => 604800, + + // 드라이버 + 'drivers_redis_port_min' => 1, + 'drivers_redis_port_max' => 65535, + 'drivers_redis_database_min' => 0, + 'drivers_redis_database_max' => 15, + 'drivers_memcached_port_min' => 1, + 'drivers_memcached_port_max' => 65535, + 'drivers_session_lifetime_min' => 1, + 'drivers_session_lifetime_max' => 43200, + 'drivers_websocket_port_min' => 1, + 'drivers_websocket_port_max' => 65535, + 'drivers_websocket_server_port_min' => 1, + 'drivers_websocket_server_port_max' => 65535, + 'drivers_log_days_min' => 1, + 'drivers_log_days_max' => 365, + + // 본인인증 + 'identity_challenge_ttl_minutes_min' => 1, + 'identity_challenge_ttl_minutes_max' => 1440, + 'identity_max_attempts_min' => 1, + 'identity_max_attempts_max' => 20, + ], + /* |-------------------------------------------------------------------------- | 코어 권한 정의 diff --git a/database/migrations/2026_07_27_000002_add_locked_permanently_to_users_table.php b/database/migrations/2026_07_27_000002_add_locked_permanently_to_users_table.php new file mode 100644 index 00000000..8781fdf0 --- /dev/null +++ b/database/migrations/2026_07_27_000002_add_locked_permanently_to_users_table.php @@ -0,0 +1,46 @@ +boolean('locked_permanently') + ->default(false) + ->after('locked_until') + ->comment('영구 잠금 여부 (잠금 시간 0 = 무한대 설정으로 잠긴 계정)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasColumn('users', 'locked_permanently')) { + return; + } + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('locked_permanently'); + }); + } +}; diff --git a/docs/backend/api/attachments.md b/docs/backend/api/attachments.md index 279ef002..771100b6 100644 --- a/docs/backend/api/attachments.md +++ b/docs/backend/api/attachments.md @@ -35,6 +35,8 @@ | source_identifier | body | string | 아니오 | max 255 | 출처 식별자 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.attachment.upload_validation_rules`). +> +> 업로드 가능한 확장자는 환경설정의 허용 형식 목록을 따르며, 확장은 `core.attachment.allowed_extensions` 필터로 목록을 조정할 수 있습니다 (허용 목록이 비어 있으면 확장자를 제한하지 않습니다). **요청 예시** @@ -112,6 +114,8 @@ example-key | source_identifier | body | string | 아니오 | max 255 | 출처 식별자 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.attachment.upload_batch_validation_rules`). +> +> 업로드 가능한 확장자는 환경설정의 허용 형식 목록을 따르며, 확장은 `core.attachment.allowed_extensions` 필터로 목록을 조정할 수 있습니다 (허용 목록이 비어 있으면 확장자를 제한하지 않습니다). **요청 예시** diff --git a/docs/backend/api/auth.md b/docs/backend/api/auth.md index 9d824d43..76829251 100644 --- a/docs/backend/api/auth.md +++ b/docs/backend/api/auth.md @@ -1432,7 +1432,65 @@ Content-Type: application/json **설명** -일반 사용자 로그인(공개). 관리자 로그인과 달리 `isAdmin()` 검사가 없다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user` 를 반환하며 `message: auth.login_success`. 계정 잠금 시 `423 auth.account_locked` 를 잠금 해제까지 남은 정보와 함께 반환한다. 프론트 로그인 폼(`partials/auth/_register_form.json` 인접)에서 소비한다. +일반 사용자 로그인(공개). 관리자 로그인과 달리 `isAdmin()` 검사가 없다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user` 를 반환하며 `message: auth.login_success`. 계정 잠금 시 `423 auth.account_locked` 를 잠금 해제까지 남은 정보(`errors.locked_until`, `errors.retry_after_seconds`)와 함께 반환한다. 보안 환경설정의 잠금 시간이 `0`(무한대)이면 무기한 잠금이 되어 `423 auth.account_locked_permanently` 와 함께 `errors.permanent=true`, `errors.locked_until=null`, `errors.retry_after_seconds=null` 을 반환하며 `Retry-After` 헤더도 붙지 않는다. 이 경우 해제 수단은 관리자 해제 API(`POST /api/admin/users/{user}/unlock`) 뿐이다. 프론트 로그인 폼(`partials/auth/_register_form.json` 인접)에서 소비한다. + +**2단계 인증이 켜져 있는 경우**: 보안 환경설정 `security.two_factor_auth` 가 켜져 있으면 비밀번호가 맞아도 **토큰을 발급하지 않는다**. 대신 `200` 과 함께 `message: auth.two_factor_required` 및 아래 필드를 반환하며, 클라이언트는 `POST /api/auth/login/two-factor` 로 코드를 확인해야 로그인이 완료된다. + +| 필드 | 타입 | 설명 | +| --- | --- | --- | +| `two_factor_required` | boolean | 항상 `true` — 이 응답이 추가 확인 단계임을 나타낸다 | +| `challenge_id` | string(uuid) | 확인 단계에 그대로 전달할 challenge 식별자 | +| `provider_id` | string | 코드를 발송한 본인인증 프로바이더 | +| `expires_at` | string(ISO8601)\|null | challenge 만료 시각 | + +이 응답에는 `data.token` 과 `data.user` 가 없다. 토큰 존재 여부로 로그인 완료를 판정하는 클라이언트는 그대로 동작한다. + + +### POST /api/auth/login/two-factor + +- **라우트명**: `api.auth.login.two-factor` +- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@verifyTwoFactor` +- **인증/권한**: 공개 (인증 불필요 — 주체는 challenge 가 식별) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| challenge_id | body | uuid | 예 | — | 로그인 응답이 돌려준 challenge 식별자 | +| code | body | string | 예 | 4~16자 | 사용자가 받은 인증 코드 | + +**요청 예시** + +```http +POST /api/auth/login/two-factor HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json +``` + +```json +{ + "challenge_id": "0f8c2b6e-1a2b-4c3d-9e8f-7a6b5c4d3e2f", + "code": "135790" +} +``` + +**응답 필드** (`data` 내부) + +로그인 성공과 동일하다 — `token`(Sanctum Bearer) · `token_type` · `user`. + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthorized | 코드 불일치·만료·시도 횟수 초과, 또는 challenge 가 로그인 용도(`login`)가 아니거나 대상 사용자가 활성 상태가 아닌 경우 (`auth.two_factor_failed`) | +| 422 | Unprocessable Entity | `challenge_id`/`code` 형식 위반 | + +**설명** + +비밀번호 단계가 돌려준 challenge 를 확인해 로그인을 완료한다. 로그인과 동일한 요청 제한(`throttle:auth-login`)이 걸려 코드 대입 시도도 함께 억제된다. + +challenge 의 `purpose` 가 `login` 인지 먼저 대조한다 — 대조하지 않으면 회원가입·비밀번호 재설정 등 다른 흐름에서 발급된 challenge 로 로그인할 수 있다. 코드 확인에 성공하기 전에는 어떤 경우에도 토큰이 발급되지 않는다. ### POST /api/auth/logout diff --git a/docs/backend/api/identity.md b/docs/backend/api/identity.md index 9fdf8f87..8a79b952 100644 --- a/docs/backend/api/identity.md +++ b/docs/backend/api/identity.md @@ -28,7 +28,7 @@ | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | provider_id | query | string | 아니오 | max 64 | provider 식별자 | -| purpose | query | string | 아니오 | max 64 | 인증 목적 필터 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) | +| purpose | query | string | 아니오 | max 64 | 인증 목적 필터 (signup/password_reset/self_update/sensitive_action/login 또는 모듈 정의 목적) | | status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) | | channel | query | string | 아니오 | max 16 | 전송 채널 필터 (email 등 — 해당 채널로 시도한 이력만) | | origin_type | query | string | 아니오 | — | 인증 트리거 출처 유형 필터 (route/hook/policy/middleware/api/custom/system — IdentityOriginType) | @@ -66,7 +66,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | --- | --- | --- | --- | | id | string | `e6ab6cd6-cdff-46cd-b4c2-b89dfda8745b` | 기본 키 (내부 식별자) | | provider_id | string | `inicis` | provider 식별자 (연관 리소스 참조) | -| purpose | string | `sensitive_action` | 인증 목적 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) | +| purpose | string | `sensitive_action` | 인증 목적 (signup/password_reset/self_update/sensitive_action/login 또는 모듈 정의 목적) | | channel | string | `ipin` | 인증에 사용된 전송 채널 (email 등 코어 채널 또는 모듈 provider 자체 식별자) | | user_id | integer | `130` | user 식별자 (연관 리소스 참조) | | target_hash | string | `d88d36166bbeffc41eb6994e390f4715280c1…` | 인증 대상 해시 (SHA256(email\|phone) — PII 원본 저장 회피) | @@ -1806,7 +1806,7 @@ Content-Type: application/json | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| purpose | body | string | 예 | max 64 | 인증 목적 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) | +| purpose | body | string | 예 | max 64 | 인증 목적 (signup/password_reset/self_update/sensitive_action/login 또는 모듈 정의 목적) | | target | body | array | 아니오 | — | 비로그인 게스트의 인증 대상 (target.email 또는 target.phone — 로그인 사용자는 본인으로 자동 설정) | | provider_id | body | string | 아니오 | max 64 | provider 식별자 | @@ -1880,7 +1880,7 @@ _단건 응답: `data` 객체의 필드._ | id | string | `00484973-8cd3-4a1d-85f2-78361feb6f0d` | 기본 키 (내부 식별자) | | status | string | `verified` | requested\|sent\|processing\|verified\|failed\|expired\|cancelled\|policy_violation_logged | | provider_id | string | `inicis` | 프로바이더 식별자 (예: g7:core.mail, kcp) | -| purpose | string | `sensitive_action` | 인증 목적 (signup\|password_reset\|self_update\|sensitive_action\|*module-defined*) — 코어 4종은 App\Enums\IdentityVerificationPurpose enum, 모듈/플러그인은 declaredPurposes 레지스트리 | +| purpose | string | `sensitive_action` | 인증 목적 (signup\|password_reset\|self_update\|sensitive_action\|login\|*module-defined*) — 코어 5종은 App\Enums\IdentityVerificationPurpose enum, 모듈/플러그인은 declaredPurposes 레지스트리 | | render_hint | string | `text_code` | 프론트 렌더 힌트 (text_code\|link\|external_redirect) | | expires_at | string | `2026-05-12T18:14:19+00:00` | expires 일시 | | attempts | integer | `3` | 시도 횟수 | @@ -2132,7 +2132,7 @@ Accept: application/json | description | string | `신규 가입자의 이메일/전화번호 소유 확인.` | 설명 (다국어 필드는 로케일별 값 객체) | | default_provider | string | `g7:core.mail` | 이 목적에 매핑된 기본 프로바이더 ID (요청 시 provider_id 미지정이면 이 값 사용, 미설정 시 null) | | allowed_channels | array | `["mail","sms"]` | 이 목적에서 사용 가능한 전송 채널 목록 | -| source_type | string | `core` | 목적 출처 (core: 코어 기본 4종 / module / plugin — 어느 확장이 선언했는지) | +| source_type | string | `core` | 목적 출처 (core: 코어 기본 5종 / module / plugin — 어느 확장이 선언했는지) | | source_identifier | string | `core` | 출처 식별자 (목적을 선언한 확장의 identifier) | **응답 예시** @@ -2194,6 +2194,18 @@ HTTP/1.1 200 "source_type": "core", "source_identifier": "core" }, + { + "id": "login", + "label": "로그인 2단계 인증", + "description": "2단계 인증을 켰을 때 비밀번호 확인 뒤 한 단계를 더 요구.", + "default_provider": "g7:core.mail", + "allowed_channels": [ + "mail", + "sms" + ], + "source_type": "core", + "source_identifier": "core" + }, { "id": "checkout_verification", "label": "결제 시 본인 확인", @@ -2217,6 +2229,6 @@ _대표 에러 없음 (공개 조회). 인증·권한 미요구 엔드포인트 -**설명** 등록된 인증 목적(purpose) 목록을 반환합니다. 공개 엔드포인트로 인증이 필요하지 않습니다. `IdentityVerificationManager::getAllPurposes()` 로 코어 기본 4종(signup/password_reset/self_update/sensitive_action) + 활성 모듈/플러그인의 `getIdentityPurposes()` 선언 + `core.identity.purposes` 필터 훅(서드파티 동적 확장) 을 병합하며, 각 항목의 label/description 은 i18n 키·다국어 배열·평문 세 형태를 현재 로케일 문자열로 정규화해 내려줍니다. 각 목적의 기본 프로바이더·허용 채널·출처(source_type/source_identifier) 를 함께 반환하므로, 인증 UI 가 목적별 선택지와 문구를 구성할 때 사용합니다. +**설명** 등록된 인증 목적(purpose) 목록을 반환합니다. 공개 엔드포인트로 인증이 필요하지 않습니다. `IdentityVerificationManager::getAllPurposes()` 로 코어 기본 5종(signup/password_reset/self_update/sensitive_action/login) + 활성 모듈/플러그인의 `getIdentityPurposes()` 선언 + `core.identity.purposes` 필터 훅(서드파티 동적 확장) 을 병합하며, 각 항목의 label/description 은 i18n 키·다국어 배열·평문 세 형태를 현재 로케일 문자열로 정규화해 내려줍니다. 각 목적의 기본 프로바이더·허용 채널·출처(source_type/source_identifier) 를 함께 반환하므로, 인증 UI 가 목적별 선택지와 문구를 구성할 때 사용합니다. diff --git a/docs/backend/api/users.md b/docs/backend/api/users.md index 0a9afe28..e74b099b 100644 --- a/docs/backend/api/users.md +++ b/docs/backend/api/users.md @@ -853,6 +853,78 @@ HTTP/1.1 200 지정한 사용자(경로 파라미터는 UUID 로 바인딩)를 삭제합니다. 슈퍼관리자 계정은 삭제할 수 없으며 시도 시 422(`exceptions.cannot_delete_super_admin`)를 반환한다. 그 외 삭제 실패 시에는 실패 상세 사유가 담긴 422 를, 나머지 오류는 500 을 반환한다. 삭제는 Service 계층에서 관련 데이터 정리와 훅을 거쳐 처리된다. +### POST /api/admin/users/{user}/unlock + +- **라우트명**: `api.admin.users.unlock` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@unlock` +- **인증/권한**: `auth:sanctum` + `permission:core.users.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| user | path | string | 예 | — | 대상 user 의 UUID | + +본문 파라미터는 없습니다. + +**요청 예시** + +```http +POST /api/admin/users/a234c2b1-cde8-437f-b28b-23323be2b98d/unlock HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +갱신된 사용자 리소스(`UserResource`)를 반환합니다. 관리자 응답에는 잠금 상태 필드가 포함됩니다. + +| 이름 | 타입 | 용도 | +| --- | --- | --- | +| is_locked | boolean | 현재 잠금 여부 (영구 잠금이거나 `locked_until` 이 미래) | +| locked_permanently | boolean | 무기한 잠금 여부 | +| locked_until | string\|null | 잠금 해제 예정 시각 (영구 잠금이면 null) | +| failed_login_attempts | integer | 연속 로그인 실패 횟수 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "계정 잠금이 해제되었습니다.", + "data": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "is_locked": false, + "locked_permanently": false, + "locked_until": null, + "failed_login_attempts": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.users.update`)이 없는 경우 | +| 404 | Not Found | 해당 UUID 의 사용자가 없는 경우 | + +**설명** + +로그인 실패 누적으로 잠긴 계정의 잠금을 관리자가 수동으로 해제합니다. 로그인 시도 추적 값 +(`failed_login_attempts` / `locked_until` / `locked_permanently`)을 모두 초기화합니다. + +보안 환경설정의 잠금 시간(`security.login_lockout_time`)을 `0`(무한대)으로 두면 계정이 무기한 +잠기며, 이 경우 자동 해제도 성공 로그인도 불가능하므로 **이 엔드포인트가 유일한 복구 경로**입니다. +해제는 활동 로그(`auth.account_unlocked`)에 기록됩니다. + + ### GET /api/admin/users/{user} - **라우트명**: `api.admin.users.show` diff --git a/docs/backend/identity-messages.md b/docs/backend/identity-messages.md index 2b4b9928..45817083 100644 --- a/docs/backend/identity-messages.md +++ b/docs/backend/identity-messages.md @@ -112,7 +112,7 @@ IdentityMessageResolver::resolve( | 변수 | 설명 | 적용 | |------|------|------| -| `{code}` | 인증 코드 (text_code 흐름) | signup / self_update / sensitive_action | +| `{code}` | 인증 코드 (text_code 흐름) | signup / self_update / sensitive_action / login | | `{action_url}` | 검증 링크 URL (link 흐름) | password_reset | | `{expire_minutes}` | 만료까지 남은 분 | 모든 정의 | | `{purpose_label}` | 인증 목적 라벨 (다국어 해석) | 모든 정의 | @@ -136,6 +136,8 @@ IdentityMessageResolver::resolve( **시드 위치**: `database/seeders/IdentityMessageDefinitionSeeder.php` +로그인 2단계 인증(`purpose=login`)은 전용 시드가 없어 `provider_default` 템플릿으로 발송됩니다. 전용 문구가 필요하면 관리자 화면에서 `scope_type=purpose` / `scope_value=login` 정의를 추가합니다 — 목적 목록에 `login` 이 등록되어 있으므로 선택할 수 있습니다. + --- ## 클래스 계층 diff --git a/docs/backend/identity-policies.md b/docs/backend/identity-policies.md index 673a0d99..54c44ba4 100644 --- a/docs/backend/identity-policies.md +++ b/docs/backend/identity-policies.md @@ -22,7 +22,7 @@ applies_to 는 permission 기반 admin 판정 (User::isAdmin() — type='admin' | `key` | 고유 식별자 (시드 기준) | `core.auth.signup_before_submit` | | `scope` | 매칭 단위 | `route` (라우트 미들웨어) / `hook` (action 훅) | | `target` | scope 별 대상 식별자 | route name 또는 hook name | -| `purpose` | IDV 목적 | `signup` / `password_reset` / `self_update` / `sensitive_action` 등 | +| `purpose` | IDV 목적 | `signup` / `password_reset` / `self_update` / `sensitive_action` / `login` 등 | | `provider_id` | 강제할 IDV provider | `null` 이면 매니저 default | | `applies_to` | 강제 대상 사용자군 | `self` (관리자 제외) / `admin` (관리자만) / `both` | | `enabled` | 활성 여부 | bool | diff --git a/docs/backend/identity-providers.md b/docs/backend/identity-providers.md index ebe56885..89de200b 100644 --- a/docs/backend/identity-providers.md +++ b/docs/backend/identity-providers.md @@ -100,7 +100,7 @@ final class SmsIdentityProvider implements VerificationProviderInterface | --- | --- | --- | | `id` | string | challenge UUID | | `providerId` | string | provider 식별자 | -| `purpose` | string | signup / password_reset / self_update / sensitive_action / 확장 정의 | +| `purpose` | string | signup / password_reset / self_update / sensitive_action / login / 확장 정의 | | `channel` | string | email / sms / ipin / 확장 채널 | | `targetHash` | string | SHA256(식별자) | | `expiresAt` | CarbonInterface | 만료 시각 | @@ -210,7 +210,7 @@ public static function providerMatrix(): array | `target_hash` 를 raw 식별자로 저장 | 반드시 SHA256(lower(식별자)) 로 PII 보호 | | `verify()` 성공 시 `consumed_at = now()` 자동 set | 다운스트림 listener (가입/비번리셋 등) 가 사용 시점에 set | | provider 가 listener 책임을 가져가서 직접 사용자 생성 | provider 는 verify 만 담당. 사용자 생성/세션은 도메인 service | -| `purpose` 를 정책 purpose 와 다르게 임의 부여 | 정책-provider purpose 일치 (`signup`, `password_reset`, `sensitive_action`, `self_update`) | +| `purpose` 를 정책 purpose 와 다르게 임의 부여 | 정책-provider purpose 일치 (`signup`, `password_reset`, `sensitive_action`, `self_update`, `login`) | ## 8. 관련 코드 진입점 diff --git a/docs/extension/module-identity-settings.md b/docs/extension/module-identity-settings.md index 6116fe55..e0c4e13f 100644 --- a/docs/extension/module-identity-settings.md +++ b/docs/extension/module-identity-settings.md @@ -60,7 +60,7 @@ public function getIdentityPolicies(): array ### 신규 purpose 등록 -코어가 제공하는 4 종(`signup` / `password_reset` / `self_update` / `sensitive_action`) 외에 도메인 특화 purpose 를 도입하려면 `getIdentityPurposes()` 를 오버라이드합니다. +코어가 제공하는 5 종(`signup` / `password_reset` / `self_update` / `sensitive_action` / `login`) 외에 도메인 특화 purpose 를 도입하려면 `getIdentityPurposes()` 를 오버라이드합니다. ```php public function getIdentityPurposes(): array diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index dc72c475..af6e4982 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -8,10 +8,14 @@ ### Added +- 계정이 무기한 잠겼을 때의 로그인 안내와 관리자 잠금 해제 안내, 그리고 해당 활동 기록 문구의 일본어 번역 추가. +- 허용되지 않는 파일 형식을 업로드했을 때의 안내 문구 일본어 번역 추가. - 확장 식별자·메뉴 역할·활동 로그 검색 조건의 안내 문구 일본어 번역 추가 — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 모듈·플러그인 파일 업로드 시 허용되지 않는 파일 형식 안내 문구 일본어 번역 추가. - 플러그인을 GitHub 주소 또는 ZIP 파일로 설치할 때의 입력 검증 안내 문구 일본어 번역 추가 — 주소 형식이 잘못됐거나 ZIP 이 아닌 파일을 올렸을 때, 파일 용량을 초과했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 회원가입 검증 메시지의 휴대폰·전화번호 항목(`validation.auth.mobile.*`, `validation.auth.phone.*`) 일본어 번역 추가 — 회원가입 시 연락처 형식 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 환경설정 저장 시 오류 안내에 나오는 항목 이름의 일본어 번역 추가 — 보안·업로드·SEO·고급·드라이버 등 각 설정 항목이 내부 식별자 대신 일본어 항목명으로 표시됩니다. +- 로그인 2단계 인증 안내(인증번호 발송·불일치·요청 만료)와 인증번호 입력 검증 문구의 일본어 번역 추가. ## [1.0.6] - 2026-07-22 diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php index abe57b8f..31a53df8 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php @@ -31,6 +31,7 @@ return [ 'logout' => 'ログアウト', 'login_failed' => 'ログイン失敗', 'account_locked' => 'アカウントロック', + 'account_unlocked' => 'アカウントロック解除', 'request' => 'リクエスト', 'expired' => '有効期限切れ', 'verify' => '本人認証', @@ -125,6 +126,8 @@ return [ 'auth_record_consents' => '利用規約同意記録', 'auth_login_failed' => 'ログイン失敗 (:email)', 'auth_account_locked' => 'アカウントロック (:attempts回失敗、:minutes分ブロック)', + 'auth_account_locked_permanently' => 'アカウント無期限ロック (:attempts回失敗、管理者による解除が必要)', + 'auth_account_unlocked' => 'アカウントロック解除 (:email)', 'schedule_index' => 'スケジュール一覧の閲覧', 'schedule_show' => 'スケジュール詳細の閲覧 (ID: :schedule_id)', 'schedule_create' => 'スケジュールの作成 (ID: :schedule_id)', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php b/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php index 1b63d4fc..2a9c5a0b 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php @@ -20,6 +20,7 @@ return [ 'file_required' => 'ファイルを選択してください。', 'file_invalid' => '有効なファイルではありません。', 'file_max' => 'ファイルサイズは :maxMB を超えることができません。', + 'file_extension_invalid' => '許可されていないファイル形式です。許可形式: :extensions', 'files_required' => 'ファイルを選択してください。', 'files_array' => 'ファイル形式が正しくありません。', 'files_min' => '最低1つ以上のファイルを選択してください。', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php b/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php index f942a5dc..5b5c0096 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php @@ -3,6 +3,9 @@ return [ 'login_success' => 'ログインが成功しました。', 'login_failed' => '認証に失敗しました。', + 'two_factor_required' => '認証番号を送信しました。受け取った番号を入力してログインを完了してください。', + 'two_factor_failed' => '認証番号が正しくないか、有効期限が過ぎています。', + 'two_factor_invalid_challenge' => '認証リクエストが見つかりません。最初からログインし直してください。', 'register_success' => '会員登録が成功しました。', 'register_failed' => '会員登録に失敗しました。', 'logout_success' => 'ログアウトが成功しました。', @@ -32,8 +35,11 @@ return [ 'account_withdrawn' => '退会したアカウントです。', 'account_pending_verification' => '本人認証が完了していないアカウントです。メール認証を完了してください。', 'account_locked' => 'ログイン試行回数の超過によりアカウントがロックされました。:minutes分後に再度お試しください。', + 'account_locked_permanently' => 'ログイン試行回数の超過によりアカウントがロックされました。管理者にお問い合わせください。', + 'account_unlocked' => 'アカウントのロックを解除しました。', 'reset_token_invalid' => '無効なパスワードリセットトークンです。', 'reset_token_expired' => 'パスワードリセットトークンが有効期限切れです。再度リクエストしてください。', 'password_reset_email_sent' => 'パスワードリセットメールが送信されました。', 'email_send_failed' => 'メール送信に失敗しました。しばらく後に再度お試しください。', + 'two_factor_delivery_failed' => '認証番号を送信できませんでした。管理者にお問い合わせください。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php b/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php index 962182ac..ee10002c 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php @@ -61,6 +61,10 @@ return [ 'label' => '機密操作', 'description' => 'アカウント削除·管理者操作など再認証が必要な時点。', ], + 'login' => [ + 'label' => 'ログイン2段階認証', + 'description' => '2段階認証を有効にした場合、パスワード確認後にもう一段階を要求。', + ], ], 'channels' => [ 'email' => 'メール', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index e7c1a759..086c6037 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -692,6 +692,11 @@ return [ 'outside_base_directory' => '基準ディレクトリ外へのアクセスは許可されません。', ], 'auth' => [ + 'two_factor' => [ + 'challenge_required' => '認証リクエスト情報がありません。最初からログインし直してください。', + 'challenge_invalid' => '認証リクエスト情報が正しくありません。', + 'code_required' => '認証番号を入力してください。', + ], 'email' => [ 'required' => 'メールアドレスは必須です。', 'email' => '正しいメールアドレス形式ではありません。', @@ -1074,5 +1079,66 @@ return [ 'websocket_host' => 'WebSocket ホスト', 'websocket_port' => 'WebSocket ポート', 'websocket_scheme' => 'WebSocket プロトコル', + 'mailgun_endpoint' => 'Mailgun エンドポイント', + 'channels' => '通知チャンネル', + 'currency' => 'デフォルト通貨', + 'maintenance_mode' => 'メンテナンスモード', + 'asset_url_mode' => 'アセットURL方式', + 'site_logo' => 'サイトロゴ', + 'bot_user_agents' => 'ボット User-Agent 一覧', + 'bot_detection_enabled' => 'ボット検出の使用', + 'bot_detection_library_enabled' => 'ボット検出ライブラリの使用', + 'og_default_site_name' => 'OG デフォルトサイト名', + 'og_image_default_width' => 'OG 画像デフォルト幅', + 'og_image_default_height' => 'OG 画像デフォルト高さ', + 'twitter_default_card' => 'Twitter デフォルトカード種類', + 'twitter_default_site' => 'Twitter デフォルトアカウント', + 'seo_page_cache_enabled' => 'SEO ページキャッシュの使用', + 'cache_ttl' => 'SEO ページキャッシュ保持時間', + 'sitemap_enabled' => 'サイトマップの使用', + 'sitemap_cache_ttl' => 'サイトマップキャッシュ保持時間', + 'sitemap_urls_per_file' => 'サイトマップ1ファイルあたりのURL数', + 'sitemap_gzip' => 'サイトマップ圧縮', + 'sitemap_serve_stale_on_miss' => '未生成時に既存サイトマップを提供', + 'sitemap_max_urls_per_contributor' => '項目別サイトマップ最大URL数', + 'sitemap_hreflang_enabled' => 'サイトマップ代替言語リンクの使用', + 'sitemap_schedule' => 'サイトマップ自動生成の周期', + 'sitemap_schedule_time' => 'サイトマップ自動生成の時刻', + 'generator_enabled' => 'SEO ページジェネレーターの使用', + 'generator_content' => 'SEO ページジェネレーターのコンテンツ', + 'force_https' => 'HTTPS 強制適用', + 'login_attempt_enabled' => 'ログイン試行制限', + 'auth_token_lifetime' => '認証トークン有効期限', + 'max_login_attempts' => '最大ログイン試行回数', + 'login_lockout_time' => 'ログインブロック時間', + 'password_min_length' => 'パスワード最小長', + 'require_password_special_char' => 'パスワード特殊文字必須', + 'two_factor_auth' => '2段階認証', + 'allow_internal_outbound_urls' => '内部ネットワークアドレス呼び出しの許可', + 'advanced_cache_enabled' => 'キャッシュの使用', + 'layout_cache_enabled' => 'レイアウトキャッシュの使用', + 'layout_cache_ttl' => 'レイアウトキャッシュ保持時間', + 'stats_cache_enabled' => '統計キャッシュの使用', + 'stats_cache_ttl' => '統計キャッシュ保持時間', + 'seo_cache_enabled' => 'SEO キャッシュの使用', + 'seo_cache_ttl' => 'SEO キャッシュ保持時間', + 'seo_sitemap_cache_ttl' => 'SEO サイトマップキャッシュ保持時間', + 'debug_mode' => 'デバッグモード', + 'sql_query_log' => 'SQL クエリログ', + 'core_update_github_url' => 'コア更新 GitHub アドレス', + 'core_update_github_token' => 'コア更新 GitHub トークン', + 'geoip_enabled' => 'GeoIP の使用', + 'geoip_license_key' => 'GeoIP ライセンスキー', + 'geoip_auto_update_enabled' => 'GeoIP 自動更新', + 'websocket_app_id' => 'WebSocket アプリ ID', + 'websocket_app_secret' => 'WebSocket アプリ シークレット', + 'websocket_verify_ssl' => 'WebSocket SSL 証明書の検証', + 'websocket_server_host' => 'WebSocket サーバー ホスト', + 'websocket_server_port' => 'WebSocket サーバー ポート', + 'websocket_server_scheme' => 'WebSocket サーバー プロトコル', + 'search_engine_driver' => '検索エンジン ドライバー', + 'log_driver' => 'ログ ドライバー', + 'log_level' => 'ログレベル', + 'log_days' => 'ログ保持日数', ], ]; diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md index 21618688..8437ac34 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- 첨부파일 개수 상한 초과 안내 문구 일본어 번역 추가. - 게시판 대시보드 조회 안내 문구 일본어 번역 추가. ## [1.0.3] - 2026-07-22 diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php index 5329bdf4..49e239e9 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php @@ -176,6 +176,7 @@ return [ 'table_creation_failed' => '掲示板テーブルの作成に失敗しました (:table)::error', 'category_in_use' => ':category カテゴリーは :count個の投稿で使用中です。', 'board_not_found' => '掲示板が見つかりません。', + 'attachment_limit_exceeded' => '添付ファイルは最大:limit個まで登録できます。(リクエスト: :attempted個)', 'post_not_found' => '投稿が見つかりません。', 'permission_denied' => '権限がありません。', 'validation_failed' => '入力値が正しくありません。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 9b263342..fc82863e 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -8,12 +8,14 @@ ### Added +- 장바구니 수량 상한 초과, 정률 적립률 상한 초과, 판매가·가격 하한 안내 문구 일본어 번역 추가. - 쇼핑몰 환경설정·카테고리 이미지·결제 설정 안내 문구 일본어 번역 추가 — 저장/조회 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 검색 프리셋·브랜드·배송사·배송정책 목록 안내 문구 일본어 번역 추가. - 카테고리를 자기 자신이나 하위 카테고리 아래로 옮기려 할 때, 주문에 속하지 않은 옵션을 변경하려 할 때, 상품에 속하지 않은 이미지를 대표로 지정하려 할 때 나오는 안내 문구 일본어 번역 추가. ### Fixed +- 마일리지 사용 한도 초과 안내에 사용 가능한 최대 금액이 함께 표시되도록 일본어 문구를 갱신했습니다. - 주문 관리자 메모 글자 수 안내가 실제 입력 한도(2000자)와 다른 "1000자"로 표시되던 문제를 수정했습니다. ## [1.0.5] - 2026-07-19 diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php index 088c0de3..771ade99 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php @@ -67,7 +67,7 @@ return [ 'deduct_exceeds_balance' => '差し引くマイレージが残高を超えています。', 'below_min_use_amount' => '最小使用金額は:amountポイントです。', 'invalid_use_unit' => 'マイレージは:unitポイント単位で使用できます。', - 'exceeds_max_use' => '最大使用限度を超えました。', + 'exceeds_max_use' => '最大使用限度を超えました。(使用可能な最大::amountポイント)', 'use_exceeds_balance' => '保有ポイント(:amount点)を超えて使用することはできません。', 'base_currency_rule_missing' => '基本通貨のマイレージ使用単位が設定されていないため、マイレージを使用できません。', ], diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php index 29628fc0..4717a939 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php @@ -26,6 +26,7 @@ return [ 'type_invalid' => '配送業者タイプは国内(domestic)または国際(international)のみ可能です。', ], 'list' => [ + 'per_page_max' => '一度に取得できる最大件数は:max件です。', 'page' => [ 'integer' => 'ページ番号は数字である必要があります。', 'min' => 'ページ番号は1以上である必要があります。', @@ -218,6 +219,9 @@ return [ 'selling_price_lte_list' => '販売価格は定価より大きくすることはできません。', 'option_selling_price_lte_list' => 'オプション販売価格は定価より大きくすることはできません。', 'product' => [ + 'mileage_percent_max' => '定率のマイレージ付与率は100%を超えることはできません。', + 'selling_price_lte_list' => '販売価格は定価より高くすることはできません。', + 'price_min' => '価格は0より大きい必要があります。', 'attributes' => [ 'name' => '商品名', 'product_code' => '商品コード', @@ -848,13 +852,14 @@ return [ ], ], 'cart' => [ + 'quantity_limit_exceeded' => 'カートには商品ごとに最大:limit個まで追加できます。(リクエスト: :attempted個)', 'product_id_required' => '商品を選択してください。', 'product_not_found' => '存在しない商品です。', 'option_id_required' => 'オプションを選択してください。', 'option_not_found' => '存在しないオプションです。', 'quantity_required' => '数量を入力してください。', 'quantity_min' => '数量は1個以上である必要があります。', - 'quantity_max' => '数量は最大9,999個まで可能です。', + 'quantity_max' => '数量は最大:max個まで可能です。', 'ids_required' => '削除する商品を選択してください。', 'ids_array' => '商品IDは配列形式である必要があります。', 'ids_min' => '最低1つ以上の商品を選択してください。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-page-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-page-ja/CHANGELOG.md index 64e4d296..8be76886 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-page-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-page-ja/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- 첨부파일 개수 상한 초과 안내와 허용되지 않는 파일 형식 안내 문구 일본어 번역 추가. - 페이지 활동 로그의 "제목"·"내용" 변경 항목 라벨 일본어 번역을 추가했습니다. 일본어 로케일에서 항목명이 비어 보이던 문제가 해소됩니다. ## [1.0.1] - 2026-07-08 diff --git a/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/messages.php index c7464073..8d5f8ddb 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/messages.php @@ -35,5 +35,6 @@ return [ 'version_belongs_to_different_page' => '指定されたバージョンは該当ページに属していません。', 'permission_denied' => '権限がありません。', 'validation_failed' => '入力値が正しくありません。', + 'attachment_limit_exceeded' => '添付ファイルは最大:limit個まで登録できます。(リクエスト: :attempted個)', ], ]; diff --git a/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php index a83cc4ed..c2addd8d 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php @@ -56,6 +56,7 @@ return [ 'file' => '正しいファイル形式ではありません。', 'max' => 'ファイルサイズは:maxKBを超えることはできません。', 'mimes' => '許可されていないファイル形式です。', + 'mimetypes' => '許可されていないファイル形式です。', ], 'order' => [ 'required' => '順序情報は必須です。', diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 4c5df692..43dd0ba6 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -8,6 +8,8 @@ ### Added +- 환경설정 > 보안의 2단계 인증 항목 문구 일본어 번역 추가 (`settings.security.two_factor_auth*`) — 항목 이름과 설명이 일본어 로케일에서 자연스럽게 표시됩니다. +- 회원 상세의 계정 잠금 상태 표시와 잠금 해제 버튼 문구 일본어 번역 추가 (`users.detail.unlock*`, `users.detail.lock_status`) — 무기한 잠금·기한부 잠금·정상 상태 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 언어팩 관리 목록의 "파일 없음" 배지와 다시 설치 버튼 문구 일본어 번역 추가 (`language_packs.badge.files_missing`, `language_packs.actions.reinstall`). - 일반 설정의 에셋 파일 서빙 방식 항목 문구 일본어 번역 추가 (`settings.general.asset_url_mode*`) — 항목 이름과 설명, 선택지, 자동 감지 결과(확장자를 가로채는 서버 규칙 안내 포함)가 일본어 로케일에서 자연스럽게 표시됩니다. - 대시보드의 에셋 파일 서빙 방식 불일치 안내 문구 일본어 번역 추가 (`dashboard.asset_url_mode_drift_*`) — 안내 제목과 설명, 환경설정 이동 문구가 일본어 로케일에서 자연스럽게 표시됩니다. diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index 31b1155a..3602a0c4 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -256,7 +256,8 @@ "last_login_at": "最後のログイン", "ip_address": "IP", "country_select": "国を選択", - "country_search": "国を検索..." + "country_search": "国を検索...", + "lock_status": "アカウントロック状態" }, "verification": { "method": "本人確認方法", @@ -338,7 +339,13 @@ "profile_memo": "プロフィール/メモ", "activity_info": "アクティビティ情報", "consent_info": "同意履歴" - } + }, + "unlock": "ロック解除", + "unlock_success": "アカウントのロックを解除しました。", + "lock_status": "アカウントロック状態", + "locked_permanently_value": "ロック中 (無期限 — 管理者による解除が必要)", + "locked_until_value": "ロック中 ({{until}} まで)", + "not_locked_value": "正常" }, "bulk_status_change": "ステータス変更" }, @@ -1479,7 +1486,16 @@ "regenerate_key": "キー 再生成", "app_key_warning": "キーを変更すると、既存の暗号化されたデータにアクセスできなくなる可能性があります。", "allow_internal_outbound_urls": "内部ネットワークアドレス呼び出し許可", - "allow_internal_outbound_urls_desc": "外部API連携·スケジュールURL呼び出しなどで、プライベートIPやlocalhostなどの内部アドレスを使用できるようにします。社内サーバーを呼び出す必要がある場合のみ有効にしてください。有効にするとサーバーが内部ネットワークにリクエストを送信できるようになるため、デフォルトは無効です。" + "allow_internal_outbound_urls_desc": "外部API連携·スケジュールURL呼び出しなどで、プライベートIPやlocalhostなどの内部アドレスを使用できるようにします。社内サーバーを呼び出す必要がある場合のみ有効にしてください。有効にするとサーバーが内部ネットワークにリクエストを送信できるようになるため、デフォルトは無効です。", + "password_min_length": "パスワードの最小文字数", + "password_min_length_hint": "会員登録・パスワード変更・再設定に適用されます。ログインには適用されません(既存のパスワードでログインできなくなるのを防ぐため)。", + "require_password_special_char": "特殊文字必須", + "require_password_special_char_label": "パスワードに特殊文字を必ず含める", + "require_password_special_char_hint": "有効にすると、新しく設定するパスワードに特殊文字が少なくとも1つ必要です。", + "two_factor_auth": "2段階認証", + "two_factor_auth_label": "ログイン時に認証番号をもう一度確認", + "two_factor_auth_hint": "有効にすると、パスワード確認後に登録メールへ認証番号を送信し、その番号を入力してログインが完了します。", + "ip_throttle_floor_hint": "IP単位のリクエスト制限はこの値の6倍で適用され、1分あたり最低30回は常に保証されます。" }, "identity": { "sub_tabs": { diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md index 8cf6397f..ea9c9392 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - 회원가입 화면의 휴대폰번호·전화번호 입력란 라벨·안내 문구(`auth.mobile`, `auth.phone`) 일본어 번역 추가 — 회원가입 시 연락처 입력란이 일본어 로케일에서 자연스럽게 표시됩니다. +- 모달 닫기 버튼의 안내 라벨(`common.close_modal`) 일본어 번역 추가 — 화면 낭독기 사용자에게 버튼 용도가 일본어로 안내됩니다. ## [1.0.1] - 2026-07-08 diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/common.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/common.json index 54f95d1b..2afc5fd4 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/common.json +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/common.json @@ -19,6 +19,7 @@ "prev": "前へ", "more": "もっと見る", "close": "閉じる", + "close_modal": "モーダルを閉じる", "submit": "送信", "required": "必須", "optional": "選択", diff --git a/lang/en/activity_log.php b/lang/en/activity_log.php index 9d322426..7d7afb06 100644 --- a/lang/en/activity_log.php +++ b/lang/en/activity_log.php @@ -38,6 +38,7 @@ return [ 'logout' => 'Logout', 'login_failed' => 'Login failed', 'account_locked' => 'Account locked', + 'account_unlocked' => 'Account unlocked', 'request' => 'Request', 'expired' => 'Expired', 'verify' => 'Identity verified', @@ -148,6 +149,8 @@ return [ 'auth_record_consents' => 'Consent recorded', 'auth_login_failed' => 'Login failed (:email)', 'auth_account_locked' => 'Account locked (:attempts attempts, :minutes min lockout)', + 'auth_account_locked_permanently' => 'Account locked permanently (:attempts attempts, administrator unlock required)', + 'auth_account_unlocked' => 'Account unlocked (:email)', // Schedule management 'schedule_index' => 'Schedule list viewed', diff --git a/lang/en/attachment.php b/lang/en/attachment.php index 0a4caa0e..fe386189 100644 --- a/lang/en/attachment.php +++ b/lang/en/attachment.php @@ -27,6 +27,7 @@ return [ 'file_required' => 'Please select a file.', 'file_invalid' => 'The file is not valid.', 'file_max' => 'The file size cannot exceed :max MB.', + 'file_extension_invalid' => 'This file type is not allowed. Allowed types: :extensions', 'files_required' => 'Please select files.', 'files_array' => 'The files format is invalid.', 'files_min' => 'Please select at least one file.', diff --git a/lang/en/auth.php b/lang/en/auth.php index 63ece7ce..3e782708 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -4,6 +4,10 @@ return [ // Authentication related messages 'login_success' => 'Login successful.', 'login_failed' => 'Authentication failed.', + 'two_factor_required' => 'A verification code has been sent. Enter it to finish signing in.', + 'two_factor_failed' => 'The verification code is incorrect or has expired.', + 'two_factor_invalid_challenge' => 'Verification request not found. Please sign in again.', + 'two_factor_delivery_failed' => 'Could not send the verification code. Please contact the administrator.', 'register_success' => 'Registration successful.', 'register_failed' => 'Registration failed.', 'logout_success' => 'Logout successful.', @@ -33,6 +37,8 @@ return [ 'account_withdrawn' => 'This account has been withdrawn.', 'account_pending_verification' => 'Identity verification is not complete. Please complete the email verification.', 'account_locked' => 'Too many failed login attempts. Your account is locked for :minutes minute(s).', + 'account_locked_permanently' => 'Too many failed login attempts. Your account has been locked. Please contact an administrator.', + 'account_unlocked' => 'The account lock has been released.', // Password reset 'reset_token_invalid' => 'Invalid password reset token.', diff --git a/lang/en/identity.php b/lang/en/identity.php index ddc105d6..65555f2c 100644 --- a/lang/en/identity.php +++ b/lang/en/identity.php @@ -65,6 +65,10 @@ return [ 'label' => 'Sensitive Action', 'description' => 'Re-verify before sensitive actions such as account deletion or admin operations.', ], + 'login' => [ + 'label' => 'Two-Factor Login', + 'description' => 'Require one more step after the password when two-factor authentication is enabled.', + ], ], 'channels' => [ diff --git a/lang/en/validation.php b/lang/en/validation.php index d9fb829e..359464da 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -762,6 +762,11 @@ return [ // Auth validation messages 'auth' => [ + 'two_factor' => [ + 'challenge_required' => 'Verification request is missing. Please sign in again.', + 'challenge_invalid' => 'Verification request is not valid.', + 'code_required' => 'Enter the verification code.', + ], 'email' => [ 'required' => 'Email is required.', 'email' => 'Please enter a valid email address.', @@ -1200,5 +1205,71 @@ return [ 'ses_key' => 'SES access key', 'ses_secret' => 'SES secret key', 'ses_region' => 'SES region', + 'mailgun_endpoint' => 'Mailgun endpoint', + // General settings (additional) + 'channels' => 'notification channels', + 'currency' => 'default currency', + 'maintenance_mode' => 'maintenance mode', + 'asset_url_mode' => 'asset URL mode', + 'site_logo' => 'site logo', + // SEO settings (additional) + 'bot_user_agents' => 'bot user agents', + 'bot_detection_enabled' => 'bot detection', + 'bot_detection_library_enabled' => 'bot detection library', + 'og_default_site_name' => 'OG default site name', + 'og_image_default_width' => 'OG image default width', + 'og_image_default_height' => 'OG image default height', + 'twitter_default_card' => 'Twitter default card type', + 'twitter_default_site' => 'Twitter default account', + 'seo_page_cache_enabled' => 'SEO page cache', + 'cache_ttl' => 'SEO page cache lifetime', + 'sitemap_enabled' => 'sitemap', + 'sitemap_cache_ttl' => 'sitemap cache lifetime', + 'sitemap_urls_per_file' => 'sitemap URLs per file', + 'sitemap_gzip' => 'sitemap compression', + 'sitemap_serve_stale_on_miss' => 'serve stale sitemap on miss', + 'sitemap_max_urls_per_contributor' => 'sitemap max URLs per contributor', + 'sitemap_hreflang_enabled' => 'sitemap hreflang links', + 'sitemap_schedule' => 'sitemap schedule', + 'sitemap_schedule_time' => 'sitemap schedule time', + 'generator_enabled' => 'SEO page generator', + 'generator_content' => 'SEO page generator content', + // Security settings (additional) + 'force_https' => 'force HTTPS', + 'login_attempt_enabled' => 'login attempt limit', + 'auth_token_lifetime' => 'auth token lifetime', + 'max_login_attempts' => 'max login attempts', + 'login_lockout_time' => 'login lockout time', + 'password_min_length' => 'minimum password length', + 'require_password_special_char' => 'require special character in password', + 'two_factor_auth' => 'two-factor authentication', + 'allow_internal_outbound_urls' => 'allow internal outbound URLs', + // Advanced settings (additional) + 'advanced_cache_enabled' => 'cache', + 'layout_cache_enabled' => 'layout cache', + 'layout_cache_ttl' => 'layout cache lifetime', + 'stats_cache_enabled' => 'statistics cache', + 'stats_cache_ttl' => 'statistics cache lifetime', + 'seo_cache_enabled' => 'SEO cache', + 'seo_cache_ttl' => 'SEO cache lifetime', + 'seo_sitemap_cache_ttl' => 'SEO sitemap cache lifetime', + 'debug_mode' => 'debug mode', + 'sql_query_log' => 'SQL query log', + 'core_update_github_url' => 'core update GitHub URL', + 'core_update_github_token' => 'core update GitHub token', + 'geoip_enabled' => 'GeoIP', + 'geoip_license_key' => 'GeoIP license key', + 'geoip_auto_update_enabled' => 'GeoIP auto update', + // Driver settings (additional) + 'websocket_app_id' => 'WebSocket app ID', + 'websocket_app_secret' => 'WebSocket app secret', + 'websocket_verify_ssl' => 'WebSocket SSL verification', + 'websocket_server_host' => 'WebSocket server host', + 'websocket_server_port' => 'WebSocket server port', + 'websocket_server_scheme' => 'WebSocket server scheme', + 'search_engine_driver' => 'search engine driver', + 'log_driver' => 'log driver', + 'log_level' => 'log level', + 'log_days' => 'log retention days', ], ]; diff --git a/lang/ko/activity_log.php b/lang/ko/activity_log.php index d2acf918..6e7452b0 100644 --- a/lang/ko/activity_log.php +++ b/lang/ko/activity_log.php @@ -38,6 +38,7 @@ return [ 'logout' => '로그아웃', 'login_failed' => '로그인 실패', 'account_locked' => '계정 잠금', + 'account_unlocked' => '계정 잠금 해제', 'request' => '요청', 'expired' => '만료', 'verify' => '본인인증', @@ -148,6 +149,8 @@ return [ 'auth_record_consents' => '이용약관 동의 기록', 'auth_login_failed' => '로그인 실패 (:email)', 'auth_account_locked' => '계정 잠금 (:attempts회 실패, :minutes분 차단)', + 'auth_account_locked_permanently' => '계정 영구 잠금 (:attempts회 실패, 관리자 해제 필요)', + 'auth_account_unlocked' => '계정 잠금 해제 (:email)', // 스케줄 관리 'schedule_index' => '스케줄 목록 조회', diff --git a/lang/ko/attachment.php b/lang/ko/attachment.php index ba92d264..d66f5520 100644 --- a/lang/ko/attachment.php +++ b/lang/ko/attachment.php @@ -27,6 +27,7 @@ return [ 'file_required' => '파일을 선택해주세요.', 'file_invalid' => '유효한 파일이 아닙니다.', 'file_max' => '파일 크기는 :maxMB를 초과할 수 없습니다.', + 'file_extension_invalid' => '허용되지 않는 파일 형식입니다. 허용 형식: :extensions', 'files_required' => '파일을 선택해주세요.', 'files_array' => '파일 형식이 올바르지 않습니다.', 'files_min' => '최소 1개 이상의 파일을 선택해주세요.', diff --git a/lang/ko/auth.php b/lang/ko/auth.php index e8697b60..fe18839f 100644 --- a/lang/ko/auth.php +++ b/lang/ko/auth.php @@ -4,6 +4,10 @@ return [ // 인증 관련 메시지 'login_success' => '로그인이 성공했습니다.', 'login_failed' => '인증에 실패했습니다.', + 'two_factor_required' => '인증번호를 보냈습니다. 받은 번호를 입력해 로그인을 완료해주세요.', + 'two_factor_failed' => '인증번호가 올바르지 않거나 유효시간이 지났습니다.', + 'two_factor_invalid_challenge' => '인증 요청을 찾을 수 없습니다. 처음부터 다시 로그인해주세요.', + 'two_factor_delivery_failed' => '인증번호를 보내지 못했습니다. 관리자에게 문의해주세요.', 'register_success' => '회원가입이 성공했습니다.', 'register_failed' => '회원가입에 실패했습니다.', 'logout_success' => '로그아웃이 성공했습니다.', @@ -33,6 +37,8 @@ return [ 'account_withdrawn' => '탈퇴한 계정입니다.', 'account_pending_verification' => '본인인증이 완료되지 않은 계정입니다. 이메일 인증을 완료해주세요.', 'account_locked' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. :minutes분 후 다시 시도해주세요.', + 'account_locked_permanently' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의해주세요.', + 'account_unlocked' => '계정 잠금이 해제되었습니다.', // 비밀번호 재설정 'reset_token_invalid' => '유효하지 않은 비밀번호 재설정 토큰입니다.', diff --git a/lang/ko/identity.php b/lang/ko/identity.php index aa21a8f4..50302998 100644 --- a/lang/ko/identity.php +++ b/lang/ko/identity.php @@ -65,6 +65,10 @@ return [ 'label' => '민감 작업', 'description' => '계정 탈퇴·관리자 작업 등 재인증이 필요한 시점.', ], + 'login' => [ + 'label' => '로그인 2단계 인증', + 'description' => '2단계 인증을 켰을 때 비밀번호 확인 뒤 한 단계를 더 요구.', + ], ], 'channels' => [ diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 549fbe5f..6a32f20c 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -763,6 +763,11 @@ return [ // 인증 관련 검증 메시지 'auth' => [ + 'two_factor' => [ + 'challenge_required' => '인증 요청 정보가 없습니다. 처음부터 다시 로그인해주세요.', + 'challenge_invalid' => '인증 요청 정보가 올바르지 않습니다.', + 'code_required' => '인증번호를 입력해주세요.', + ], 'email' => [ 'required' => '이메일은 필수입니다.', 'email' => '올바른 이메일 형식이 아닙니다.', @@ -1193,5 +1198,71 @@ return [ 'ses_key' => 'SES 액세스 키', 'ses_secret' => 'SES 시크릿 키', 'ses_region' => 'SES 리전', + 'mailgun_endpoint' => 'Mailgun 엔드포인트', + // 일반 설정 (추가) + 'channels' => '알림 채널', + 'currency' => '기본 통화', + 'maintenance_mode' => '점검 모드', + 'asset_url_mode' => '자산 주소 방식', + 'site_logo' => '사이트 로고', + // SEO 설정 (추가) + 'bot_user_agents' => '봇 User-Agent 목록', + 'bot_detection_enabled' => '봇 감지 사용', + 'bot_detection_library_enabled' => '봇 감지 라이브러리 사용', + 'og_default_site_name' => 'OG 기본 사이트 이름', + 'og_image_default_width' => 'OG 이미지 기본 너비', + 'og_image_default_height' => 'OG 이미지 기본 높이', + 'twitter_default_card' => '트위터 기본 카드 유형', + 'twitter_default_site' => '트위터 기본 계정', + 'seo_page_cache_enabled' => 'SEO 페이지 캐시 사용', + 'cache_ttl' => 'SEO 페이지 캐시 유지시간', + 'sitemap_enabled' => '사이트맵 사용', + 'sitemap_cache_ttl' => '사이트맵 캐시 유지시간', + 'sitemap_urls_per_file' => '사이트맵 파일당 URL 수', + 'sitemap_gzip' => '사이트맵 압축', + 'sitemap_serve_stale_on_miss' => '사이트맵 미생성 시 이전 파일 제공', + 'sitemap_max_urls_per_contributor' => '사이트맵 항목별 최대 URL 수', + 'sitemap_hreflang_enabled' => '사이트맵 대체 언어 링크 사용', + 'sitemap_schedule' => '사이트맵 자동 생성 주기', + 'sitemap_schedule_time' => '사이트맵 자동 생성 시각', + 'generator_enabled' => 'SEO 페이지 생성기 사용', + 'generator_content' => 'SEO 페이지 생성기 콘텐츠', + // 보안 설정 (추가) + 'force_https' => 'HTTPS 강제 적용', + 'login_attempt_enabled' => '로그인 시도 제한', + 'auth_token_lifetime' => '인증 토큰 유지시간', + 'max_login_attempts' => '최대 로그인 시도 횟수', + 'login_lockout_time' => '로그인 차단 시간', + 'password_min_length' => '비밀번호 최소 길이', + 'require_password_special_char' => '비밀번호 특수문자 필수', + 'two_factor_auth' => '2단계 인증', + 'allow_internal_outbound_urls' => '내부 네트워크 주소 호출 허용', + // 고급 설정 (추가) + 'advanced_cache_enabled' => '캐시 사용', + 'layout_cache_enabled' => '레이아웃 캐시 사용', + 'layout_cache_ttl' => '레이아웃 캐시 유지시간', + 'stats_cache_enabled' => '통계 캐시 사용', + 'stats_cache_ttl' => '통계 캐시 유지시간', + 'seo_cache_enabled' => 'SEO 캐시 사용', + 'seo_cache_ttl' => 'SEO 캐시 유지시간', + 'seo_sitemap_cache_ttl' => 'SEO 사이트맵 캐시 유지시간', + 'debug_mode' => '디버그 모드', + 'sql_query_log' => 'SQL 쿼리 로그', + 'core_update_github_url' => '코어 업데이트 GitHub 주소', + 'core_update_github_token' => '코어 업데이트 GitHub 토큰', + 'geoip_enabled' => 'GeoIP 사용', + 'geoip_license_key' => 'GeoIP 라이선스 키', + 'geoip_auto_update_enabled' => 'GeoIP 자동 업데이트', + // 드라이버 설정 (추가) + 'websocket_app_id' => '웹소켓 앱 ID', + 'websocket_app_secret' => '웹소켓 앱 시크릿', + 'websocket_verify_ssl' => '웹소켓 SSL 인증서 검증', + 'websocket_server_host' => '웹소켓 서버 호스트', + 'websocket_server_port' => '웹소켓 서버 포트', + 'websocket_server_scheme' => '웹소켓 서버 프로토콜', + 'search_engine_driver' => '검색 엔진 드라이버', + 'log_driver' => '로그 드라이버', + 'log_level' => '로그 레벨', + 'log_days' => '로그 보관 일수', ], ]; diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 3f7d1f9d..2880ccc9 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -6,6 +6,11 @@ ## [1.0.3] - 2026-07-16 +### Security + +- 게시판에 설정한 대댓글 최대 깊이가 모든 댓글 등록 경로에서 적용되도록 수정했습니다. 이전에는 글쓰기 화면을 거치는 요청에서만 깊이를 확인해, 연동 기능이 댓글을 직접 등록하면 설정한 깊이를 넘겨 달릴 수 있었습니다. 깊이를 넘긴 답글은 다른 위치로 옮겨 붙이지 않고 사유와 함께 거절합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 게시판에 설정한 첨부파일 최대 개수가 모든 첨부 경로에서 적용되도록 수정했습니다. 이전에는 글쓰기 화면에서 파일을 직접 첨부할 때만 개수를 확인해, 파일을 먼저 올려 두고 글을 저장하는 방식으로는 상한을 넘겨 첨부할 수 있었습니다. 이제 직접 첨부·미리 올린 파일 연결·이미 첨부된 파일까지 합산해 판정합니다. 이미 상한을 넘겨 등록된 기존 첨부는 그대로 두고 신규 등록만 차단합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) + ### Added - 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. @@ -19,11 +24,17 @@ ### Fixed +- 글을 쓰면서 파일을 함께 첨부하면 글은 저장되는데 첨부파일만 사라지던 문제를 수정했습니다. 첨부 개수·용량·형식 검사와 권한 확인은 모두 통과한 뒤 저장 단계에서만 빠져, 등록된 글에 첨부가 하나도 남지 않았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 게시판 신고 정책의 자동 숨김 기준 횟수를 0(자동 숨김 사용 안 함)으로 입력할 수 없던 문제를 수정했습니다. 서버는 0을 "사용 안 함"으로 처리하는데 화면에서만 1 이상을 요구해, 0을 입력하면 저장은 되면서도 입력칸이 계속 오류 상태로 남았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 글을 수정하면서 미리 올려 둔 파일을 첨부하면 저장은 되는데 첨부만 연결되지 않던 문제를 수정했습니다. 관리자 화면에서는 정상 연결되어 같은 요청이 화면에 따라 다르게 동작했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 개별 게시판을 수정할 때 목록 표시 개수·제목/내용 길이·댓글 깊이·첨부 개수 등 숫자 항목의 허용 범위가 화면에 표시되지 않던 문제를 수정했습니다. 게시판 환경설정(기본값) 화면에는 범위가 표시되는데 개별 게시판 화면에는 없어, 범위를 벗어난 값을 넣어도 저장을 눌러 봐야 알 수 있었습니다. 이제 두 화면이 같은 범위를 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 게시글 목록에서 글을 열어 이전글·다음글로 넘어가거나 수정 후 돌아올 때 보고 있던 페이지·검색어·분류가 사라지던 문제를 수정했습니다. 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 작성·수정 폼 어디로 오가든 목록 상태가 유지되며, 신고 관리와 게시판 목록도 동일하게 개선됐습니다. 새로고침 버튼도 걸어 둔 검색·필터를 지운 채 새로 부르지 않고 보고 있던 목록을 그대로 다시 부릅니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 게시글 목록에서 뒤쪽 페이지를 보던 중 검색하거나 게시 상태를 바꾸면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색과 상태 변경은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. 검색창에서 엔터를 치는 경우와 검색 버튼을 누르는 경우가 서로 다르게 동작하던 것도 함께 맞췄습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.) - 새 글 표시 기간을 "0시간"(새 글 표시 안 함)으로 저장할 수 없던 문제를 수정했습니다. 0은 원래 새 글 배지를 끄는 값으로 동작하도록 되어 있었으나 저장 단계에서 막혀 있었습니다. - 게시판 환경설정에서 제목·댓글 최소 길이를 0(제한 없음)으로 저장할 수 없던 문제를 수정했습니다. 내용 최소 길이는 0이 허용되어 같은 화면에서 항목마다 동작이 달랐습니다. +- 댓글 계층 정리 시 서로가 서로의 부모로 얽혀 최상위 댓글에서 닿을 수 없는 댓글이 아무 기록 없이 넘어가던 문제를 수정했습니다. 이런 댓글은 내용과 위치를 그대로 보존하되, 정리되지 않았다는 사실이 업데이트 기록에 남아 확인할 수 있습니다. - 허용 확장자를 지정하지 않은 게시판에서 어떤 파일도 첨부할 수 없던 문제를 수정했습니다. 파일 선택 단계에서 막히던 것과 서버에서 거부되던 것을 함께 고쳐, 확장자를 지정하지 않은 게시판은 기본 확장자 목록을 사용합니다. 첨부 안내에 확장자가 비어 표시되던 것도 함께 해소됩니다. 확장자를 지정한 게시판은 지금처럼 목록에 없는 파일을 계속 거부합니다. - 게시판 알림 설정에서 저장에 실패해도 아무 안내 없이 넘어가던 화면을 수정했습니다. 실패하면 그 이유가 화면에 표시됩니다. - 일부 안내 문구가 실제 문장 대신 내부 식별자(`sirsoft-board::messages....` 같은 문자열)로 표시되던 문제를 수정했습니다. 첨부파일이 있는 게시판을 찾지 못했을 때, 댓글 목록을 불러오지 못했을 때, 비회원 댓글의 비밀번호를 확인할 때, 삭제·블라인드된 글에 댓글을 달려 할 때, 권한이 없어 거절될 때가 해당됩니다. @@ -37,6 +48,7 @@ - 다른 게시글의 댓글을 답글의 부모로 지정할 수 있던 문제를 수정했습니다. 이렇게 만들어진 답글은 어느 게시글 목록에도 나타나지 않았습니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.) - 대댓글 깊이가 게시판 설정값을 넘어 계속 달리던 문제를 수정했습니다. 예전에는 6단계부터 들여쓰기가 더 들어가지 않고 설정한 최대 깊이도 지켜지지 않았습니다. 이제 설정한 깊이까지만 답글을 달 수 있고 화면의 단계 표시도 정확해집니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.) - 기존에 쌓인 댓글 중 깊이가 어긋났거나 다른 게시글의 댓글을 부모로 가진 것을 업데이트 시 자동으로 정리합니다. 잘못 연결된 댓글은 삭제하지 않고 최상위 댓글로 옮기며, 내용은 그대로 보존됩니다. +- 연동 기능이 블라인드·삭제된 글에 댓글을 등록하려 할 때 "댓글 등록에 실패했습니다" 라는 서버 오류만 표시되던 문제를 수정했습니다. 이제 글쓰기 화면에서와 똑같이 왜 등록할 수 없는지 사유가 표시됩니다. ## [1.0.2] - 2026-07-14 diff --git a/modules/_bundled/sirsoft-board/config/board.php b/modules/_bundled/sirsoft-board/config/board.php index a952dfe0..16f4a55b 100644 --- a/modules/_bundled/sirsoft-board/config/board.php +++ b/modules/_bundled/sirsoft-board/config/board.php @@ -154,6 +154,26 @@ return [ // 대댓글 깊이 제한 'max_comment_depth_min' => 0, 'max_comment_depth_max' => 10, + + // 신고 정책 + 'auto_hide_threshold_min' => 0, + 'auto_hide_threshold_max' => 100, + 'daily_report_limit_min' => 0, + 'daily_report_limit_max' => 100, + 'rejection_limit_count_min' => 0, + 'rejection_limit_count_max' => 50, + 'rejection_limit_days_min' => 1, + 'rejection_limit_days_max' => 365, + + // 스팸/보안 쿨다운 (초) + 'post_cooldown_seconds_min' => 0, + 'post_cooldown_seconds_max' => 3600, + 'comment_cooldown_seconds_min' => 0, + 'comment_cooldown_seconds_max' => 3600, + 'report_cooldown_seconds_min' => 0, + 'report_cooldown_seconds_max' => 3600, + 'view_count_cache_ttl_min' => 60, + 'view_count_cache_ttl_max' => 604800, ], /* diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md index c8e44ddb..3e201398 100644 --- a/modules/_bundled/sirsoft-board/docs/api/board.md +++ b/modules/_bundled/sirsoft-board/docs/api/board.md @@ -85,6 +85,8 @@ Content-Disposition: form-data; name="temp_key" **설명** 게시판 관리자가 게시글 첨부파일 1건을 업로드합니다. `auth:sanctum` + admin + 게시판별 `attachments.upload` 권한이 필요하며, `AttachmentService::upload()`가 게시판별 동적 첨부 테이블에 저장합니다. `post_id`가 있으면 해당 게시글에 즉시 귀속되고, 없으면 `temp_key`로 임시 업로드되어 게시글 작성/수정 저장 시점에 연결됩니다. 응답은 FileUploader 컴포넌트 호환을 위해 `data.data`로 한 번 더 감싸 파일 메타(hash·url·order 등)를 반환합니다. +첨부 개수는 게시판 설정 `max_file_count` 를 기준으로 **직접 업로드 · 미리 올려 둔 파일 연결(`attachment_ids`) · 임시 업로드 연결(`temp_key`) · 이미 연결된 첨부** 를 모두 합산해 판정합니다. 상한을 넘으면 `422`(`errors.code = attachment_limit_exceeded`)를 반환하며, 이 판정은 업로드 엔드포인트뿐 아니라 게시글 생성·수정 저장 시점에도 동일하게 적용됩니다. `max_file_count` 가 0 이면 개수를 제한하지 않습니다. + ### GET /api/modules/sirsoft-board/admin/board/{slug}/attachments/download/{hash} diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md index 34e75197..93504de1 100644 --- a/modules/_bundled/sirsoft-board/docs/api/boards.md +++ b/modules/_bundled/sirsoft-board/docs/api/boards.md @@ -3148,9 +3148,13 @@ HTTP/1.1 200 | --- | --- | --- | --- | --- | --- | | slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | | id | path | string | 예 | — | 대상 리소스의 식별자 | +| attachment_ids | body | integer[] | 아니오 | — | 미리 업로드해 둔 첨부 ID 목록. 게시글에 연결된다 (관리자 수정 경로와 동일) | +| temp_key | body | string | 아니오 | 최대 64자 | 임시 업로드 묶음 키. 해당 묶음의 첨부가 게시글로 이동·연결된다 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.user_post.update_validation_rules`). +`attachment_ids` 는 **연결 대상**이며, 목록에서 뺀 기존 첨부를 삭제하지는 않습니다. 첨부 개수 상한은 이미 연결된 첨부까지 합산해 판정하므로 상한을 넘기면 `422`(`errors.code = attachment_limit_exceeded`)로 거부됩니다. + **요청 예시** ```http diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx index 682bd846..a92c2ff7 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx @@ -1135,14 +1135,19 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => { expect(manageTagInputs[0].props.options).toContain('roleOptions'); }); + // 허용 범위는 서버가 내려주는 한계값(_meta.limits)을 바인딩한다. 화면이 숫자를 직접 들면 + // 저장 규칙과 갈라진다 — 실제로 화면 min:1 ↔ 서버 min:0 으로 어긋나 "0=비활성" 안내대로 + // 입력할 수 없던 결함이 있었다(#493 B4). it('필드에 유효한 min/max 제약이 있다', () => { const autoHide = findById(tabReportPolicy, 'field_auto_hide_threshold'); const autoHideInput = findByName(autoHide, 'Input').find( (c: any) => c.props?.name === 'report_policy.auto_hide_threshold' ); - expect(autoHideInput.props.min).toBe(1); - expect(autoHideInput.props.max).toBe(100); - + expect(String(autoHideInput.props.min)).toContain('limits?.auto_hide_threshold_min'); + expect(String(autoHideInput.props.max)).toContain('limits?.auto_hide_threshold_max'); + // 0 = 자동 숨김 비활성. 폴백이 1 로 되돌아가면 비활성으로 되돌릴 방법이 사라진다. + expect(String(autoHideInput.props.min)).toContain('?? 0'); + expect(String(autoHideInput.props.max)).toContain('?? 100'); }); it('Select가 composite 타입이다', () => { diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-form-range-contract.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-form-range-contract.test.ts new file mode 100644 index 00000000..9cf9d93c --- /dev/null +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-form-range-contract.test.ts @@ -0,0 +1,134 @@ +/** + * 개별 게시판 수정 화면 — 숫자 입력 경계값 계약 테스트 (#493 T8) + * + * 배경: 같은 게시판 설정 항목이 두 화면에 있다. + * - 일괄(모듈 기본값): `admin_board_settings` → 데이터소스 `settings`(initLocal "form") + * - 개별(게시판별) : `admin_board_form` → 데이터소스 `boards`(initLocal "form") + * + * 두 화면 모두 서버가 `data._meta.limits` 로 같은 경계값을 내려준다 + * (`BoardSettingsController::index()` / `BoardController::getFormData()`). + * 그런데 일괄 화면만 그 값을 min/max 로 바인딩하고 개별 화면은 아무 경계도 렌더하지 않아, + * 같은 항목인데 한 화면에서만 범위를 알 수 있는 비대칭이 생겼다. + * + * 저장은 `StoreBoardRequest`/`UpdateBoardRequest` 가 `ReadsBoardLimits` 로 동일하게 막으므로 + * 잘못된 값이 저장되지는 않는다. 문제는 사용자가 **제출 전에** 알 수 없다는 것이다. + * + * 회귀 차단 포인트: + * 1. 개별 화면의 숫자 입력은 min/max 를 가진다 (미지정 금지). + * 2. 그 값은 리터럴이 아니라 `_local.form._meta.limits.*` 표현식이다 + * (리터럴이면 config 를 바꿔도 화면이 따라오지 않아 원래 결함으로 되돌아간다). + * 3. 참조하는 limits 키는 일괄 화면이 쓰는 키와 동일하다 (두 화면이 갈라지지 않도록). + */ + +import { describe, it, expect } from 'vitest'; + +import formTabList from '../../../layouts/admin/partials/admin_board_form/_tab_list.json'; +import formTabPost from '../../../layouts/admin/partials/admin_board_form/_tab_post.json'; + +/** + * [필드명, partial, limits 키 prefix] + * + * per_page_mobile 은 per_page 와 같은 범위를 공유한다(일괄 화면과 동일 규약). + */ +const fields: Array<[string, unknown, string]> = [ + ['per_page', formTabList, 'per_page'], + ['per_page_mobile', formTabList, 'per_page'], + ['min_title_length', formTabPost, 'min_title_length'], + ['max_title_length', formTabPost, 'max_title_length'], + ['min_content_length', formTabPost, 'min_content_length'], + ['max_content_length', formTabPost, 'max_content_length'], + ['new_display_hours', formTabPost, 'new_display_hours'], + ['max_reply_depth', formTabPost, 'max_reply_depth'], + ['min_comment_length', formTabPost, 'min_comment_length'], + ['max_comment_length', formTabPost, 'max_comment_length'], + ['max_comment_depth', formTabPost, 'max_comment_depth'], + ['max_file_size', formTabPost, 'max_file_size'], + ['max_file_count', formTabPost, 'max_file_count'], +]; + +/** + * 레이아웃 트리에서 지정한 name 의 number 입력 props 를 찾습니다. + * + * @param node 탐색 대상 노드 + * @param name 찾을 입력 name + * @returns 찾은 props (없으면 null) + */ +function findNumberInput(node: unknown, name: string): Record | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findNumberInput(child, name); + if (found) return found; + } + + return null; + } + + if (node && typeof node === 'object') { + const props = (node as Record).props as Record | undefined; + + if (props && props.type === 'number' && props.name === name) { + return props; + } + + for (const value of Object.values(node as Record)) { + const found = findNumberInput(value, name); + if (found) return found; + } + } + + return null; +} + +describe('개별 게시판 수정 화면 — 숫자 입력 경계값 계약 (#493 T8)', () => { + it.each(fields)('%s — min/max 가 지정되어 있다', (field, layout) => { + const props = findNumberInput(layout, field); + + expect(props, `${field} 입력을 찾지 못했습니다`).not.toBeNull(); + expect(props?.min, `${field}: min 미지정 — 사용자가 하한을 알 수 없습니다`).toBeDefined(); + expect(props?.max, `${field}: max 미지정 — 사용자가 상한을 알 수 없습니다`).toBeDefined(); + }); + + it.each(fields)('%s — 경계값이 _meta.limits 표현식이다 (리터럴 금지)', (field, layout, limitsKey) => { + const props = findNumberInput(layout, field); + + expect(String(props?.min)).toBe( + `{{_local?.form?._meta?.limits?.${limitsKey}_min ?? ${fallbacks[`${limitsKey}_min`]}}}` + ); + expect(String(props?.max)).toBe( + `{{_local?.form?._meta?.limits?.${limitsKey}_max ?? ${fallbacks[`${limitsKey}_max`]}}}` + ); + }); +}); + +/** + * 표현식 폴백 값 — `config/board.php` 의 limits 및 `ReadsBoardLimits` 기본치와 같은 값. + * + * 폴백까지 고정하는 이유: 폴백이 서버 기본치와 다르면 설정 응답이 늦거나 실패한 순간 + * 화면이 서버와 다른 경계를 보여 준다. + */ +const fallbacks: Record = { + per_page_min: 5, + per_page_max: 100, + min_title_length_min: 0, + min_title_length_max: 200, + max_title_length_min: 1, + max_title_length_max: 1000, + min_content_length_min: 0, + min_content_length_max: 10000, + max_content_length_min: 1, + max_content_length_max: 100000, + new_display_hours_min: 0, + new_display_hours_max: 720, + max_reply_depth_min: 1, + max_reply_depth_max: 10, + min_comment_length_min: 0, + min_comment_length_max: 1000, + max_comment_length_min: 1, + max_comment_length_max: 10000, + max_comment_depth_min: 0, + max_comment_depth_max: 10, + max_file_size_min: 1, + max_file_size_max: 200, + max_file_count_min: 1, + max_file_count_max: 20, +}; diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-settings-range-hints.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-settings-range-hints.test.ts index db421086..ab8dabe9 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-settings-range-hints.test.ts +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-settings-range-hints.test.ts @@ -18,8 +18,14 @@ * (구 _tab_basic_defaults.json 은 더 이상 사용하지 않음 — 삭제됨) */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { describe, it, expect } from 'vitest'; +import tabReportPolicy from '../../../layouts/admin/partials/admin_board_settings/_tab_report_policy.json'; +import tabSpamSecurity from '../../../layouts/admin/partials/admin_board_settings/_tab_spam_security.json'; import tabList from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_list.json'; import tabPost from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_post.json'; import tabReply from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_reply.json'; @@ -88,3 +94,148 @@ describe('게시판 환경설정 — 범위 힌트 동적 바인딩 가드 (#413 }, ); }); + +/** + * `config/board.php` 의 `limits` 블록을 키 → 값으로 읽는다. + * + * @returns 한계값 키 맵 + */ +function configuredLimits(): Record { + const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); + const source = fs.readFileSync(path.join(moduleRoot, 'config/board.php'), 'utf-8'); + const block = /'limits'\s*=>\s*\[([\s\S]*?)\n\s*\],/.exec(source); + + expect(block, "config/board.php 에서 'limits' 블록을 찾지 못했습니다.").not.toBeNull(); + + const limits: Record = {}; + + for (const line of block![1].split('\n')) { + const matched = /'([a-z0-9_]+)'\s*=>\s*(-?[\d.]+)\s*,/.exec(line); + if (matched) { + limits[matched[1]] = Number(matched[2]); + } + } + + return limits; +} + +/** + * 레이아웃에서 해당 필드의 min/max prop 표현식을 찾는다. + * + * @param layout 레이아웃 트리 + * @param field 필드 name (예: report_policy.auto_hide_threshold) + * @returns min/max 표현식 + */ +function boundExpressions(layout: unknown, field: string): { min?: string; max?: string } { + let found: { min?: string; max?: string } = {}; + + const visit = (node: any) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach(visit); + + return; + } + if (node.props?.name === field) { + found = { min: node.props.min, max: node.props.max }; + } + Object.values(node).forEach(visit); + }; + + visit(layout); + + return found; +} + +/** + * 신고 정책 · 스팸 보안 탭의 숫자 필드 — [필드 name, partial, limits 키 prefix]. + * + * 이 탭들은 위 목록과 달리 description 범위 힌트를 쓰지 않으므로 Input 경계만 검사한다. + */ +const policyFields: Array<[string, unknown, string]> = [ + ['report_policy.auto_hide_threshold', tabReportPolicy, 'auto_hide_threshold'], + ['report_policy.daily_report_limit', tabReportPolicy, 'daily_report_limit'], + ['report_policy.rejection_limit_count', tabReportPolicy, 'rejection_limit_count'], + ['report_policy.rejection_limit_days', tabReportPolicy, 'rejection_limit_days'], + ['spam_security.report_cooldown_seconds', tabReportPolicy, 'report_cooldown_seconds'], + ['spam_security.post_cooldown_seconds', tabSpamSecurity, 'post_cooldown_seconds'], + ['spam_security.comment_cooldown_seconds', tabSpamSecurity, 'comment_cooldown_seconds'], + ['spam_security.view_count_cache_ttl', tabSpamSecurity, 'view_count_cache_ttl'], +]; + +describe('게시판 환경설정 — 신고 정책 · 스팸 보안 탭 경계값 (#493 B4)', () => { + const limits = configuredLimits(); + + it.each(policyFields)('%s — min/max 가 한계값 설정을 읽는다', (field, layout, limitsKey) => { + const bounds = boundExpressions(layout, field); + + for (const bound of ['min', 'max'] as const) { + expect(bounds[bound], `${field} 에 ${bound} 이 없습니다.`).toBeDefined(); + expect(String(bounds[bound])).toContain(`limits?.${limitsKey}_${bound}`); + } + }); + + it.each(policyFields)('%s — 폴백이 설정 파일의 한계값과 같다', (field, layout, limitsKey) => { + const bounds = boundExpressions(layout, field); + + for (const bound of ['min', 'max'] as const) { + const matched = /\?\?\s*(-?[\d.]+)\s*\}\}/.exec(String(bounds[bound])); + + expect(matched, `${field} 의 ${bound} 폴백을 찾지 못했습니다.`).not.toBeNull(); + expect(Number(matched![1]), `${field} 의 ${bound} 폴백이 설정 파일 값과 다릅니다.`).toBe( + limits[`${limitsKey}_${bound}`], + ); + } + }); + + it('자동 숨김 기준은 0(비활성)을 입력할 수 있다', () => { + // 화면이 min=1 이면 "0=비활성" 안내와 어긋나 비활성으로 되돌릴 방법이 없어진다. + expect(limits.auto_hide_threshold_min).toBe(0); + expect(boundExpressions(tabReportPolicy, 'report_policy.auto_hide_threshold').min).toContain('?? 0'); + }); +}); + +/** + * 서버 저장 규칙(`StoreBoardSettingsRequest`) 원문. + */ +function settingsRequestSource(): string { + const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); + + return fs.readFileSync( + path.join(moduleRoot, 'src/Http/Requests/Admin/StoreBoardSettingsRequest.php'), + 'utf-8', + ); +} + +describe('게시판 환경설정 — 서버 저장 규칙 정합 (#493 B4)', () => { + const limits = configuredLimits(); + const requestSource = settingsRequestSource(); + + /** + * 이 화면에는 네이티브 `
` 이 없다. 저장은 `apiCall` 이 `_local.form` 을 직접 실어 + * 보내므로 Input 의 `min`/`max` 속성은 브라우저 제출을 막지 못한다 — 안내일 뿐이다. + * 그래서 실제 관문은 서버 규칙 하나뿐이고, 화면과 규칙이 어긋나면 화면이 거부하는 + * 것처럼 보이는 값이 200 으로 저장된다(B4 의 최초 증상). + * + * 세 지점(설정 파일 · 서버 규칙 · 화면 바인딩)이 같은 키·같은 폴백을 가리키는지 대조한다. + */ + it.each(policyFields)('%s — 서버 규칙이 화면과 같은 한계값 키를 읽는다', (field, layout, limitsKey) => { + const rule = new RegExp(`'${field.replace('.', '\\.')}'\\s*=>\\s*\\[([^\\]]*)\\]`).exec(requestSource); + + expect(rule, `${field} 의 저장 규칙을 찾지 못했습니다.`).not.toBeNull(); + + for (const bound of ['min', 'max'] as const) { + // 화면이 읽는 키와 규칙이 읽는 키가 같아야 한다 + expect(String(boundExpressions(layout, field)[bound])).toContain(`limits?.${limitsKey}_${bound}`); + expect(rule![1], `${field} 의 ${bound} 규칙이 한계값 설정을 읽지 않습니다.`) + .toContain(`'${limitsKey}_${bound}'`); + + // 규칙의 폴백도 설정 파일 값과 같아야 한다 (설정이 비었을 때 양쪽이 갈라지지 않도록) + const fallback = new RegExp(`'${limitsKey}_${bound}',\\s*(-?[\\d.]+)\\)`).exec(rule![1]); + + expect(fallback, `${field} 의 ${bound} 규칙 폴백을 찾지 못했습니다.`).not.toBeNull(); + expect(Number(fallback![1]), `${field} 의 ${bound} 규칙 폴백이 설정 파일 값과 다릅니다.`) + .toBe(limits[`${limitsKey}_${bound}`]); + } + }); +}); diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_list.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_list.json index 8d5f4536..ca702761 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_list.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_list.json @@ -180,6 +180,8 @@ "props": { "type": "number", "name": "per_page", + "min": "{{_local?.form?._meta?.limits?.per_page_min ?? 5}}", + "max": "{{_local?.form?._meta?.limits?.per_page_max ?? 100}}", "required": true, "disabled": "{{_computed.isReadOnly}}", "className": "" @@ -235,6 +237,8 @@ "props": { "type": "number", "name": "per_page_mobile", + "min": "{{_local?.form?._meta?.limits?.per_page_min ?? 5}}", + "max": "{{_local?.form?._meta?.limits?.per_page_max ?? 100}}", "required": true, "disabled": "{{_computed.isReadOnly}}", "className": "" diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json index 0e6bc7d9..d54430e0 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json @@ -72,6 +72,8 @@ "props": { "type": "number", "name": "min_title_length", + "min": "{{_local?.form?._meta?.limits?.min_title_length_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.min_title_length_max ?? 200}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -126,6 +128,8 @@ "props": { "type": "number", "name": "max_title_length", + "min": "{{_local?.form?._meta?.limits?.max_title_length_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_title_length_max ?? 1000}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -190,6 +194,8 @@ "props": { "type": "number", "name": "min_content_length", + "min": "{{_local?.form?._meta?.limits?.min_content_length_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.min_content_length_max ?? 10000}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -244,6 +250,8 @@ "props": { "type": "number", "name": "max_content_length", + "min": "{{_local?.form?._meta?.limits?.max_content_length_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_content_length_max ?? 100000}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -354,6 +362,8 @@ "props": { "type": "number", "name": "new_display_hours", + "min": "{{_local?.form?._meta?.limits?.new_display_hours_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.new_display_hours_max ?? 720}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -453,7 +463,8 @@ "props": { "type": "number", "name": "max_reply_depth", - "min": 1, + "min": "{{_local?.form?._meta?.limits?.max_reply_depth_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_reply_depth_max ?? 10}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -561,6 +572,8 @@ "props": { "type": "number", "name": "min_comment_length", + "min": "{{_local?.form?._meta?.limits?.min_comment_length_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.min_comment_length_max ?? 1000}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -615,6 +628,8 @@ "props": { "type": "number", "name": "max_comment_length", + "min": "{{_local?.form?._meta?.limits?.max_comment_length_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_comment_length_max ?? 10000}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -672,6 +687,8 @@ "props": { "type": "number", "name": "max_comment_depth", + "min": "{{_local?.form?._meta?.limits?.max_comment_depth_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.max_comment_depth_max ?? 10}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -825,6 +842,8 @@ "props": { "type": "number", "name": "max_file_count", + "min": "{{_local?.form?._meta?.limits?.max_file_count_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_file_count_max ?? 20}}", "disabled": "{{_computed.isReadOnly}}", "className": "" } @@ -879,6 +898,8 @@ "props": { "type": "number", "name": "max_file_size", + "min": "{{_local?.form?._meta?.limits?.max_file_size_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.max_file_size_max ?? 200}}", "placeholder": "$t:sirsoft-board.admin.form.fields.max_file_size.placeholder", "disabled": "{{_computed.isReadOnly}}", "className": "" diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_post.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_post.json index 04ec936f..cf56f792 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_post.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_post.json @@ -578,8 +578,8 @@ "props": { "type": "number", "name": "basic_defaults.new_display_hours", - "min": 1, - "max": 720, + "min": "{{_local?.form?._meta?.limits?.new_display_hours_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.new_display_hours_max ?? 720}}", "className": "{{(_local.errors?.['basic_defaults.new_display_hours']) ? 'w-32 input-error' : 'w-32'}}", "disabled": "{{_computed.isReadOnly}}" } diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json index cbbe1d46..908e4a24 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json @@ -43,8 +43,7 @@ "id": "date_display_format_field", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -101,10 +100,27 @@ "type": "radio", "name": "display.date_display_format", "value": "standard", - "className": "radio mt-0.5 pointer-events-none", + "className": "radio mt-0.5", "disabled": "{{_computed.isReadOnly}}", "checked": "{{(_local.form?.display?.date_display_format ?? 'standard') === 'standard'}}" - } + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form": { + "...": "{{_local.form}}", + "display": { + "...": "{{_local.form?.display}}", + "date_display_format": "standard" + } + }, + "hasChanges": true + } + } + ] }, { "type": "basic", @@ -173,10 +189,27 @@ "type": "radio", "name": "display.date_display_format", "value": "relative", - "className": "radio mt-0.5 pointer-events-none", + "className": "radio mt-0.5", "disabled": "{{_computed.isReadOnly}}", "checked": "{{(_local.form?.display?.date_display_format ?? 'standard') === 'relative'}}" - } + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form": { + "...": "{{_local.form}}", + "display": { + "...": "{{_local.form?.display}}", + "date_display_format": "relative" + } + }, + "hasChanges": true + } + } + ] }, { "type": "basic", diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_report_policy.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_report_policy.json index 093f4ac2..dd358a2c 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_report_policy.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_report_policy.json @@ -55,8 +55,7 @@ "id": "field_auto_hide_threshold", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -78,8 +77,8 @@ "props": { "type": "number", "name": "report_policy.auto_hide_threshold", - "min": 1, - "max": 100, + "min": "{{_local?.form?._meta?.limits?.auto_hide_threshold_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.auto_hide_threshold_max ?? 100}}", "className": "{{(_local.errors?.['report_policy.auto_hide_threshold']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -107,8 +106,7 @@ "id": "field_auto_hide_target", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -197,8 +195,7 @@ "id": "field_daily_report_limit", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -220,8 +217,8 @@ "props": { "type": "number", "name": "report_policy.daily_report_limit", - "min": 0, - "max": 100, + "min": "{{_local?.form?._meta?.limits?.daily_report_limit_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.daily_report_limit_max ?? 100}}", "className": "{{(_local.errors?.['report_policy.daily_report_limit']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -249,8 +246,7 @@ "id": "field_rejection_limit", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -295,8 +291,8 @@ "props": { "type": "number", "name": "report_policy.rejection_limit_count", - "min": 0, - "max": 50, + "min": "{{_local?.form?._meta?.limits?.rejection_limit_count_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.rejection_limit_count_max ?? 50}}", "className": "{{(_local.errors?.['report_policy.rejection_limit_count']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -336,8 +332,8 @@ "props": { "type": "number", "name": "report_policy.rejection_limit_days", - "min": 1, - "max": 365, + "min": "{{_local?.form?._meta?.limits?.rejection_limit_days_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.rejection_limit_days_max ?? 365}}", "className": "{{(_local.errors?.['report_policy.rejection_limit_days']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -396,8 +392,8 @@ "props": { "type": "number", "name": "spam_security.report_cooldown_seconds", - "min": 0, - "max": 3600, + "min": "{{_local?.form?._meta?.limits?.report_cooldown_seconds_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.report_cooldown_seconds_max ?? 3600}}", "className": "{{(_local.errors?.['spam_security.report_cooldown_seconds']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -420,8 +416,7 @@ "text": "$t:sirsoft-board.admin.settings.fields.report_cooldown_seconds_hint" } ], - "props": { - } + "props": {} } ], "props": { @@ -468,8 +463,7 @@ "id": "field_report_view_roles", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -553,8 +547,7 @@ "text": "{{(Array.isArray(_local.errors?.['report_permissions.manage_roles']) ? _local.errors['report_permissions.manage_roles'][0] : _local.errors?.['report_permissions.manage_roles']) ?? ''}}" } ], - "props": { - } + "props": {} } ], "props": { @@ -707,7 +700,9 @@ "id": "report_received_admin_template_status", "type": "basic", "name": "Div", - "props": { "className": "space-y-2" }, + "props": { + "className": "space-y-2" + }, "children": [ { "type": "basic", @@ -717,33 +712,70 @@ "item_var": "tpl", "index_var": "tplIndex" }, - "props": { "className": "flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-700/40" }, + "props": { + "className": "flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-700/40" + }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center gap-2 min-w-0" }, + "props": { + "className": "flex items-center gap-2 min-w-0" + }, "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "{{tpl.channel === 'mail' ? 'envelope' : 'bell'}}", "size": "sm", "className": "text-gray-400 dark:text-gray-500 flex-shrink-0" } }, + { + "type": "basic", + "name": "Icon", + "props": { + "name": "{{tpl.channel === 'mail' ? 'envelope' : 'bell'}}", + "size": "sm", + "className": "text-gray-400 dark:text-gray-500 flex-shrink-0" + } + }, { "type": "basic", "name": "Div", - "props": { "className": "min-w-0" }, + "props": { + "className": "min-w-0" + }, "children": [ - { "type": "basic", "name": "Span", "props": { "className": "block text-xs font-medium text-gray-500 dark:text-gray-400" }, "text": "{{tpl.channel === 'mail' ? $t('sirsoft-board.admin.settings.fields.notification_channels.mail') : (tpl.channel === 'database' ? $t('sirsoft-board.admin.settings.fields.notification_channels.database') : tpl.channel)}}" }, - { "type": "basic", "name": "Span", "props": { "className": "block text-sm text-gray-800 dark:text-gray-200 truncate" }, "text": "{{tpl.subject?.[$locale] ?? tpl.subject?.ko ?? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_empty')}}" } + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "text": "{{tpl.channel === 'mail' ? $t('sirsoft-board.admin.settings.fields.notification_channels.mail') : (tpl.channel === 'database' ? $t('sirsoft-board.admin.settings.fields.notification_channels.database') : tpl.channel)}}" + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-sm text-gray-800 dark:text-gray-200 truncate" + }, + "text": "{{tpl.subject?.[$locale] ?? tpl.subject?.ko ?? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_empty')}}" + } ] } ] }, - { "type": "basic", "name": "Span", "props": { "className": "{{tpl.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}} inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0" }, "text": "{{tpl.is_active ? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_active') : $t('sirsoft-board.admin.settings.fields.report_notification_template_status_inactive')}}" } + { + "type": "basic", + "name": "Span", + "props": { + "className": "{{tpl.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}} inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0" + }, + "text": "{{tpl.is_active ? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_active') : $t('sirsoft-board.admin.settings.fields.report_notification_template_status_inactive')}}" + } ] }, { "type": "basic", "name": "Div", "if": "{{(((boardNotificationDefinitions?.data?.data ?? []).find(d => d.type === 'report_received_admin')?.templates) ?? []).length === 0}}", - "props": { "className": "px-3 py-2 text-sm text-gray-400 dark:text-gray-500" }, + "props": { + "className": "px-3 py-2 text-sm text-gray-400 dark:text-gray-500" + }, "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_empty" } ] @@ -751,29 +783,66 @@ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center justify-between gap-2 flex-wrap mt-1" }, + "props": { + "className": "flex items-center justify-between gap-2 flex-wrap mt-1" + }, "children": [ - { "type": "basic", "name": "Span", "props": { "className": "block text-xs text-gray-400 dark:text-gray-500" }, "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_hint" }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs text-gray-400 dark:text-gray-500" + }, + "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_hint" + }, { "id": "go_to_notification_settings_admin", "type": "basic", "name": "Button", - "props": { "type": "button", "className": "flex items-center gap-1.5 px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" }, + "props": { + "type": "button", + "className": "flex items-center gap-1.5 px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" + }, "actions": [ { "type": "click", "handler": "sequence", "params": { "actions": [ - { "handler": "setState", "params": { "target": "global", "activeBoardSettingsTab": "notification_definitions" } }, - { "comment": "audit:allow layout-list-context-navigate-merge-query 다른 탭으로 넘어가는 이동이다 — 알림 정의 탭은 자기 검색어와 페이지를 따로 갖는 별개의 목록이라 신고 정책 탭의 조건을 물려주지 않는다.", "handler": "replaceUrl", "params": { "query": { "tab": "notification_definitions" } } } + { + "handler": "setState", + "params": { + "target": "global", + "activeBoardSettingsTab": "notification_definitions" + } + }, + { + "comment": "audit:allow layout-list-context-navigate-merge-query 다른 탭으로 넘어가는 이동이다 — 알림 정의 탭은 자기 검색어와 페이지를 따로 갖는 별개의 목록이라 신고 정책 탭의 조건을 물려주지 않는다.", + "handler": "replaceUrl", + "params": { + "query": { + "tab": "notification_definitions" + } + } + } ] } } ], "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "arrow-up-right-from-square", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.report_notification_go_to_settings" } + { + "type": "basic", + "name": "Icon", + "props": { + "name": "arrow-up-right-from-square", + "size": "sm" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-board.admin.settings.fields.report_notification_go_to_settings" + } ] } ] @@ -856,7 +925,9 @@ "id": "report_action_template_status", "type": "basic", "name": "Div", - "props": { "className": "space-y-2" }, + "props": { + "className": "space-y-2" + }, "children": [ { "type": "basic", @@ -866,33 +937,70 @@ "item_var": "tpl", "index_var": "tplIndex" }, - "props": { "className": "flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-700/40" }, + "props": { + "className": "flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-700/40" + }, "children": [ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center gap-2 min-w-0" }, + "props": { + "className": "flex items-center gap-2 min-w-0" + }, "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "{{tpl.channel === 'mail' ? 'envelope' : 'bell'}}", "size": "sm", "className": "text-gray-400 dark:text-gray-500 flex-shrink-0" } }, + { + "type": "basic", + "name": "Icon", + "props": { + "name": "{{tpl.channel === 'mail' ? 'envelope' : 'bell'}}", + "size": "sm", + "className": "text-gray-400 dark:text-gray-500 flex-shrink-0" + } + }, { "type": "basic", "name": "Div", - "props": { "className": "min-w-0" }, + "props": { + "className": "min-w-0" + }, "children": [ - { "type": "basic", "name": "Span", "props": { "className": "block text-xs font-medium text-gray-500 dark:text-gray-400" }, "text": "{{tpl.channel === 'mail' ? $t('sirsoft-board.admin.settings.fields.notification_channels.mail') : (tpl.channel === 'database' ? $t('sirsoft-board.admin.settings.fields.notification_channels.database') : tpl.channel)}}" }, - { "type": "basic", "name": "Span", "props": { "className": "block text-sm text-gray-800 dark:text-gray-200 truncate" }, "text": "{{tpl.subject?.[$locale] ?? tpl.subject?.ko ?? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_empty')}}" } + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400" + }, + "text": "{{tpl.channel === 'mail' ? $t('sirsoft-board.admin.settings.fields.notification_channels.mail') : (tpl.channel === 'database' ? $t('sirsoft-board.admin.settings.fields.notification_channels.database') : tpl.channel)}}" + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-sm text-gray-800 dark:text-gray-200 truncate" + }, + "text": "{{tpl.subject?.[$locale] ?? tpl.subject?.ko ?? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_empty')}}" + } ] } ] }, - { "type": "basic", "name": "Span", "props": { "className": "{{tpl.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}} inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0" }, "text": "{{tpl.is_active ? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_active') : $t('sirsoft-board.admin.settings.fields.report_notification_template_status_inactive')}}" } + { + "type": "basic", + "name": "Span", + "props": { + "className": "{{tpl.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}} inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0" + }, + "text": "{{tpl.is_active ? $t('sirsoft-board.admin.settings.fields.report_notification_template_status_active') : $t('sirsoft-board.admin.settings.fields.report_notification_template_status_inactive')}}" + } ] }, { "type": "basic", "name": "Div", "if": "{{(((boardNotificationDefinitions?.data?.data ?? []).find(d => d.type === 'report_action')?.templates) ?? []).length === 0}}", - "props": { "className": "px-3 py-2 text-sm text-gray-400 dark:text-gray-500" }, + "props": { + "className": "px-3 py-2 text-sm text-gray-400 dark:text-gray-500" + }, "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_empty" } ] @@ -900,29 +1008,66 @@ { "type": "basic", "name": "Div", - "props": { "className": "flex items-center justify-between gap-2 flex-wrap mt-1" }, + "props": { + "className": "flex items-center justify-between gap-2 flex-wrap mt-1" + }, "children": [ - { "type": "basic", "name": "Span", "props": { "className": "block text-xs text-gray-400 dark:text-gray-500" }, "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_hint" }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs text-gray-400 dark:text-gray-500" + }, + "text": "$t:sirsoft-board.admin.settings.fields.report_notification_template_status_hint" + }, { "id": "go_to_notification_settings_author", "type": "basic", "name": "Button", - "props": { "type": "button", "className": "flex items-center gap-1.5 px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" }, + "props": { + "type": "button", + "className": "flex items-center gap-1.5 px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" + }, "actions": [ { "type": "click", "handler": "sequence", "params": { "actions": [ - { "handler": "setState", "params": { "target": "global", "activeBoardSettingsTab": "notification_definitions" } }, - { "comment": "audit:allow layout-list-context-navigate-merge-query 다른 탭으로 넘어가는 이동이다 — 알림 정의 탭은 자기 검색어와 페이지를 따로 갖는 별개의 목록이라 신고 정책 탭의 조건을 물려주지 않는다.", "handler": "replaceUrl", "params": { "query": { "tab": "notification_definitions" } } } + { + "handler": "setState", + "params": { + "target": "global", + "activeBoardSettingsTab": "notification_definitions" + } + }, + { + "comment": "audit:allow layout-list-context-navigate-merge-query 다른 탭으로 넘어가는 이동이다 — 알림 정의 탭은 자기 검색어와 페이지를 따로 갖는 별개의 목록이라 신고 정책 탭의 조건을 물려주지 않는다.", + "handler": "replaceUrl", + "params": { + "query": { + "tab": "notification_definitions" + } + } + } ] } } ], "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "arrow-up-right-from-square", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.report_notification_go_to_settings" } + { + "type": "basic", + "name": "Icon", + "props": { + "name": "arrow-up-right-from-square", + "size": "sm" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-board.admin.settings.fields.report_notification_go_to_settings" + } ] } ] @@ -938,8 +1083,7 @@ ] } ], - "props": { - } + "props": {} } ] } diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json index 26a3c346..3f749d81 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json @@ -47,8 +47,7 @@ "id": "field_post_cooldown_seconds", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -70,8 +69,8 @@ "props": { "type": "number", "name": "spam_security.post_cooldown_seconds", - "min": 0, - "max": 3600, + "min": "{{_local?.form?._meta?.limits?.post_cooldown_seconds_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.post_cooldown_seconds_max ?? 3600}}", "className": "{{(_local.errors?.['spam_security.post_cooldown_seconds']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -99,8 +98,7 @@ "id": "field_comment_cooldown_seconds", "type": "basic", "name": "Div", - "props": { - }, + "props": {}, "children": [ { "type": "basic", @@ -122,8 +120,8 @@ "props": { "type": "number", "name": "spam_security.comment_cooldown_seconds", - "min": 0, - "max": 3600, + "min": "{{_local?.form?._meta?.limits?.comment_cooldown_seconds_min ?? 0}}", + "max": "{{_local?.form?._meta?.limits?.comment_cooldown_seconds_max ?? 3600}}", "className": "{{(_local.errors?.['spam_security.comment_cooldown_seconds']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -176,8 +174,8 @@ "props": { "type": "number", "name": "spam_security.view_count_cache_ttl", - "min": 60, - "max": 604800, + "min": "{{_local?.form?._meta?.limits?.view_count_cache_ttl_min ?? 60}}", + "max": "{{_local?.form?._meta?.limits?.view_count_cache_ttl_max ?? 604800}}", "className": "{{(_local.errors?.['spam_security.view_count_cache_ttl']) ? 'input-error' : ''}}", "disabled": "{{_computed.isReadOnly}}" } @@ -200,8 +198,7 @@ "text": "$t:sirsoft-board.admin.settings.fields.view_count_cache_ttl_hint" } ], - "props": { - } + "props": {} } ], "props": { @@ -379,8 +376,7 @@ ] } ], - "props": { - } + "props": {} } ] } diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php b/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php new file mode 100644 index 00000000..fea8598d --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php @@ -0,0 +1,64 @@ + $limit, + 'attempted' => $attempted, + ])); + } + + /** + * 허용 최대 개수를 반환합니다. + * + * @return int 최대 첨부 개수 + */ + public function getLimit(): int + { + return $this->limit; + } + + /** + * 시도한 총 개수를 반환합니다. + * + * @return int 연결 시도 개수 + */ + public function getAttempted(): int + { + return $this->attempted; + } + + /** + * 컨트롤러 catch 와 동일한 422 응답으로 렌더링합니다. + * + * @return JsonResponse 상한 초과 응답 + */ + public function render(): JsonResponse + { + return ResponseHelper::error($this->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); + } +} diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php b/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php new file mode 100644 index 00000000..2f2c4046 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php @@ -0,0 +1,51 @@ + $limit, + ])); + } + + /** + * 허용 최대 깊이를 반환합니다. + * + * @return int 최대 깊이 + */ + public function getLimit(): int + { + return $this->limit; + } + + /** + * 시도한 깊이를 반환합니다. + * + * @return int 시도 깊이 + */ + public function getAttempted(): int + { + return $this->attempted; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php b/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php new file mode 100644 index 00000000..bce1a68f --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php @@ -0,0 +1,59 @@ +reason; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php index 4559089e..db7a56da 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Http\Controllers\Admin; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; +use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException; use Modules\Sirsoft\Board\Http\Requests\ReorderAttachmentsRequest; use Modules\Sirsoft\Board\Http\Requests\UploadAttachmentRequest; @@ -81,6 +82,9 @@ class AttachmentController extends AdminBaseController ], 201 ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (BoardNotFoundException $e) { return $this->error('sirsoft-board::messages.boards.not_found', 404); } catch (\Exception $e) { diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php index d62978f1..20943d3b 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; +use Modules\Sirsoft\Board\Exceptions\CommentDepthExceededException; +use Modules\Sirsoft\Board\Exceptions\PostNotCommentableException; use Modules\Sirsoft\Board\Http\Requests\BlindCommentRequest; use Modules\Sirsoft\Board\Http\Requests\RestoreCommentRequest; use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest; @@ -110,6 +112,12 @@ class CommentController extends AdminBaseController ); } catch (ModelNotFoundException) { return $this->notFound('sirsoft-board::messages.boards.not_found'); + } catch (CommentDepthExceededException $e) { + // 요청 단계 검증을 우회해 Service 관문에 걸린 경우 — 사용자 입력 문제이므로 422 + return $this->error($e->getMessage(), 422); + } catch (PostNotCommentableException $e) { + // 블라인드·삭제된 게시글 — 서버 오류가 아니라 게시글 상태 문제이므로 422 + 사유 전달 + return $this->error($e->getMessage(), 422); } catch (\Exception $e) { return $this->error('sirsoft-board::messages.comment.create_failed', 500, $e->getMessage()); } diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php index dae59479..0e71eb38 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php @@ -8,6 +8,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Modules\Sirsoft\Board\Enums\PostStatus; +use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException; use Modules\Sirsoft\Board\Exceptions\PostNotFoundException; use Modules\Sirsoft\Board\Http\Requests\BlindPostRequest; @@ -193,6 +194,9 @@ class PostController extends AdminBaseController new PostResource($post), 201 ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (\Exception $e) { return $this->error('sirsoft-board::messages.posts.create_failed', 500, $e->getMessage()); } @@ -245,6 +249,9 @@ class PostController extends AdminBaseController 'sirsoft-board::messages.posts.update_success', new PostResource($post) ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (ModelNotFoundException $e) { throw new PostNotFoundException($id); } catch (AccessDeniedHttpException $e) { diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php index 4048d2f5..f51568c6 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\Base\PublicBaseController; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Illuminate\Support\Facades\Auth; +use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException; use Modules\Sirsoft\Board\Http\Requests\ReorderAttachmentsRequest; use Modules\Sirsoft\Board\Http\Requests\UploadAttachmentRequest; @@ -175,6 +176,9 @@ class AttachmentController extends PublicBaseController ], 201 ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (BoardNotFoundException $e) { return $this->notFound(__('sirsoft-board::messages.boards.not_found')); } catch (\Exception $e) { diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php index 3ee6d65e..bcd13397 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php @@ -8,6 +8,8 @@ use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use Modules\Sirsoft\Board\Exceptions\CommentDepthExceededException; +use Modules\Sirsoft\Board\Exceptions\PostNotCommentableException; use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest; use Modules\Sirsoft\Board\Http\Requests\UpdateCommentRequest; use Modules\Sirsoft\Board\Http\Requests\VerifyCommentPasswordRequest; @@ -114,6 +116,12 @@ class CommentController extends PublicBaseController ); } catch (ModelNotFoundException) { return $this->error('sirsoft-board::messages.boards.not_found', 404); + } catch (CommentDepthExceededException $e) { + // 요청 단계 검증을 우회해 Service 관문에 걸린 경우 — 사용자 입력 문제이므로 422 + return $this->error($e->getMessage(), 422); + } catch (PostNotCommentableException $e) { + // 블라인드·삭제된 게시글 — 서버 오류가 아니라 게시글 상태 문제이므로 422 + 사유 전달 + return $this->error($e->getMessage(), 422); } catch (\Exception $e) { return $this->error('sirsoft-board::messages.comment.create_failed', 500); } diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php index 24a06376..9b27420b 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Enums\SecretMode; +use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException; use Modules\Sirsoft\Board\Exceptions\PostNotFoundException; use Modules\Sirsoft\Board\Http\Requests\User\StorePostRequest; @@ -320,7 +321,11 @@ class PostController extends PublicBaseController // secret_mode='enabled'인 경우 사용자 선택에 따름 (별도 처리 불필요) // 게시글 생성 - $post = $this->postService->createPost($slug, $data); + // `files[]` 를 Service 로 넘기지 않으면 검증·권한 확인은 통과하고 첨부만 조용히 + // 사라진다 (201 + 첨부 0 건). 위에서 이미 `hasFile('files')` 로 업로드 권한을 + // 확인하고 있으므로 이 경로가 파일을 받는다는 의도는 명확하다. + $files = $request->file('files'); + $post = $this->postService->createPost($slug, $data, is_array($files) ? $files : []); // 쿨다운 캐시 기록 (게시글 생성 성공 후) $spamSecurity = g7_module_settings('sirsoft-board', 'spam_security', []); @@ -338,6 +343,9 @@ class PostController extends PublicBaseController new PostResource($post), 201 ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (BoardNotFoundException $e) { throw $e; } catch (\Exception $e) { @@ -374,7 +382,14 @@ class PostController extends PublicBaseController // 게시글 수정 $data = $request->validated(); - $post = $this->postService->updatePost($slug, $id, $data); + + // `attachment_ids` 를 Service 로 넘기지 않으면 검증(형식·개수 상한 합산)은 통과하고 + // 첨부만 조용히 연결되지 않는다 (200 + 첨부 0 건). 관리자 경로는 넘기고 있으므로 + // 같은 요청이 화면에 따라 다르게 동작했다. + $attachmentIds = $data['attachment_ids'] ?? []; + unset($data['attachment_ids']); + + $post = $this->postService->updatePost($slug, $id, $data, $attachmentIds); // board 관계 수동 설정 $post->setRelation('board', $board); @@ -383,6 +398,9 @@ class PostController extends PublicBaseController 'sirsoft-board::messages.posts.update_success', new PostResource($post) ); + } catch (AttachmentLimitExceededException $e) { + // 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단 + return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']); } catch (AccessDeniedHttpException $e) { return $this->error('auth.scope_denied', 403); } catch (ModelNotFoundException $e) { diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php index 85f90f45..15f81a67 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php @@ -211,11 +211,11 @@ class StoreBoardSettingsRequest extends FormRequest // report_policy (신고 정책) 카테고리 // ======================================== 'report_policy' => ['sometimes', 'array'], - 'report_policy.auto_hide_threshold' => ['nullable', 'integer', 'min:0', 'max:100'], + 'report_policy.auto_hide_threshold' => ['nullable', 'integer', 'min:'.$this->boardLimit('auto_hide_threshold_min', 0), 'max:'.$this->boardLimit('auto_hide_threshold_max', 100)], 'report_policy.auto_hide_target' => ['nullable', 'string', 'in:post,comment,both'], - 'report_policy.daily_report_limit' => ['nullable', 'integer', 'min:0', 'max:100'], - 'report_policy.rejection_limit_count' => ['nullable', 'integer', 'min:0', 'max:50'], - 'report_policy.rejection_limit_days' => ['nullable', 'integer', 'min:1', 'max:365'], + 'report_policy.daily_report_limit' => ['nullable', 'integer', 'min:'.$this->boardLimit('daily_report_limit_min', 0), 'max:'.$this->boardLimit('daily_report_limit_max', 100)], + 'report_policy.rejection_limit_count' => ['nullable', 'integer', 'min:'.$this->boardLimit('rejection_limit_count_min', 0), 'max:'.$this->boardLimit('rejection_limit_count_max', 50)], + 'report_policy.rejection_limit_days' => ['nullable', 'integer', 'min:'.$this->boardLimit('rejection_limit_days_min', 1), 'max:'.$this->boardLimit('rejection_limit_days_max', 365)], 'report_policy.notify_admin_on_report' => ['nullable', 'boolean'], 'report_policy.notify_admin_on_report_scope' => ['nullable', 'string', 'in:per_case,per_report'], 'report_policy.notify_admin_on_report_channels' => ['nullable', 'array'], @@ -244,10 +244,10 @@ class StoreBoardSettingsRequest extends FormRequest // spam_security (스팸/보안) 카테고리 // ======================================== 'spam_security' => ['sometimes', 'array'], - 'spam_security.post_cooldown_seconds' => ['nullable', 'integer', 'min:0', 'max:3600'], - 'spam_security.comment_cooldown_seconds' => ['nullable', 'integer', 'min:0', 'max:3600'], - 'spam_security.report_cooldown_seconds' => ['nullable', 'integer', 'min:0', 'max:3600'], - 'spam_security.view_count_cache_ttl' => ['nullable', 'integer', 'min:60', 'max:604800'], + 'spam_security.post_cooldown_seconds' => ['nullable', 'integer', 'min:'.$this->boardLimit('post_cooldown_seconds_min', 0), 'max:'.$this->boardLimit('post_cooldown_seconds_max', 3600)], + 'spam_security.comment_cooldown_seconds' => ['nullable', 'integer', 'min:'.$this->boardLimit('comment_cooldown_seconds_min', 0), 'max:'.$this->boardLimit('comment_cooldown_seconds_max', 3600)], + 'spam_security.report_cooldown_seconds' => ['nullable', 'integer', 'min:'.$this->boardLimit('report_cooldown_seconds_min', 0), 'max:'.$this->boardLimit('report_cooldown_seconds_max', 3600)], + 'spam_security.view_count_cache_ttl' => ['nullable', 'integer', 'min:'.$this->boardLimit('view_count_cache_ttl_min', 60), 'max:'.$this->boardLimit('view_count_cache_ttl_max', 604800)], // ======================================== // seo (SEO 설정) 카테고리 diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php index d732f2a6..8323a609 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php @@ -47,6 +47,24 @@ trait ReadsBoardLimits 'max_comment_depth_min' => 0, 'max_comment_depth_max' => 10, + 'auto_hide_threshold_min' => 0, + 'auto_hide_threshold_max' => 100, + 'daily_report_limit_min' => 0, + 'daily_report_limit_max' => 100, + 'rejection_limit_count_min' => 0, + 'rejection_limit_count_max' => 50, + 'rejection_limit_days_min' => 1, + 'rejection_limit_days_max' => 365, + + 'post_cooldown_seconds_min' => 0, + 'post_cooldown_seconds_max' => 3600, + 'comment_cooldown_seconds_min' => 0, + 'comment_cooldown_seconds_max' => 3600, + 'report_cooldown_seconds_min' => 0, + 'report_cooldown_seconds_max' => 3600, + 'view_count_cache_ttl_min' => 60, + 'view_count_cache_ttl_max' => 604800, + 'new_display_hours_min' => 0, 'new_display_hours_max' => 720, ]; @@ -72,10 +90,11 @@ trait ReadsBoardLimits * 특정 제한값을 반환합니다. * * @param string $key 제한값 키 (예: per_page_min) + * @param int|null $default 키가 없을 때 쓸 값 (미지정 시 0) * @return int 제한값 */ - protected function boardLimit(string $key): int + protected function boardLimit(string $key, ?int $default = null): int { - return (int) ($this->boardLimits()[$key] ?? 0); + return (int) ($this->boardLimits()[$key] ?? $default ?? 0); } } diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ValidatesAttachmentCount.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ValidatesAttachmentCount.php new file mode 100644 index 00000000..f7167059 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ValidatesAttachmentCount.php @@ -0,0 +1,120 @@ +max_file_count ?? 0); + if ($limit <= 0) { + return; + } + + $files = $this->file('files'); + $newCount = is_array($files) ? count($files) : 0; + + $attachmentIds = $this->input('attachment_ids'); + $newCount += is_array($attachmentIds) ? count($attachmentIds) : 0; + + $tempKey = $this->input('temp_key'); + if (is_string($tempKey) && $tempKey !== '') { + $newCount += Attachment::query() + ->where('temp_key', $tempKey) + ->whereNull('post_id') + ->count(); + } + + if ($newCount <= 0) { + return; + } + + $existing = $postId !== null + ? Attachment::query()->where('board_id', $board->id)->where('post_id', $postId)->count() + : 0; + + if ($existing + $newCount > $limit) { + $validator->errors()->add('files', __('sirsoft-board::messages.errors.attachment_limit_exceeded', [ + 'limit' => $limit, + 'attempted' => $existing + $newCount, + ])); + } + } + + /** + * 단일 파일 업로드 1건이 게시판 상한을 넘으면 도메인 예외로 선차단합니다. + * + * 합산 기준은 `AttachmentService::assertAttachmentCountWithin()` 과 동일합니다 — + * `post_id` 가 있으면 그 게시글의 기존 첨부, 없으면 `temp_key` 의 임시첨부를 기준으로 + * 삼습니다(둘을 더하지 않습니다). 두 계층의 판정이 갈리면 화면과 서버가 다른 답을 + * 내놓게 되므로 우선순위까지 같아야 합니다. + * + * 필드 검증 오류가 아니라 `AttachmentLimitExceededException` 을 던지는 이유는 응답 형태 + * 때문입니다 — 이 엔드포인트의 상한 초과 응답은 `errors.code = attachment_limit_exceeded` + * 로 공개 문서화되어 있고, 선차단이 다른 형태를 내놓으면 업로더가 두 응답을 분기해야 + * 합니다. 예외의 `render()` 가 컨트롤러 catch 와 같은 응답을 만듭니다. + * + * @param Board|null $board 대상 게시판 + * @param int|null $postId 대상 게시글 ID (임시 업로드면 null) + * @param string|null $tempKey 임시 업로드 키 + * + * @throws AttachmentLimitExceededException 상한 초과 시 + */ + protected function assertSingleAttachmentSlotAvailable( + ?Board $board, + ?int $postId = null, + ?string $tempKey = null + ): void { + if (! $board) { + return; + } + + $limit = (int) ($board->max_file_count ?? 0); + if ($limit <= 0) { + return; + } + + $existing = 0; + if ($postId !== null) { + $existing = Attachment::query() + ->where('board_id', $board->id) + ->where('post_id', $postId) + ->count(); + } elseif (is_string($tempKey) && $tempKey !== '') { + // 임시첨부는 아직 게시판에 귀속되기 전이라 board_id 로 좁히지 않는다 + // (`validateAttachmentTotal()` 의 temp_key 합산과 동일 기준). + $existing = Attachment::query() + ->where('temp_key', $tempKey) + ->whereNull('post_id') + ->count(); + } + + if ($existing + 1 > $limit) { + throw new AttachmentLimitExceededException($limit, $existing + 1); + } + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/StorePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/StorePostRequest.php index feaf4c7e..76e7d1b4 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/StorePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/StorePostRequest.php @@ -3,10 +3,12 @@ namespace Modules\Sirsoft\Board\Http\Requests; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions; +use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Models\Post; use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; @@ -19,6 +21,7 @@ use Modules\Sirsoft\Board\Rules\ParentPostValidationRule; class StorePostRequest extends FormRequest { use ResolvesAllowedExtensions; + use ValidatesAttachmentCount; /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 @@ -166,4 +169,17 @@ class StorePostRequest extends FormRequest 'files.*' => __('sirsoft-board::validation.attributes.post.file'), ]; } + + /** + * 필드 단위 규칙으로 판정할 수 없는 첨부 총합을 검증합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $board = Board::where('slug', $this->route('slug'))->first(); + $this->validateAttachmentTotal($validator, $board); + }); + } } diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdatePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdatePostRequest.php index 5b5f4abe..fab2e3d6 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdatePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdatePostRequest.php @@ -3,9 +3,11 @@ namespace Modules\Sirsoft\Board\Http\Requests; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Modules\Sirsoft\Board\Enums\PostStatus; +use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; @@ -14,8 +16,12 @@ use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; */ class UpdatePostRequest extends FormRequest { + use ValidatesAttachmentCount; + /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -107,4 +113,20 @@ class UpdatePostRequest extends FormRequest 'status' => __('sirsoft-board::validation.attributes.post.status'), ]; } -} \ No newline at end of file + + /** + * 필드 단위 규칙으로 판정할 수 없는 첨부 총합을 검증합니다. + * + * 수정은 이미 연결된 첨부와 합산해 판정합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $board = Board::where('slug', $this->route('slug'))->first(); + $postId = $this->route('id'); + $this->validateAttachmentTotal($validator, $board, is_numeric($postId) ? (int) $postId : null); + }); + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/UploadAttachmentRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/UploadAttachmentRequest.php index 33486c94..551f87ad 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/UploadAttachmentRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/UploadAttachmentRequest.php @@ -3,16 +3,24 @@ namespace Modules\Sirsoft\Board\Http\Requests; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions; +use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; use Modules\Sirsoft\Board\Models\Board; /** * 게시판 첨부파일 업로드 요청 검증 + * + * 첨부 개수 상한은 게시글 작성 Request 들과 같이 여기서도 선차단한다 — 최종 불변조건은 + * `AttachmentService::assertAttachmentCountWithin()` 이 담당하지만(모든 경로가 지나는 + * SSoT), 요청 계층에서 막으면 다른 검증 오류와 같은 422 필드 오류 형태로 돌아가 업로더가 + * 응답을 분기하지 않아도 된다. 두 계층은 합산 기준·우선순위가 동일하므로 판정이 갈리지 않는다. */ class UploadAttachmentRequest extends FormRequest { use ResolvesAllowedExtensions; + use ValidatesAttachmentCount; /** * 요청 권한 확인 @@ -91,6 +99,30 @@ class UploadAttachmentRequest extends FormRequest ]; } + /** + * 첨부 개수 상한을 선차단합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + // 필드 규칙(용량·확장자)이 이미 걸린 요청에는 개수 판정을 덧붙이지 않는다 — + // 파일 자체가 거부될 요청까지 상한 초과로 보고하면 원인이 뒤바뀐다. + if ($validator->errors()->isNotEmpty()) { + return; + } + + $postId = $this->input('post_id'); + + $this->assertSingleAttachmentSlotAvailable( + Board::where('slug', $this->route('slug'))->first(), + $postId !== null && $postId !== '' ? (int) $postId : null, + $this->input('temp_key') + ); + }); + } + /** * 검증 전 데이터 준비 */ diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php index 9d75dc26..7c53f6a8 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php @@ -3,12 +3,14 @@ namespace Modules\Sirsoft\Board\Http\Requests\User; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rule; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions; +use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Models\Post; use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; @@ -26,6 +28,7 @@ use Modules\Sirsoft\Board\Rules\ParentPostValidationRule; class StorePostRequest extends FormRequest { use ResolvesAllowedExtensions; + use ValidatesAttachmentCount; /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 @@ -213,4 +216,17 @@ class StorePostRequest extends FormRequest return $validated; } + + /** + * 필드 단위 규칙으로 판정할 수 없는 첨부 총합을 검증합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $board = Board::where('slug', $this->route('slug'))->first(); + $this->validateAttachmentTotal($validator, $board); + }); + } } diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php index d2c209ec..ce75e6ec 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php @@ -3,9 +3,11 @@ namespace Modules\Sirsoft\Board\Http\Requests\User; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Modules\Sirsoft\Board\Enums\PostStatus; +use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; @@ -19,8 +21,12 @@ use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; */ class UpdatePostRequest extends FormRequest { + use ValidatesAttachmentCount; + /** * 사용자가 이 요청을 수행할 권한이 있는지 확인 + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -117,4 +123,20 @@ class UpdatePostRequest extends FormRequest 'status' => __('sirsoft-board::validation.attributes.post.status'), ]; } + + /** + * 필드 단위 규칙으로 판정할 수 없는 첨부 총합을 검증합니다. + * + * 수정은 이미 연결된 첨부와 합산해 판정합니다. + * + * @param Validator $validator 검증기 + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $board = Board::where('slug', $this->route('slug'))->first(); + $postId = $this->route('id'); + $this->validateAttachmentTotal($validator, $board, is_numeric($postId) ? (int) $postId : null); + }); + } } diff --git a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php index 582ba756..a84c572e 100644 --- a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php +++ b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php @@ -4,12 +4,15 @@ namespace Modules\Sirsoft\Board\Services; use App\Contracts\Extension\StorageInterface; use App\Extension\HookManager; +use App\Helpers\PermissionHelper; +use App\Support\ImageResizer; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; -use App\Helpers\PermissionHelper; +use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Board\Models\Attachment; use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface; use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; @@ -35,6 +38,56 @@ class AttachmentService private StorageInterface $storage ) {} + /** + * 첨부 개수가 게시판 상한을 넘지 않는지 검증합니다 (도메인 불변조건). + * + * 총합은 **직접 업로드 + `attachment_ids` + `temp_key` 임시첨부 + 기존 첨부** 4소스이고 + * temp_key 쪽은 DB 조회가 필요하므로 FormRequest 만으로는 판정할 수 없습니다. + * 모든 연결 경로가 반드시 지나는 이 지점이 상한의 SSoT 입니다. + * (FormRequest 의 `files` max 규칙은 "글을 다 쓰고 저장 실패" 를 막는 UX 선차단이며 + * 역할이 다릅니다 — 둘 다 필요합니다.) + * + * @param string $slug 게시판 슬러그 + * @param int|null $postId 게시글 ID (신규 작성 중이면 null) + * @param int $additional 이번에 추가하려는 첨부 개수 + * @param string|null $tempKey 임시 업로드 키 (신규 작성 시 기존 임시첨부 합산용) + * + * @throws AttachmentLimitExceededException 상한 초과 시 + */ + public function assertAttachmentCountWithin( + string $slug, + ?int $postId, + int $additional, + ?string $tempKey = null + ): void { + if ($additional <= 0) { + return; + } + + $board = $this->boardRepository->findBySlug($slug); + if (! $board) { + throw new ModelNotFoundException(__('sirsoft-board::messages.errors.board_not_found')); + } + + $limit = (int) ($board->max_file_count ?? 0); + if ($limit <= 0) { + // 상한 미설정 게시판은 제한하지 않는다 (기존 동작 유지) + return; + } + + $existing = 0; + if ($postId !== null) { + $existing = $this->repository->getByPost($slug, $postId, 'attachments')->count(); + } elseif ($tempKey !== null) { + $existing = $this->repository->getByTempKey($slug, $tempKey, 'attachments')->count(); + } + + $total = $existing + $additional; + if ($total > $limit) { + throw new AttachmentLimitExceededException($limit, $total); + } + } + /** * 단일 파일 업로드 * @@ -55,6 +108,9 @@ class AttachmentService string $collection = 'attachments', ?string $tempKey = null ): Attachment { + // 개수 상한 최종 방어선 — 단일 업로드를 N 번 반복하는 경로도 여기서 막힌다 + $this->assertAttachmentCountWithin($slug, $postId, 1, $tempKey); + // Before 훅 HookManager::doAction('sirsoft-board.attachment.before_upload', $slug, $file, $postId); @@ -72,6 +128,10 @@ class AttachmentService $path = "{$slug}/temp/{$tempKey}/{$storedFilename}"; } + // 환경설정 > 업로드의 최대 가로/세로·품질을 적용한다 (코어 설정이 모든 업로드 경로에 동일 적용). + // 임시 파일을 제자리에서 줄이므로 아래의 저장·크기 계산이 모두 축소본을 본다. + app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType()); + // 스토리지에 파일 저장 (category: 'attachments') $this->storage->put('attachments', $path, file_get_contents($file->getRealPath())); @@ -182,6 +242,11 @@ class AttachmentService } $tempAttachments = $this->repository->getByTempKey($slug, $tempKey); + + // 개수 상한 최종 방어선 — FormRequest 는 files[] 만 보므로 temp_key 경로가 그대로 뚫린다. + // 이미 연결된 첨부와 합산해 판정한다 (수정 시 기존 첨부 + 신규 임시첨부). + $this->assertAttachmentCountWithin($slug, $postId, $tempAttachments->count()); + $linkedCount = 0; foreach ($tempAttachments as $attachment) { @@ -230,7 +295,7 @@ class AttachmentService * @param string $slug 게시판 슬러그 * @param string $tempKey 임시 업로드 키 * @param string|null $collection 컬렉션 필터 - * @return \Illuminate\Database\Eloquent\Collection + * @return Collection */ public function getTempAttachments(string $slug, string $tempKey, ?string $collection = null) { @@ -258,7 +323,6 @@ class AttachmentService * * @param string $slug 게시판 슬러그 * @param Attachment $attachment 첨부파일 모델 - * @return void * * @throws AccessDeniedHttpException 삭제글 첨부에 권한 없이 접근한 경우 */ @@ -402,7 +466,7 @@ class AttachmentService * @param string $slug 게시판 슬러그 * @param int $postId 게시글 ID * @param string|null $collection 컬렉션 필터 - * @return \Illuminate\Database\Eloquent\Collection + * @return Collection */ public function getAttachments(string $slug, int $postId, ?string $collection = null) { diff --git a/modules/_bundled/sirsoft-board/src/Services/CommentService.php b/modules/_bundled/sirsoft-board/src/Services/CommentService.php index aa9c358e..0c343050 100644 --- a/modules/_bundled/sirsoft-board/src/Services/CommentService.php +++ b/modules/_bundled/sirsoft-board/src/Services/CommentService.php @@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; use Modules\Sirsoft\Board\Enums\PostStatus; +use Modules\Sirsoft\Board\Exceptions\CommentDepthExceededException; +use Modules\Sirsoft\Board\Exceptions\PostNotCommentableException; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Models\Comment; use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; @@ -208,18 +210,18 @@ class CommentService * @return bool 댓글 작성 가능 여부 * * @throws ModelNotFoundException 게시글을 찾을 수 없는 경우 - * @throws \Exception 블라인드/삭제된 게시글인 경우 + * @throws PostNotCommentableException 블라인드/삭제된 게시글인 경우 */ public function validatePostForComment(string $slug, int $postId): bool { $post = $this->postRepository->findOrFail($slug, $postId); if ($post->status === PostStatus::Blinded) { - throw new \Exception(__('sirsoft-board::messages.comment.post_blinded')); + throw PostNotCommentableException::blinded(); } if ($post->status === PostStatus::Deleted || $post->deleted_at) { - throw new \Exception(__('sirsoft-board::messages.comment.post_deleted')); + throw PostNotCommentableException::deleted(); } return true; @@ -257,8 +259,17 @@ class CommentService // 같은 게시글에 속한 부모 댓글만 인정 (교차 게시글 부모 차단) $parentComment = $this->commentRepository->find($slug, $data['parent_id'], (int) $data['post_id']); if ($parentComment) { - // 부모 댓글의 depth + 1 — 상한은 CommentValidationRule 이 게시판 설정으로 검증 $data['depth'] = ($parentComment->depth ?? 0) + 1; + + // 최종 불변조건 — `CommentValidationRule` 은 요청 단계 선차단이라 훅이나 + // Service 직접 호출 경로에는 걸리지 않는다. 클램프하지 않고 예외를 던진다: + // 요청한 위치와 다른 자리에 조용히 붙으면 사용자가 알 수 없다. + $board = $this->boardRepository->findBySlug($slug); + $maxDepth = (int) ($board->max_comment_depth ?? 0); + + if ($data['depth'] > $maxDepth) { + throw new CommentDepthExceededException($maxDepth, $data['depth']); + } } else { // 부모 댓글을 찾을 수 없으면 0으로 설정 $data['depth'] = 0; diff --git a/modules/_bundled/sirsoft-board/src/Services/PostService.php b/modules/_bundled/sirsoft-board/src/Services/PostService.php index 3a09ce17..aaa4acac 100644 --- a/modules/_bundled/sirsoft-board/src/Services/PostService.php +++ b/modules/_bundled/sirsoft-board/src/Services/PostService.php @@ -85,6 +85,7 @@ class PostService * @param string $slug 게시판 슬러그 * @param array $filters 필터 조건 * @param bool $withTrashed 삭제된 게시글 포함 여부 + * @param string $context 호출 컨텍스트 (admin/user — 권한 스코프 판정용) * @return int 일반 게시글 수 (답글, 공지 제외) * * @throws ModelNotFoundException 게시판을 찾을 수 없는 경우 @@ -145,6 +146,7 @@ class PostService * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @param string $context 호출 컨텍스트 (admin/user — 권한 스코프 판정용) * @return Post 게시글 모델 * * @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우 @@ -393,8 +395,9 @@ class PostService * @param int $id 게시글 ID * @param string $reason 블라인드 사유 * @param string|null $triggerType 트리거 유형 (admin, report 등) + * @return Post 블라인드 처리된 게시글 모델 * - * @throws ModelNotFoundException + * @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우 */ public function blindPost(string $slug, int $id, string $reason, ?string $triggerType = null): Post { @@ -519,6 +522,7 @@ class PostService * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @param int|null $boardId 이미 검증된 게시판 ID (중복 조회 방지용) * @return Post 게시글 모델 (카운트 포함) * * @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우 @@ -629,6 +633,7 @@ class PostService * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID * @param bool $canViewDeleted 삭제된 게시글 조회 가능 여부 + * @param string $context 호출 컨텍스트 (admin/user — 권한 스코프 판정용) * @return Post 상세 정보가 포함된 게시글 모델 * * @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우 @@ -725,6 +730,9 @@ class PostService ): void { // 방안 A: 업로드 완료된 첨부파일 ID로 연결 (신규 방식) if (! empty($attachmentIds)) { + // 개수 상한 최종 방어선 (기존 연결분과 합산) — Request 는 files[] 만 세므로 여기서 다시 판정한다 + $this->attachmentService->assertAttachmentCountWithin($slug, $postId, count($attachmentIds)); + $linkedCount = $this->attachmentRepository->linkAttachmentsByIds($slug, $attachmentIds, $postId); if ($linkedCount > 0) { Log::info("{$context} 시 첨부파일 ID로 연결 완료", [ @@ -1154,6 +1162,7 @@ class PostService * * @param array $boardIds 검색 대상 게시판 ID 목록 * @param string $keyword 검색 키워드 + * @return int 일치하는 게시글 수 */ public function countAcrossBoards(array $boardIds, string $keyword): int { diff --git a/modules/_bundled/sirsoft-board/src/lang/en/messages.php b/modules/_bundled/sirsoft-board/src/lang/en/messages.php index 7ea248e1..925196f5 100644 --- a/modules/_bundled/sirsoft-board/src/lang/en/messages.php +++ b/modules/_bundled/sirsoft-board/src/lang/en/messages.php @@ -220,6 +220,7 @@ return [ 'table_creation_failed' => 'Failed to create board table (:table): :error', 'category_in_use' => 'Category :category is in use by :count post(s).', 'board_not_found' => 'Board not found.', + 'attachment_limit_exceeded' => 'You can attach up to :limit file(s). (requested: :attempted)', 'post_not_found' => 'Post not found.', 'permission_denied' => 'Permission denied.', 'validation_failed' => 'Validation failed.', diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php index 1492acb9..10041c1e 100644 --- a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php +++ b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php @@ -220,6 +220,7 @@ return [ 'table_creation_failed' => '게시판 테이블 생성에 실패했습니다 (:table): :error', 'category_in_use' => ':category 카테고리는 :count개의 게시글에서 사용 중입니다.', 'board_not_found' => '게시판을 찾을 수 없습니다.', + 'attachment_limit_exceeded' => '첨부파일은 최대 :limit개까지 등록할 수 있습니다. (요청: :attempted개)', 'post_not_found' => '게시글을 찾을 수 없습니다.', 'permission_denied' => '권한이 없습니다.', 'validation_failed' => '입력값이 올바르지 않습니다.', diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php index ad92f418..5e0504de 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php @@ -553,6 +553,88 @@ class BoardSettingsControllerTest extends ModuleTestCase ->assertJsonValidationErrors(['basic_defaults.per_page_mobile']); } + /** + * basic_defaults.max_file_count 는 config max_file_count_max(20)까지 허용 (#493 B2) + */ + public function test_settings_request_max_file_count_allows_config_max(): void + { + $response = $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'basic_defaults' => ['max_file_count' => 20], + ]); + + $response->assertStatus(200); + } + + /** + * basic_defaults.max_file_count 가 config max(20) 초과 시 422 (#493 B2) + */ + public function test_settings_request_max_file_count_rejects_above_config_max(): void + { + $response = $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'basic_defaults' => ['max_file_count' => 21], + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['basic_defaults.max_file_count']); + } + + /** + * 제한값 설정을 바꾸면 저장 규칙이 따라오는지 확인 (#493 B2 — config 가 SSoT) + * + * 규칙이 리터럴로 되돌아가면 설정을 바꿔도 경계가 그대로라 이 테스트가 먼저 깨진다. + */ + public function test_settings_request_bounds_follow_config(): void + { + config(['sirsoft-board.limits.max_file_count_max' => 3]); + + $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'basic_defaults' => ['max_file_count' => 3], + ]) + ->assertStatus(200); + + $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'basic_defaults' => ['max_file_count' => 4], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['basic_defaults.max_file_count']); + } + + /** + * report_policy.auto_hide_threshold 는 0(자동 숨김 비활성)을 저장할 수 있다 (#493 B4) + * + * 화면 안내가 "0=비활성" 이므로 서버가 0 을 거부하면 비활성으로 되돌릴 방법이 없다. + */ + public function test_settings_request_auto_hide_threshold_allows_zero(): void + { + $response = $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'report_policy' => ['auto_hide_threshold' => 0], + ]); + + $response->assertStatus(200); + + $content = json_decode(File::get($this->settingsStoragePath.'/report_policy.json'), true); + $this->assertSame(0, (int) $content['auto_hide_threshold']); + } + + /** + * report_policy.auto_hide_threshold 가 config max(100) 초과 시 422 (#493 B4) + */ + public function test_settings_request_auto_hide_threshold_rejects_above_config_max(): void + { + $response = $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + 'report_policy' => ['auto_hide_threshold' => 101], + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['report_policy.auto_hide_threshold']); + } + // ======================================== // clearCache (캐시 초기화) 테스트 // ======================================== diff --git a/modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php b/modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php new file mode 100644 index 00000000..c31ecc52 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php @@ -0,0 +1,522 @@ + $slug, + 'name' => ['ko' => '첨부 상한 게시판', 'en' => 'Attachment Limit Board'], + 'is_active' => true, + 'use_file_upload' => true, + 'max_file_count' => 5, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setGuestPermissions(['posts.read', 'posts.write']); + $this->grantUserRolePermissions(['posts.read', 'posts.write', 'attachments.upload']); + + $this->memberUser = User::factory()->create(['email' => 'attachment-limit@test.com']); + + $userRole = Role::where('identifier', 'user')->first(); + if ($userRole) { + $this->memberUser->roles()->attach($userRole->id); + } + } + + /** + * 임시 첨부(temp_key) 를 N 개 만듭니다. + * + * @param string $tempKey 임시 업로드 키 + * @param int $count 생성 개수 + */ + private function seedTempAttachments(string $tempKey, int $count): void + { + for ($i = 0; $i < $count; $i++) { + Attachment::create([ + 'board_id' => 0, + 'post_id' => null, + 'temp_key' => $tempKey, + 'collection' => 'attachments', + 'original_filename' => "temp-{$i}.png", + 'stored_filename' => "temp-{$i}.png", + 'path' => "{$this->getTestBoardSlug()}/temp/{$tempKey}/temp-{$i}.png", + 'mime_type' => 'image/png', + 'size' => 100, + 'order' => $i, + ]); + } + } + + /** + * 게시글에 연결된 첨부를 N 개 만듭니다. + * + * @param int $postId 게시글 ID + * @param int $count 생성 개수 + */ + private function seedLinkedAttachments(int $postId, int $count): void + { + $board = $this->board; + + for ($i = 0; $i < $count; $i++) { + Attachment::create([ + 'board_id' => $board->id, + 'post_id' => $postId, + 'collection' => 'attachments', + 'original_filename' => "linked-{$i}.png", + 'stored_filename' => "linked-{$i}.png", + 'path' => "{$this->getTestBoardSlug()}/2026/07/27/linked-{$i}.png", + 'mime_type' => 'image/png', + 'size' => 100, + 'order' => $i, + ]); + } + } + + /** + * temp_key 경로로 상한을 초과하면 차단된다 (실측 재현된 우회 경로). + */ + public function test_temp_key_link_over_limit_is_blocked(): void + { + $postId = $this->createTestPost(); + $tempKey = 'tk_'.str_repeat('a', 20); + $this->seedTempAttachments($tempKey, 8); + + $this->expectException(AttachmentLimitExceededException::class); + + app(AttachmentService::class)->linkTempAttachmentsWithMove( + $this->getTestBoardSlug(), + $tempKey, + $postId + ); + } + + /** + * 상한과 정확히 같은 개수는 통과한다 (경계). + */ + public function test_temp_key_link_at_limit_passes(): void + { + $postId = $this->createTestPost(); + $tempKey = 'tk_'.str_repeat('b', 20); + $this->seedTempAttachments($tempKey, 5); + + $linked = app(AttachmentService::class)->linkTempAttachmentsWithMove( + $this->getTestBoardSlug(), + $tempKey, + $postId + ); + + $this->assertSame(5, $linked); + } + + /** + * 기존 첨부와 합산해 상한을 넘으면 차단된다 (수정 경로). + */ + public function test_existing_plus_new_over_limit_is_blocked(): void + { + $postId = $this->createTestPost(); + $this->seedLinkedAttachments($postId, 4); + + $tempKey = 'tk_'.str_repeat('c', 20); + $this->seedTempAttachments($tempKey, 2); + + $this->expectException(AttachmentLimitExceededException::class); + + app(AttachmentService::class)->linkTempAttachmentsWithMove( + $this->getTestBoardSlug(), + $tempKey, + $postId + ); + } + + /** + * 기존 4 + 신규 1 = 5 는 통과한다 (경계). + */ + public function test_existing_plus_new_at_limit_passes(): void + { + $postId = $this->createTestPost(); + $this->seedLinkedAttachments($postId, 4); + + $tempKey = 'tk_'.str_repeat('d', 20); + $this->seedTempAttachments($tempKey, 1); + + $linked = app(AttachmentService::class)->linkTempAttachmentsWithMove( + $this->getTestBoardSlug(), + $tempKey, + $postId + ); + + $this->assertSame(1, $linked); + } + + /** + * attachment_ids 경로도 합산 판정된다. + */ + public function test_attachment_ids_over_limit_is_blocked(): void + { + $postId = $this->createTestPost(); + $this->seedLinkedAttachments($postId, 5); + + $this->expectException(AttachmentLimitExceededException::class); + + app(AttachmentService::class)->assertAttachmentCountWithin( + $this->getTestBoardSlug(), + $postId, + 1 + ); + } + + /** + * 관리자 수정 경로에서 상한 초과가 500 이 아닌 422 로 응답되는지 검증합니다. + * + * FormRequest 선차단은 UX 용이고 최종 불변조건은 Service 예외다. 그런데 관리자 수정 + * 컨트롤러의 catch 가 게시글 *조회* try 에 붙어 있어, 예외를 실제로 던지는 저장 try 는 + * 이 예외를 잡지 못하고 generic 500 으로 떨어졌다. 조회 mock 이 정상 반환하고 저장 mock 이 + * 예외를 던지는 구성으로 그 매핑만 격리해 검증한다. + */ + public function test_admin_update_maps_attachment_limit_to_422(): void + { + $slug = $this->getTestBoardSlug(); + $admin = $this->createAdminUser([ + "sirsoft-board.{$slug}.admin.posts.write", + "sirsoft-board.{$slug}.admin.manage", + ]); + + $postId = $this->createTestPost(['user_id' => $admin->id]); + $post = Post::find($postId); + + $mock = Mockery::mock(PostService::class); + $mock->shouldReceive('getPost')->andReturn($post); + $mock->shouldReceive('updatePost') + ->andThrow(new AttachmentLimitExceededException(5, 6)); + $this->app->instance(PostService::class, $mock); + + $this->actingAs($admin) + ->putJson("/api/modules/sirsoft-board/admin/board/{$slug}/posts/{$postId}", [ + 'title' => '상한 초과 수정 시도', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + ]) + ->assertStatus(422) + ->assertJsonPath('errors.code', 'attachment_limit_exceeded'); + } + + /** + * 직접 업로드와 `attachment_ids` 를 섞어 보내도 합산 판정된다. + * + * 소스가 나뉘면 각 소스는 상한 이하인데 총합만 초과하는 조합이 만들어진다. 어느 한 소스만 + * 세는 구현은 이 조합을 그대로 통과시킨다. + */ + public function test_mixed_files_and_attachment_ids_over_limit_is_blocked(): void + { + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + // 기존 첨부 3 개 — 이 중 3 개를 attachment_ids 로 유지하면서 새 파일 3 개를 더 보낸다 + $this->seedLinkedAttachments($postId, 3); + $keepIds = Attachment::where('post_id', $postId)->pluck('id')->all(); + + $response = $this->actingAs($this->memberUser)->putJson( + "/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}", + [ + 'title' => '혼합 소스 상한 초과', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + 'attachment_ids' => $keepIds, + 'files' => [ + UploadedFile::fake()->image('mix-1.png'), + UploadedFile::fake()->image('mix-2.png'), + UploadedFile::fake()->image('mix-3.png'), + ], + ] + ); + + $response->assertStatus(422); + $this->assertArrayHasKey('files', $response->json('errors') ?? []); + } + + /** + * 작성 시 직접 업로드가 상한과 정확히 같은 개수면 통과하고 실제로 저장된다 (경계). + * + * 합산 판정이 들어간 뒤로 `files[]` 경로는 초과 케이스만 고정돼 있었다. 판정식이 + * `>=` 로 어긋나거나 기존 첨부 0 건을 1 건으로 세면 상한 동수가 조용히 막히는데, + * 초과 케이스만 보는 테스트는 그 회귀를 통과시킨다. 저장 건수까지 단언해 "차단은 + * 안 했지만 첨부도 안 됐다" 는 절반짜리 통과를 배제한다. + */ + public function test_files_at_limit_on_create_passes_and_persists(): void + { + $response = $this->actingAs($this->memberUser, 'sanctum')->postJson( + "/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts", + [ + 'title' => '상한 동수 첨부', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + 'files' => [ + UploadedFile::fake()->image('at-1.png'), + UploadedFile::fake()->image('at-2.png'), + UploadedFile::fake()->image('at-3.png'), + UploadedFile::fake()->image('at-4.png'), + UploadedFile::fake()->image('at-5.png'), + ], + ] + ); + + $response->assertStatus(201); + + $postId = (int) $response->json('data.id'); + $this->assertSame(5, Attachment::where('post_id', $postId)->count()); + } + + /** + * 작성 시 상한을 1 건 넘기면 차단된다 (위 경계의 대조군). + */ + public function test_files_over_limit_on_create_is_blocked(): void + { + $response = $this->actingAs($this->memberUser, 'sanctum')->postJson( + "/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts", + [ + 'title' => '상한 초과 첨부', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + 'files' => [ + UploadedFile::fake()->image('over-1.png'), + UploadedFile::fake()->image('over-2.png'), + UploadedFile::fake()->image('over-3.png'), + UploadedFile::fake()->image('over-4.png'), + UploadedFile::fake()->image('over-5.png'), + UploadedFile::fake()->image('over-6.png'), + ], + ] + ); + + $response->assertStatus(422); + $this->assertArrayHasKey('files', $response->json('errors') ?? []); + } + + /** + * 단일 업로드를 반복해도 상한을 넘는 순간 차단된다. + * + * 한 번에 여러 개를 보내는 경로만 막으면, 한 개씩 N 번 부르는 경로로 그대로 우회된다. + */ + public function test_repeated_single_upload_is_blocked_at_the_limit(): void + { + $postId = $this->createTestPost(); + $service = app(AttachmentService::class); + + for ($i = 0; $i < 5; $i++) { + $service->upload( + $this->getTestBoardSlug(), + UploadedFile::fake()->image("single-{$i}.png"), + $postId + ); + } + + $this->assertSame(5, Attachment::where('post_id', $postId)->count()); + + $this->expectException(AttachmentLimitExceededException::class); + + $service->upload( + $this->getTestBoardSlug(), + UploadedFile::fake()->image('single-6.png'), + $postId + ); + } + + /** + * 단일 업로드 엔드포인트가 상한 초과를 422 로 돌려주는지 검증합니다. + * + * 이 엔드포인트는 게시글 작성 Request 들과 마찬가지로 요청 계층에서 선차단하고, + * `AttachmentService::upload()` 가 최종 불변조건으로 남는다(훅·Service 직접 호출 방어). + * + * 두 계층이 같은 응답을 내야 소비자가 분기하지 않는다 — 선차단은 필드 검증 오류가 아니라 + * `AttachmentLimitExceededException` 을 던지고, 그 `render()` 가 컨트롤러 catch 와 + * 동일한 `errors.code=attachment_limit_exceeded` 를 만든다. 이 테스트가 그 동일성을 고정한다. + */ + public function test_single_upload_endpoint_blocks_over_limit_with_422(): void + { + $slug = $this->getTestBoardSlug(); + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + $this->seedLinkedAttachments($postId, 5); + + $this->actingAs($this->memberUser, 'sanctum') + ->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [ + 'file' => UploadedFile::fake()->image('over-limit.png'), + 'post_id' => $postId, + ]) + ->assertStatus(422) + ->assertJsonPath('errors.code', 'attachment_limit_exceeded'); + } + + /** + * 단일 업로드 상한 초과는 Service 에 닿기 전 요청 계층에서 차단된다. + * + * `AttachmentService` 를 통째로 mock 해 `upload()` 가 아예 호출되지 않음을 단언한다 — + * Service 가 최종 관문으로 남아 있어도 요청 계층이 먼저 막아야 선차단이라 부를 수 있다. + */ + public function test_single_upload_over_limit_is_blocked_before_reaching_service(): void + { + $slug = $this->getTestBoardSlug(); + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + $this->seedLinkedAttachments($postId, 5); + + $service = Mockery::mock(AttachmentService::class); + $service->shouldNotReceive('upload'); + $this->app->instance(AttachmentService::class, $service); + + $this->actingAs($this->memberUser, 'sanctum') + ->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [ + 'file' => UploadedFile::fake()->image('blocked-before-service.png'), + 'post_id' => $postId, + ]) + ->assertStatus(422) + ->assertJsonPath('errors.code', 'attachment_limit_exceeded'); + } + + /** + * temp_key 임시 업로드도 상한을 넘는 순간 요청 계층에서 차단된다. + */ + public function test_single_upload_with_temp_key_over_limit_is_blocked(): void + { + $slug = $this->getTestBoardSlug(); + $tempKey = 'temp-single-upload-limit'; + + $this->seedTempAttachments($tempKey, 5); + + $this->actingAs($this->memberUser, 'sanctum') + ->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [ + 'file' => UploadedFile::fake()->image('temp-over-limit.png'), + 'temp_key' => $tempKey, + ]) + ->assertStatus(422) + ->assertJsonPath('errors.code', 'attachment_limit_exceeded'); + } + + /** + * 상한 이내 단일 업로드는 그대로 통과한다 (경계). + */ + public function test_single_upload_endpoint_allows_within_limit(): void + { + $slug = $this->getTestBoardSlug(); + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + $this->seedLinkedAttachments($postId, 4); + + $this->actingAs($this->memberUser, 'sanctum') + ->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [ + 'file' => UploadedFile::fake()->image('within-limit.png'), + 'post_id' => $postId, + ]) + ->assertSuccessful(); + } + + /** + * 수정 시 `attachment_ids` 로 보낸 첨부가 사용자 경로에서도 실제로 연결된다. + * + * 결함: 관리자 컨트롤러는 `attachment_ids` 를 뽑아 Service 로 넘기는데 사용자 컨트롤러는 + * 넘기지 않았다. 그래서 같은 요청이 검증(형식·개수 상한 합산)은 통과하고 200 을 받는데 + * 첨부만 조용히 연결되지 않았다 — 화면에는 성공으로 보이고 첨부만 사라진다. + * 작성 경로의 `files[]` 유실과 같은 계열이며, 두 경로가 같은 강도여야 한다. + */ + public function test_update_links_attachment_ids_on_user_path(): void + { + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + $tempKey = 'tk_'.str_repeat('u', 20); + $this->seedTempAttachments($tempKey, 2); + $attachmentIds = Attachment::where('temp_key', $tempKey)->pluck('id')->all(); + + $response = $this->actingAs($this->memberUser, 'sanctum')->putJson( + "/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}", + [ + 'title' => '첨부 연결 수정', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + 'attachment_ids' => $attachmentIds, + ] + ); + + $response->assertStatus(200); + + $this->assertSame( + 2, + Attachment::where('post_id', $postId)->count(), + 'attachment_ids 로 보낸 첨부가 게시글에 연결되지 않았습니다 — 응답은 성공인데 첨부만 사라집니다.' + ); + } + + /** + * 수정 경로의 `attachment_ids` 도 개수 상한을 넘기면 차단된다 (위 경계의 대조군). + */ + public function test_update_attachment_ids_over_limit_is_blocked(): void + { + $postId = $this->createTestPost(['user_id' => $this->memberUser->id]); + + $tempKey = 'tk_'.str_repeat('o', 20); + $this->seedTempAttachments($tempKey, 6); + $attachmentIds = Attachment::where('temp_key', $tempKey)->pluck('id')->all(); + + $response = $this->actingAs($this->memberUser, 'sanctum')->putJson( + "/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}", + [ + 'title' => '상한 초과 첨부 수정', + 'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.', + 'attachment_ids' => $attachmentIds, + ] + ); + + $response->assertStatus(422); + $this->assertSame(0, Attachment::where('post_id', $postId)->count()); + } + + /** + * 상한 미설정(0) 게시판은 제한하지 않는다 (기존 동작 유지). + */ + public function test_board_without_limit_is_not_restricted(): void + { + $this->board->update(['max_file_count' => 0]); + + $postId = $this->createTestPost(); + $tempKey = 'tk_'.str_repeat('e', 20); + $this->seedTempAttachments($tempKey, 8); + + $linked = app(AttachmentService::class)->linkTempAttachmentsWithMove( + $this->getTestBoardSlug(), + $tempKey, + $postId + ); + + $this->assertSame(8, $linked); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php b/modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php index cc85f128..27304a33 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php @@ -6,6 +6,8 @@ require_once __DIR__.'/../ModuleTestCase.php'; use App\Models\Role; use App\Models\User; +use Modules\Sirsoft\Board\Exceptions\CommentDepthExceededException; +use Modules\Sirsoft\Board\Services\CommentService; use Modules\Sirsoft\Board\Tests\BoardTestCase; /** @@ -159,6 +161,50 @@ class CommentDepthLimitTest extends BoardTestCase ]); } + /** + * max_comment_depth=0 게시판에서는 대댓글 자체가 불가능하다 + * + * 0 은 "답글 없음" 설정이다. 상한을 리터럴로 클램프하거나 0 을 무제한으로 오해하면 + * 이 게시판에서 대댓글이 그대로 달린다. + * + * @scenario entity=board_comment, parent_target=unrelated + * + * @effects depth_beyond_board_max_rejected_422, depth_follows_board_setting_not_literal + */ + public function test_reply_is_rejected_when_max_depth_is_zero(): void + { + $this->updateBoardSettings(['max_comment_depth' => 0]); + + $postId = $this->createTestPost(); + $parentId = $this->createTestComment($postId, ['depth' => 0]); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postId), [ + 'content' => '대댓글 불가 게시판에서의 답글 시도', + 'parent_id' => $parentId, + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['parent_id']); + } + + /** + * max_comment_depth=0 이어도 최상위 댓글은 정상 작성된다 (과잉 차단 방지) + * + * @scenario entity=board_comment, parent_target=unrelated + * + * @effects depth_follows_board_setting_not_literal + */ + public function test_top_level_comment_is_allowed_when_max_depth_is_zero(): void + { + $this->updateBoardSettings(['max_comment_depth' => 0]); + + $postId = $this->createTestPost(); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postId), ['content' => '최상위 댓글']) + ->assertStatus(201); + } + /** * 댓글 생성 엔드포인트 URL 을 생성합니다. * @@ -169,4 +215,61 @@ class CommentDepthLimitTest extends BoardTestCase { return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments"; } + + /** + * FormRequest 를 거치지 않는 경로(훅 · Service 직접 호출)에서도 깊이 상한이 지켜진다 + * + * `CommentValidationRule` 은 요청 단계 선차단이라 Service 를 직접 부르는 확장 코드에는 + * 걸리지 않는다. 첨부 개수(B3)가 Service 를 최종 불변조건으로 삼은 것과 같은 이유로, + * 깊이 상한도 Service 가 마지막 관문이어야 한다. + * + * @scenario entity=board_comment, parent_target=unrelated + * + * @effects depth_beyond_board_max_rejected_422 + */ + public function test_service_direct_call_rejects_reply_beyond_max_depth(): void + { + $this->updateBoardSettings(['max_comment_depth' => 2]); + + $postId = $this->createTestPost(); + $depth0 = $this->createTestComment($postId, ['depth' => 0]); + $depth1 = $this->createTestComment($postId, ['parent_id' => $depth0, 'depth' => 1]); + $depth2 = $this->createTestComment($postId, ['parent_id' => $depth1, 'depth' => 2]); + + $service = app(CommentService::class); + + $this->expectException(CommentDepthExceededException::class); + + // depth 3 = 상한 2 초과 → Service 가 막아야 한다 + $service->createComment($this->board->slug, [ + 'post_id' => $postId, + 'parent_id' => $depth2, + 'content' => 'FormRequest 를 우회한 깊이 초과 답글', + 'user_id' => $this->memberUser->id, + ]); + } + + /** + * 상한 이내의 Service 직접 호출은 그대로 통과한다 (과잉 차단 방지) + * + * @scenario entity=board_comment, parent_target=unrelated + * + * @effects depth_follows_board_setting_not_literal + */ + public function test_service_direct_call_allows_reply_within_max_depth(): void + { + $this->updateBoardSettings(['max_comment_depth' => 2]); + + $postId = $this->createTestPost(); + $depth0 = $this->createTestComment($postId, ['depth' => 0]); + + $comment = app(CommentService::class)->createComment($this->board->slug, [ + 'post_id' => $postId, + 'parent_id' => $depth0, + 'content' => '상한 이내 답글', + 'user_id' => $this->memberUser->id, + ]); + + $this->assertSame(1, (int) $comment->depth); + } } diff --git a/modules/_bundled/sirsoft-board/tests/Feature/CommentPostStateTest.php b/modules/_bundled/sirsoft-board/tests/Feature/CommentPostStateTest.php new file mode 100644 index 00000000..e293d32a --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/CommentPostStateTest.php @@ -0,0 +1,197 @@ + $slug, + 'name' => ['ko' => '댓글 상태 게시판', 'en' => 'Comment State Board'], + 'is_active' => true, + 'use_comment' => true, + 'max_comment_depth' => 5, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setGuestPermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']); + $this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']); + + $this->memberUser = User::factory()->create(['email' => 'comment-post-state@test.com']); + + $userRole = Role::where('identifier', 'user')->first(); + if ($userRole) { + $this->memberUser->roles()->attach($userRole->id); + } + } + + /** + * 요청 경로: 블라인드 게시글 댓글은 422 로 선차단된다 + * + * @scenario entity=board_comment, post_state=blinded + * + * @effects blinded_post_comment_rejected_422 + */ + public function test_request_path_rejects_comment_on_blinded_post(): void + { + $postId = $this->createTestPost(['status' => 'blinded']); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postId), ['content' => '블라인드 글에 댓글']) + ->assertStatus(422); + + $this->assertDatabaseMissing('board_comments', ['post_id' => $postId]); + } + + /** + * 요청 경로: 삭제된 게시글 댓글은 422 로 선차단된다 + * + * @scenario entity=board_comment, post_state=deleted + * + * @effects deleted_post_comment_rejected_422 + */ + public function test_request_path_rejects_comment_on_deleted_post(): void + { + $postId = $this->createTestPost(['status' => 'deleted']); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postId), ['content' => '삭제된 글에 댓글']) + ->assertStatus(422); + + $this->assertDatabaseMissing('board_comments', ['post_id' => $postId]); + } + + /** + * Service 직접 경로: 블라인드 게시글은 도메인 예외로 막힌다 + * + * 베이스 `\Exception` 이면 컨트롤러 제네릭 catch 에 흡수되어 사유가 사라진다. + * + * @scenario entity=board_comment, post_state=blinded + * + * @effects blinded_post_comment_rejected_422 + */ + public function test_service_direct_call_rejects_comment_on_blinded_post(): void + { + $postId = $this->createTestPost(['status' => 'blinded']); + + $this->expectException(PostNotCommentableException::class); + $this->expectExceptionMessage(__('sirsoft-board::messages.comment.post_blinded')); + + app(CommentService::class)->createComment($this->board->slug, [ + 'post_id' => $postId, + 'content' => 'FormRequest 를 우회한 블라인드 글 댓글', + 'user_id' => $this->memberUser->id, + ]); + } + + /** + * Service 직접 경로: 삭제된 게시글은 도메인 예외로 막힌다 + * + * @scenario entity=board_comment, post_state=deleted + * + * @effects deleted_post_comment_rejected_422 + */ + public function test_service_direct_call_rejects_comment_on_deleted_post(): void + { + $postId = $this->createTestPost(['status' => 'deleted']); + + $this->expectException(PostNotCommentableException::class); + $this->expectExceptionMessage(__('sirsoft-board::messages.comment.post_deleted')); + + app(CommentService::class)->createComment($this->board->slug, [ + 'post_id' => $postId, + 'content' => 'FormRequest 를 우회한 삭제 글 댓글', + 'user_id' => $this->memberUser->id, + ]); + } + + /** + * Service 직접 경로: soft-delete 된 게시글도 도메인 예외로 막힌다 + * + * @scenario entity=board_comment, post_state=soft_deleted + * + * @effects deleted_post_comment_rejected_422 + */ + public function test_service_direct_call_rejects_comment_on_soft_deleted_post(): void + { + $postId = $this->createTestPost(['deleted_at' => now()]); + + $this->expectException(PostNotCommentableException::class); + $this->expectExceptionMessage(__('sirsoft-board::messages.comment.post_deleted')); + + app(CommentService::class)->createComment($this->board->slug, [ + 'post_id' => $postId, + 'content' => 'FormRequest 를 우회한 soft-delete 글 댓글', + 'user_id' => $this->memberUser->id, + ]); + } + + /** + * 정상 게시글의 댓글 작성은 요청·Service 양 경로에서 그대로 통과한다 (과잉 차단 방지) + * + * @scenario entity=board_comment, post_state=published + * + * @effects published_post_comment_allowed + */ + public function test_comment_on_published_post_still_passes(): void + { + $postId = $this->createTestPost(); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postId), ['content' => '정상 댓글']) + ->assertStatus(201); + + $comment = app(CommentService::class)->createComment($this->board->slug, [ + 'post_id' => $postId, + 'content' => 'Service 직접 호출 댓글', + 'user_id' => $this->memberUser->id, + ]); + + $this->assertNotNull($comment->id); + } + + /** + * 댓글 생성 엔드포인트 URL 을 생성합니다. + * + * @param int $postId 게시글 ID + * @return string 댓글 생성 URL + */ + private function storeUrl(int $postId): string + { + return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php index fff2ade1..aeb0286d 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php @@ -6,8 +6,10 @@ require_once __DIR__.'/../../ModuleTestCase.php'; use App\Extension\UpgradeContext; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Board\Tests\BoardTestCase; use Modules\Sirsoft\Board\Upgrades\Upgrade_1_0_3; +use Psr\Log\LoggerInterface; /** * 1.0.3 댓글 계층 오염 데이터 정리 업그레이드 스텝 테스트 @@ -128,6 +130,124 @@ class CommentHierarchyCleanupTest extends BoardTestCase $this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.'); } + /** + * 부모 행이 삭제된 고아 댓글은 값이 보존되고 경고 로그가 남는다 + * + * 정리 1단계는 부모와 INNER JOIN 하므로 부모 행 자체가 없는 댓글은 매칭되지 않고, + * 2단계 BFS 는 `parent_id IS NULL` 루트에서 출발하므로 이 댓글에 도달하지 못한다. + * 값을 임의로 고치거나 지우면 데이터 손실이므로 보존하되, 정정되지 않았다는 사실은 + * 경고로 남겨야 한다. + * + * @scenario entity=board_comment, parent_target=missing + * + * @effects dangling_parent_skipped_with_warning + */ + public function test_dangling_parent_is_skipped_with_warning(): void + { + $post = $this->createTestPost(['title' => '게시글 A']); + + $parent = $this->createTestComment($post, ['depth' => 0]); + $dangling = $this->createTestComment($post, ['parent_id' => $parent, 'depth' => 3]); + + // 부모 행만 물리 삭제하여 dangling parent_id 상태를 만든다. + DB::table('board_comments')->where('id', $parent)->delete(); + + $logger = \Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel') + ->with('extension-upgrade') + ->andReturn($logger); + + $this->runCleanup(); + + $this->assertDatabaseHas('board_comments', [ + 'id' => $dangling, + 'parent_id' => $parent, + 'depth' => 3, + ]); + + $logger->shouldHaveReceived('warning') + ->withArgs(fn (string $message) => str_contains($message, '부모 행이 없는 댓글') + && str_contains($message, (string) $dangling)) + ->once(); + } + + /** + * 루트에서 도달할 수 없는 순환 참조 댓글은 값이 보존되고 경고 로그가 남는다 + * + * 부모 행이 실재하므로 고아 판정(`부모 행이 없는 댓글`)에 걸리지 않고, + * BFS 는 루트에서만 출발하므로 순환 컴포넌트에 진입조차 못 해 세대 상한에도 + * 걸리지 않는다. 두 그물을 모두 빠져나가므로 전용 경고가 없으면 + * "정정되지 않았다" 는 사실이 조용히 묻힌다. + * + * @scenario entity=board_comment, parent_target=cycle + * + * @effects unreachable_cycle_skipped_with_warning + */ + public function test_unreachable_cycle_is_skipped_with_warning(): void + { + $post = $this->createTestPost(['title' => '게시글 A']); + + $first = $this->createTestComment($post, ['depth' => 0]); + $second = $this->createTestComment($post, ['parent_id' => $first, 'depth' => 1]); + + // 첫 댓글의 부모를 두 번째로 돌려 루트 없는 순환(A→B→A)을 만든다. + DB::table('board_comments')->where('id', $first)->update([ + 'parent_id' => $second, + 'depth' => 4, + ]); + + $logger = \Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel') + ->with('extension-upgrade') + ->andReturn($logger); + + $this->runCleanup(); + + // 값 보존 — 순환의 어느 지점을 끊을지는 운영자 판단이므로 건드리지 않는다. + $this->assertDatabaseHas('board_comments', [ + 'id' => $first, + 'parent_id' => $second, + 'depth' => 4, + ]); + $this->assertDatabaseHas('board_comments', [ + 'id' => $second, + 'parent_id' => $first, + 'depth' => 1, + ]); + + $logger->shouldHaveReceived('warning') + ->withArgs(fn (string $message) => str_contains($message, '루트에서 도달하지 못한 댓글') + && str_contains($message, (string) $first) + && str_contains($message, (string) $second)) + ->once(); + } + + /** + * 정합한 계층만 있으면 순환 경고가 발생하지 않는다 (과잉 경고 방지) + * + * @scenario entity=board_comment, parent_target=self + * + * @effects healthy_hierarchy_emits_no_cycle_warning + */ + public function test_healthy_hierarchy_emits_no_cycle_warning(): void + { + $post = $this->createTestPost(['title' => '게시글 A']); + + $root = $this->createTestComment($post, ['depth' => 0]); + $this->createTestComment($post, ['parent_id' => $root, 'depth' => 1]); + + $logger = \Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel') + ->with('extension-upgrade') + ->andReturn($logger); + + $this->runCleanup(); + + $logger->shouldNotHaveReceived('warning', [ + \Mockery::on(fn (string $message) => str_contains($message, '루트에서 도달하지 못한 댓글')), + ]); + } + /** * 1.0.3 정리 마이그레이션을 실행합니다. */ diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-form-range-parity.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-form-range-parity.spec.ts new file mode 100644 index 00000000..3e7b01b9 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-form-range-parity.spec.ts @@ -0,0 +1,78 @@ +/** + * 개별 게시판 수정 화면의 숫자 입력 경계값이 서버 한계값과 일치하는지 검증 (공개 이슈 #81). + * + * 결함: 같은 설정 항목이 두 화면에 있는데 경계 안내가 한쪽에만 있었다. + * - 게시판 환경설정(모듈 기본값): 숫자 입력에 허용 범위가 붙어 있음 + * - 개별 게시판 수정: min/max 가 아예 없어 범위를 알 수 없음 + * 저장은 서버가 동일하게 막으므로 잘못된 값이 들어가지는 않았지만, 사용자는 저장을 + * 눌러 422 를 받아야만 범위를 알 수 있었다. + * + * 단위/정적: + * - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-form-range-contract.test.ts + * 가 레이아웃 JSON 의 표현식(_local.form._meta.limits.*)과 폴백 값을 전수 고정 + * 이 spec 은 브라우저 수준 — 서버가 실제로 내려준 한계값이 DOM 속성으로 해석되는지를 담당한다. + * (표현식이 잘못된 경로를 가리켜도 폴백 값이 서버 기본치와 같으면 화면만 봐서는 구분되지 + * 않으므로, 응답의 `_meta.limits` 를 읽어 그 값과 대조한다.) + * + * 시드 의존: 샘플 시드의 자유게시판(`free`). + * + * @scenario board-form-range-parity + * @axes screen=per_board screen=module_defaults + * @effects form_boundary_matches_server_limits + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; + +const SLUG = 'free'; + +/** 개별 게시판 화면의 입력 name → 서버 limits 키 prefix (per_page_mobile 은 per_page 범위 공유) */ +const FIELD_TO_LIMIT: Record = { + per_page: 'per_page', + per_page_mobile: 'per_page', + min_title_length: 'min_title_length', + max_title_length: 'max_title_length', + min_content_length: 'min_content_length', + max_content_length: 'max_content_length', + new_display_hours: 'new_display_hours', + min_comment_length: 'min_comment_length', + max_comment_length: 'max_comment_length', + max_comment_depth: 'max_comment_depth', + max_file_size: 'max_file_size', + max_file_count: 'max_file_count', +}; + +test('#81 - 개별 게시판 수정 화면의 숫자 경계값이 서버 한계값과 일치한다', async ({ page, boardManageToken }) => { + await authenticatePage(page, boardManageToken); + + await page.goto(`/admin/boards/${SLUG}/edit`); + await expect(page.locator('input[name="per_page"]')).toBeAttached({ timeout: 20_000 }); + + // 서버가 실제로 내려주는 한계값을 그대로 읽는다 (화면 폴백과 구분하기 위함) + const limits = await page.evaluate(async ({ slug, bearer }) => { + const response = await fetch( + `/api/modules/sirsoft-board/admin/boards/form-data?board_slug=${slug}`, + { headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' } } + ); + const body = await response.json(); + + return (body?.data?._meta?.limits ?? {}) as Record; + }, { slug: SLUG, bearer: boardManageToken }); + + expect( + Object.keys(limits).length, + '게시판 한계값이 폼 데이터 응답에 실려 있지 않습니다 — 화면 바인딩이 영구히 폴백으로 떨어집니다.' + ).toBeGreaterThan(0); + + for (const [field, limitKey] of Object.entries(FIELD_TO_LIMIT)) { + const input = page.locator(`input[name="${field}"]`).first(); + + // 토글 뒤에 숨은 입력(예: 답글 사용)은 이 화면 기본 상태에서 렌더되지 않을 수 있다 + if ((await input.count()) === 0) { + continue; + } + + await expect(input, `${field}: min 이 서버 한계값과 다릅니다`) + .toHaveAttribute('min', String(limits[`${limitKey}_min`])); + await expect(input, `${field}: max 가 서버 한계값과 다릅니다`) + .toHaveAttribute('max', String(limits[`${limitKey}_max`])); + } +}); diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/attachment-count-limit.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/attachment-count-limit.yaml new file mode 100644 index 00000000..9613dfd7 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/attachment-count-limit.yaml @@ -0,0 +1,49 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 번들 시나리오와 동일 한계). 본 변경은 백엔드 전용(첨부 개수 불변조건) +# 이며 PHPUnit Feature 테스트가 경로·경계 조합을 커버하고 green 이다. + +feature: 게시판 첨부파일 개수 상한 서버측 강제 (B3) + +description: | + 게시판 설정 `max_file_count` 를 모든 첨부 연결 경로에서 강제한다. + + 배경: + - `files[]` 직접 업로드만 FormRequest 가 막고 `attachment_ids` · `temp_key` · + 단일 업로드 반복 경로에는 개수 검사가 없었다. 같은 8개 파일을 `temp_key` 로 보내면 + 201 로 전부 연결되어 사용자 페이지에 그대로 노출됐다. + + 계층 결정: + - 총합은 직접 파일 + `attachment_ids` + `temp_key` 임시첨부 + 기존 첨부 4소스이고 + temp_key 쪽은 DB 조회가 필요하므로 FormRequest 만으로 판정 불가 + - `AttachmentService::assertAttachmentCountWithin()` 이 불변조건의 SSoT + - FormRequest 선차단은 "글 다 쓰고 저장 실패" 를 막는 UX 역할 (중복이지만 역할이 다름) + +axes: + link_path: [direct_files, attachment_ids, temp_key, single_upload_repeat] + existing_state: [none, below_limit, at_limit] + request_count: [under, exact, over] + board_limit: [configured, unlimited] + +exclusions: + - { board_limit: unlimited, request_count: over, reason: "상한 미설정 게시판은 제한하지 않음 (기존 동작)" } + +effects: + - temp_key_link_over_limit_blocked + - temp_key_link_at_limit_passes + - existing_plus_new_over_limit_blocked + - existing_plus_new_at_limit_passes + - attachment_ids_path_counted_in_total + - single_upload_repeat_blocked_at_limit + - unlimited_board_not_restricted + - limit_violation_returns_422_not_500 + - existing_over_limit_attachments_are_not_deleted + +test_files: + - modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php + +rules_layer_coverage: + - rule: i18n-throw-hardcoded-korean + coverage: AttachmentLimitExceededException 메시지는 sirsoft-board::messages.errors.attachment_limit_exceeded + - rule: service-direct-data-access + coverage: AttachmentService 는 AttachmentRepositoryInterface / BoardRepositoryInterface 만 사용 diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/comment-depth-limit.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/comment-depth-limit.yaml new file mode 100644 index 00000000..1fa816b8 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/comment-depth-limit.yaml @@ -0,0 +1,63 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 번들 시나리오와 동일 한계). 본 변경은 백엔드 전용(댓글 깊이 불변조건 + +# 업그레이드 데이터 정정)이며 PHPUnit Feature 테스트가 경로·경계 조합을 커버하고 green 이다. + +feature: 대댓글 깊이 상한 강제 + 포화 depth 데이터 정정 (B1) + +description: | + 게시판 설정 `max_comment_depth` 를 저장 시점에도 강제하고, 과거에 포화 저장된 depth 를 + 업그레이드에서 재계산한다. + + 배경: + - 검증(`CommentValidationRule`)은 부모의 *저장된* depth 로 판정하는데 저장 시 + `min(parent.depth + 1, 5)` 로 포화시켰다. 부모 depth 가 5 를 넘지 못하니 + `max_comment_depth=10` 게시판에서 판정식이 영원히 참이 되지 않아 **무한 중첩**이 가능했다. + - 실측: 20단 중첩이 전부 201 로 저장되고 depth 가 `0,1,2,3,4,5,5,5,…` 로 5 에서 포화. + + 계층 결정: + - 저장부의 리터럴 클램프를 제거하고 `부모 depth + 1` 을 그대로 쓴다 + (검증을 통과한 값이므로 클램프가 불필요). + - 방어적 상한은 두되 **초과 시 클램프가 아니라 예외** — 클램프가 이 버그의 원인이었다. + - 코드 수정만으로는 결함이 닫히지 않는다. 기존 스레드의 부모 depth 가 5 로 고정되어 있어 + 그 스레드에서는 계속 우회가 가능하므로 업그레이드 데이터 정정이 함께 필요하다. + + 데이터 정정 규율: + - 루트(`parent_id IS NULL`)부터 **레벨 단위 청크 UPDATE**(재귀 금지), 방문 집합으로 유한 종료 + - 재계산 결과가 상한을 넘어도 **값만 정정, 삭제 금지** + - 루트에서 도달하지 못한 행(고아 부모 / 순환 컴포넌트)은 값 보존 + 경고 로그 + +axes: + entity: [board_comment] + parent_target: [unrelated, foreign, missing, cycle, self] + board_max_depth: [zero, lowered, default] + request_depth: [under, exact, over] + entry_path: [http_user, http_admin, service_direct] + +exclusions: + - { board_max_depth: zero, request_depth: under, reason: "상한 0 은 대댓글 자체가 불가 — 하위 조합 없음" } + - { parent_target: missing, entry_path: http_user, reason: "고아 부모는 업그레이드 정정 축이며 등록 경로 조합이 아님" } + - { parent_target: cycle, entry_path: http_user, reason: "순환은 앱 경로로 생성 불가 — 오염 데이터 정정 축" } + +effects: + - depth_equals_parent_depth_plus_one + - depth_follows_board_setting_not_literal + - depth_beyond_board_max_rejected_422 + - existing_clamped_depth_recalculated + - existing_cross_post_parent_detached_content_preserved + - dangling_parent_skipped_with_warning + - unreachable_cycle_skipped_with_warning + - healthy_hierarchy_emits_no_cycle_warning + - cleanup_is_idempotent + +test_files: + - modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php + - modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php + +rules_layer_coverage: + - rule: i18n-throw-hardcoded-korean + coverage: CommentDepthExceededException 메시지는 sirsoft-board::validation.comment.depth.exceeded + - rule: service-config-value-clamp + coverage: CommentService 의 리터럴 클램프 제거 — 이 룰이 재유입을 검출한다 + - rule: upgrade-step-path-a + coverage: 정정 스텝은 Facades\DB/Schema 만 사용하고 로직을 private 헬퍼로 로컬 완결 diff --git a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php index 11d01d34..6885ccdb 100644 --- a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php +++ b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php @@ -17,7 +17,8 @@ use Illuminate\Support\Facades\Schema; * 콘텐츠는 건드리지 않으며 depth 컬럼만 갱신하므로 결정론적입니다. * * idempotent: 이미 정합한 행은 갱신 대상에서 제외되어 재실행해도 결과가 같습니다. - * 순환 참조가 남아 있어도 방문 ID 집합으로 유한 종료합니다. + * 순환 참조가 남아 있어도 방문 ID 집합으로 유한 종료하며, 루트에서 도달하지 못한 + * 행(고아 부모 / 순환 컴포넌트)은 값을 보존한 채 경고 로그로 남깁니다. * V-1 안전: Facades\DB/Schema 만 사용. */ class RecalculateCommentDepth implements DataMigration @@ -84,6 +85,9 @@ class RecalculateCommentDepth implements DataMigration )); } + $this->logOrphanParents($context); + $this->logUnreachableComments($context, $visited); + $context->logger->info(sprintf( '[board:1.0.3] depth 재계산 완료 — %d 건 갱신 (최대 세대 %d)', $updated, @@ -180,6 +184,94 @@ class RecalculateCommentDepth implements DataMigration return $updated; } + /** + * 부모 행이 존재하지 않는 고아 댓글을 경고 로그로 남깁니다. + * + * BFS 는 `parent_id IS NULL` 인 루트에서만 출발하므로, 부모가 이미 삭제된 댓글은 + * 어느 세대에도 도달하지 못해 depth 가 옛 값 그대로 남는다. 값을 임의로 고치거나 + * 행을 지우면 데이터 손실이므로 건드리지 않되, 정정되지 않았다는 사실은 남긴다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + private function logOrphanParents(UpgradeContext $context): void + { + $orphanIds = DB::table(self::TABLE.' as c') + ->whereNotNull('c.parent_id') + ->whereNotExists(function ($query) { + $query->select('p.id') + ->from(self::TABLE.' as p') + ->whereColumn('p.id', 'c.parent_id'); + }) + ->pluck('c.id') + ->all(); + + if ($orphanIds === []) { + return; + } + + $context->logger->warning(sprintf( + '[board:1.0.3] 부모 행이 없는 댓글 %d 건 — depth 재계산에서 스킵(값 보존). 대상 id: %s', + count($orphanIds), + json_encode(array_map('intval', $orphanIds), JSON_UNESCAPED_UNICODE) + )); + } + + /** + * 루트에서 도달하지 못한 댓글(순환 참조 컴포넌트)을 경고 로그로 남깁니다. + * + * 부모 행이 존재하는데도 BFS 가 방문하지 못했다면 그 부모 역시 도달 불가라는 뜻이고, + * 거슬러 올라가면 루트가 없는 순환(A→B→A)에 닿는다. 이런 행은 세대 상한에 걸리지도 + * (BFS 가 진입 자체를 못 함) 고아 판정에 걸리지도(부모 행은 실재함) 않아, + * 경고가 없으면 정정되지 않은 사실이 조용히 묻힌다. + * + * 값은 건드리지 않는다 — 순환의 어느 지점을 끊을지는 운영자 판단 영역이다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + * @param array $visited BFS 가 방문한 댓글 ID 집합 + */ + private function logUnreachableComments(UpgradeContext $context, array $visited): void + { + $cycleIds = []; + $lastId = 0; + + while (true) { + $ids = DB::table(self::TABLE.' as c') + ->whereNotNull('c.parent_id') + ->where('c.id', '>', $lastId) + ->whereExists(function ($query) { + $query->select('p.id') + ->from(self::TABLE.' as p') + ->whereColumn('p.id', 'c.parent_id'); + }) + ->orderBy('c.id') + ->limit(self::CHUNK) + ->pluck('c.id') + ->all(); + + if ($ids === []) { + break; + } + + foreach ($ids as $id) { + $lastId = (int) $id; + + if (! isset($visited[$lastId])) { + $cycleIds[] = $lastId; + } + } + } + + if ($cycleIds === []) { + return; + } + + $context->logger->warning(sprintf( + '[board:1.0.3] 루트에서 도달하지 못한 댓글 %d 건 — 순환 참조로 보이며 depth 재계산에서 스킵(값 보존). 대상 id: %s', + count($cycleIds), + json_encode($cycleIds, JSON_UNESCAPED_UNICODE) + )); + } + /** * 갱신 대상의 변경 전 상태를 로그로 남깁니다 (복구 근거). * diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 2e0b7590..a937b2ea 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -6,6 +6,16 @@ ## [1.0.5] - 2026-07-16 +### Security + +- 관리자가 설정한 마일리지 사용 정책(최소 사용액·사용 단위·최대 사용 한도)이 주문서 작성과 주문 확정 양쪽에서 실제로 적용됩니다. 이전에는 보유 잔액만 확인해, 사용 한도를 30%로 제한해 두어도 결제금액 전액을 마일리지로 결제할 수 있었습니다. 한도를 넘으면 사용 가능한 최대 금액을 함께 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문에는 주문 시점의 마일리지 사용 정책이 함께 보관됩니다. 이후 관리자가 한도를 바꿔도 과거 주문이 어떤 기준으로 성립했는지 확인할 수 있습니다. (기존 주문은 당시 설정을 알 수 없어 비어 있습니다.) (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 무통장입금 기한이 주문 요청에 실린 값으로 정해지던 문제를 수정했습니다. 이제 가상계좌와 동일하게 관리자 환경설정의 미입금 자동취소 기한만 사용합니다. 이전에는 안내된 입금기한과 자동취소 시점이 어긋날 수 있었습니다. 이미 생성된 주문의 기한은 그대로 유지됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 장바구니 담기 수량 제한이 실제로 적용되도록 수정했습니다. 설정값(상품당 99개)이 화면·저장 어디에도 반영되지 않아 한 번에 9,999개까지 담을 수 있었고, 이미 담긴 수량과 합산되는 경우에는 제한 자체가 없었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 장바구니 수량 제한 안내 문구가 실제 설정값을 알려 주도록 수정했습니다. 이전에는 설정과 무관하게 "최대 9,999개까지 가능"으로 고정 표시돼, 안내받은 수량보다 적은 수량에서 저장이 막혔습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품의 최대 구매수량 제한이 장바구니 담기와 옵션 변경에서도 적용되도록 수정했습니다. 이전에는 수량 변경 화면에서만 검사해, 담기나 옵션 변경으로는 한도를 넘길 수 있었습니다. 비회원 장바구니가 로그인 시 합쳐질 때는 로그인이 실패하지 않도록 한도까지 자동 조정하고 조정 내역을 함께 알려 줍니다. 이때 같은 상품을 옵션별로 나눠 담아 한도를 넘기는 것도 함께 막습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 재주문으로 장바구니에 담을 때도 상품의 최대 구매수량 제한이 적용됩니다. 한도를 넘는 항목은 품절 상품과 같이 사유와 함께 제외되고, 나머지 항목은 정상적으로 담깁니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) + ### Added - 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. @@ -14,11 +24,21 @@ ### Changed +- 실제로 사용되지 않던 설정 항목(상품·주문 목록의 페이지당 개수, 결제 자동취소 기한 중복 항목)을 정리했습니다. 미입금 자동취소 기한은 관리자 환경설정의 값 하나만 사용합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. - 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. ### Fixed +- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 로그인하지 않은 상태로 장바구니에 담아 둔 상품이 로그인 후 사라지던 문제를 수정했습니다. 로그인 시 장바구니를 회원 계정으로 옮기는 처리가 실행되지 않아, 담아 둔 상품이 아무 안내 없이 없어졌습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 설정 화면이 허용하는 값과 저장 규칙이 서로 달라 저장이 거부되거나 그 반대가 되던 항목들을 맞췄습니다. 리뷰 이미지 최대 개수(최대 20개), 리뷰 이미지 최대 크기(정수 MB, 최대 50MB), 마일리지 기본 적립률(최대 100%), 미입금 자동취소 기한(최소 1일 — 0일은 주문 즉시 만료라 사용할 수 없는 값)이 대상입니다. 정수만 받는 항목은 입력칸의 증감 단위도 1로 맞춰, 화살표로 값을 올리다 소수가 되어 저장이 거부되는 일이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품 고시정보 템플릿 목록에서 한 번에 가져올 개수에 상한(100개)을 두었습니다. 이전에는 임의의 큰 값을 넘겨 한 요청으로 전체 데이터를 가져갈 수 있었습니다. '전체' 조회는 종전대로 동작합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품 옵션의 정률 적립률을 100%를 넘겨 저장할 수 있던 문제를 수정했습니다. 전체 기본 적립률은 100%까지만 허용되는데 옵션별 적립률에는 상한이 없어, 결제금액보다 많은 마일리지가 적립되도록 설정할 수 있었습니다. 정액(원 단위) 적립은 종전처럼 상한이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품·옵션 일괄 수정에서 가격 검증이 등록/수정 화면보다 느슨하던 문제를 수정했습니다. 원화처럼 소수점을 쓰지 않는 통화에서 소수 가격이 저장되거나, 판매가가 정가보다 크게 저장되거나, 0원으로 저장되던 경우가 막힙니다. 정가·판매가 중 한쪽만 수정하는 경우도 저장된 나머지 값과 함께 판정하므로, 판매가만 정가 위로 올려 저장할 수 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 부분취소 후에도 주문의 부가세가 취소 전 금액으로 남아 과세표준과 어긋나던 문제를 수정했습니다. 취소로 과세표준이 줄면 부가세도 함께 다시 계산되며, 계산에는 주문 당시의 세율이 사용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문의 부가세가 항상 0으로 표시되던 문제를 수정했습니다. 부가세는 부분취소를 거친 주문에서만 채워져 같은 목록에서도 주문마다 세금 표기가 달랐습니다. 이제 주문을 만들 때 계산해 기록하며, 상품별 세율이 다르면 상품별로 계산해 합산합니다. 기존 주문의 부가세는 업데이트 시 자동으로 채워지며, 채운 주문 건수가 업그레이드 로그에 남습니다(이미 값이 있는 주문은 건드리지 않습니다). (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문서의 마일리지 '사용 가능 최대' 안내가 실제 결제 기준 금액과 최소 사용액을 반영하도록 수정했습니다. 이전에는 할인·배송비 반영 전 상품합계를 기준으로 계산해 실제로 쓸 수 있는 금액보다 크게 안내되었고, 최소 사용액에 못 미치는 경우에도 사용 가능한 것처럼 보였습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) - 휴대폰으로 관리자 주문 목록을 볼 때 왼쪽 위 '뒤로' 버튼을 누르면 이전 화면이 아니라 빈 페이지로 넘어가던 문제를 수정했습니다. - 관리자 주문·상품·쿠폰·배송정책·리뷰·마일리지 목록에서 상세나 등록/수정 화면에 다녀오면 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. 필터를 여러 개 건 상태에서 주문 하나를 확인하고 돌아와도 조건이 그대로 유지됩니다. 정렬·표시 개수를 바꾸거나 새로고침해도 걸어 둔 조건은 그대로입니다. 반대로 초기화 버튼과 프리셋 적용은 종전처럼 조건을 새로 세팅합니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 브랜드·상품 공통정보·상품 고시정보 목록에서 검색어(브랜드는 정렬 포함)를 걸어 둔 채 새로고침 버튼을 누르면 걸어 둔 검색이 풀려 전체 목록으로 돌아가던 문제를 수정했습니다. 이제 새로고침은 보던 조건 그대로 다시 불러옵니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) diff --git a/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php b/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php index 7cd8e56b..4aef2a12 100644 --- a/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php +++ b/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php @@ -17,7 +17,6 @@ return [ // 상품 설정 'product' => [ - 'per_page' => 12, 'max_images' => 10, 'max_image_size' => 5 * 1024 * 1024, // 5MB 'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'], @@ -25,14 +24,13 @@ return [ // 주문 설정 'order' => [ - 'per_page' => 20, 'order_number_prefix' => 'ORD', ], // 결제 설정 'payment' => [ - // 미입금 주문 자동취소 기한 (일) — 결제수단 무관 단일 SSoT - 'auto_cancel_days' => 3, + // 미입금 주문 자동취소 기한은 관리자 환경설정 order_settings.auto_cancel_days 가 SSoT 다. + // 여기에 같은 이름의 값을 두면 두 번째 SSoT 가 되어 어느 쪽이 적용되는지 알 수 없어진다. // 입금 기한 만료 시 자동 취소 여부 'auto_cancel_expired' => true, @@ -85,6 +83,53 @@ return [ 'max_image_size_mb' => 10, // 리뷰 이미지 최대 크기 (MB) ], + /* + |-------------------------------------------------------------------------- + | 환경설정 입력 한계값 + |-------------------------------------------------------------------------- + | 관리자 환경설정 화면의 숫자 입력 경계값(min/max)과 저장 검증 규칙이 공유하는 단일 출처. + | 규칙(StoreEcommerceSettingsRequest)이 이 값을 읽어 규칙 문자열을 만들고, + | 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다. + */ + 'limits' => [ + // 주문 + 'auto_cancel_days_min' => 1, + 'auto_cancel_days_max' => 30, + 'cart_expiry_days_min' => 1, + 'cart_expiry_days_max' => 365, + + // 마일리지 (전역) + 'default_earn_rate_min' => 0, + 'default_earn_rate_max' => 100, + 'earn_delay_days_min' => 0, + 'earn_delay_days_max' => 365, + 'expiry_days_min' => 1, + 'expiry_days_max' => 3650, + 'expiry_notification_days_before_min' => 1, + 'expiry_notification_days_before_max' => 365, + + // 마일리지 (통화별 규칙 행) + 'point_value_min' => 0.001, + 'min_use_amount_min' => 0, + 'use_unit_min' => 1, + 'max_use_percent_min' => 0, + 'max_use_percent_max' => 100, + 'max_use_value_min' => 0, + 'max_use_value_max' => 1000000000, + + // 통화 행 + 'decimal_places_min' => 0, + 'decimal_places_max' => 8, + + // 리뷰 + 'write_deadline_days_min' => 1, + 'write_deadline_days_max' => 365, + 'max_images_min' => 0, + 'max_images_max' => 20, + 'max_image_size_mb_min' => 1, + 'max_image_size_mb_max' => 50, + ], + // 관리자 대시보드 이커머스 영역 설정 'dashboard' => [ 'scheduler_enabled' => true, // 판매 현황 집계 스케줄러(hourly) 활성화 여부 diff --git a/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php b/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php index d718ba80..87782a82 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php +++ b/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php @@ -35,7 +35,11 @@ class OrderOptionFactory extends Factory 'order_id' => Order::factory(), 'parent_option_id' => null, 'product_id' => Product::factory(), - 'product_option_id' => ProductOption::factory(), + // 연결 옵션의 재고는 아래 option_snapshot 이 적는 값(100)과 맞춘다. + // ProductOption 기본 정의는 재고를 0~100 난수로 뽑는데, 주문 수량(1~5)보다 작게 + // 뽑히면 결제 완료 시 재고 차감이 실패해 주문 상태를 다루는 테스트가 재고 부족으로 + // 무작위 실패한다. 재고 부족 시나리오는 그 상태를 명시하는 테스트가 따로 지정한다. + 'product_option_id' => ProductOption::factory()->state(['stock_quantity' => 100]), 'option_status' => OrderStatusEnum::PENDING_ORDER, 'source_type' => 'order', 'source_option_id' => null, diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_27_000001_add_mileage_policy_snapshot_to_ecommerce_orders_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_27_000001_add_mileage_policy_snapshot_to_ecommerce_orders_table.php new file mode 100644 index 00000000..b41d28aa --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_27_000001_add_mileage_policy_snapshot_to_ecommerce_orders_table.php @@ -0,0 +1,53 @@ +mediumText('mileage_policy_snapshot') + ->nullable() + ->after('shipping_policy_applied_snapshot') + ->comment('주문 시점 마일리지 사용 정책 스냅샷 (최소 사용액/사용 단위/한도 재현용)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('ecommerce_orders')) { + return; + } + + if (! Schema::hasColumn('ecommerce_orders', 'mileage_policy_snapshot')) { + return; + } + + Schema::table('ecommerce_orders', function (Blueprint $table) { + $table->dropColumn('mileage_policy_snapshot'); + }); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md index bbfd7d86..6a39b6e7 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md @@ -415,15 +415,48 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +| 필드 | 타입 | 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| merged_count | integer | `2` | 회원 장바구니로 옮겨진 비회원 아이템 수 | +| adjustments | array | `[]` | 병합 과정에서 수량이 상한으로 줄어든 항목 목록 (조정이 없으면 빈 배열) | +| adjustments[].product_id | integer | `12` | 조정된 상품 ID | +| adjustments[].product_option_id | integer | `34` | 조정된 상품 옵션 ID | +| adjustments[].requested | integer | `10` | 병합 시 합산된 수량 | +| adjustments[].applied | integer | `5` | 상한 적용 후 실제 저장된 수량 | + +> 병합은 로그인 흐름에서 자동 수행되므로 수량 상한을 넘겨도 예외를 던지지 않고 상한까지 줄입니다. +> 줄어든 사실을 사용자에게 알릴 수 있도록 `adjustments` 를 확인해 안내하세요. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "장바구니를 병합했습니다.", + "data": { + "merged_count": 2, + "adjustments": [ + { + "product_id": 12, + "product_option_id": 34, + "requested": 10, + "applied": 5 + } + ] + } +} +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태 | 조건 | 응답 | +| --- | --- | --- | +| 422 | 미로그인 상태이거나 `X-Cart-Key` 헤더가 없음/형식 불일치 | `errors.auth` 또는 `errors.cart_key` | +| 500 | 병합 처리 실패 | `messages.cart.merge_failed` | @@ -611,7 +644,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | --- | --- | --- | --- | --- | --- | | id | path | string | 예 | — | 대상 리소스의 식별자 | | product_option_id | body | integer | 예 | — | product option 식별자 | -| quantity | body | integer | 예 | min 1, max 9999 | 변경할 구매 수량 (1~9999) | +| quantity | body | integer | 예 | min 1, max: 장바구니 수량 상한 설정 | 변경할 구매 수량 (1 ~ 관리자 설정 상한, 기본 99) | | additional_option_selections | body | array | 아니오 | — | 추가 옵션 재선택 목록 (항목별 additional_option_id/value_id, 직접입력 custom_text — 미전달 시 기존 선택 유지) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.change_option_validation_rules`). @@ -665,7 +698,7 @@ Content-Type: application/json | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | id | path | string | 예 | — | 대상 리소스의 식별자 | -| quantity | body | integer | 예 | min 1, max 9999 | 변경할 구매 수량 (1~9999) | +| quantity | body | integer | 예 | min 1, max: 장바구니 수량 상한 설정 | 변경할 구매 수량 (1 ~ 관리자 설정 상한, 기본 99) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.update_quantity_validation_rules`). @@ -700,6 +733,6 @@ Content-Type: application/json -**설명** 장바구니 아이템(`id`)의 수량만 변경합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CartController@updateQuantity`가 `CartService::updateQuantity()`로 수량을 반영한 뒤 프론트가 별도 refetch 없이 화면을 갱신할 수 있도록 `index`와 동일한 전체 목록·계산 결과(`items`·`calculation`)를 함께 반환합니다. 수량은 1~9999 범위이며, 재고 부족·판매 중지·구매수량 한도 위반은 사유별 422, 항목/권한 문제는 404/403으로 매핑됩니다. 장바구니의 수량 증감(+/-) 컨트롤에 사용합니다. +**설명** 장바구니 아이템(`id`)의 수량만 변경합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CartController@updateQuantity`가 `CartService::updateQuantity()`로 수량을 반영한 뒤 프론트가 별도 refetch 없이 화면을 갱신할 수 있도록 `index`와 동일한 전체 목록·계산 결과(`items`·`calculation`)를 함께 반환합니다. 수량 상한은 관리자 환경설정의 장바구니 수량 상한(기본 99)을 따르며, 재고 부족·판매 중지·구매수량 한도 위반은 사유별 422, 항목/권한 문제는 404/403으로 매핑됩니다. 장바구니의 수량 증감(+/-) 컨트롤에 사용합니다. diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md index e2141763..f04e1189 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md @@ -96,6 +96,8 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **설명** 현재 유효한 임시 주문을 조회하면서 최신 가격으로 실시간 재계산해 주문서 페이지 데이터를 반환합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@show`가 `TempOrderService::getTempOrderWithCalculation()`으로 재계산하고 `CheckoutDataService::buildResponseData()`가 쿠폰·마일리지·상품·구매불가 상품 정보를 포함해 응답을 구성합니다. 쿼리로 `country_code`/`zipcode`/`region` 등 배송 주소를 전달하면 해당 주소 기준 배송비가 계산되며, 우편번호 없이 배송국가만으로도 미리보기 배송비를 산출합니다. 임시 주문이 만료·미존재면 404를 반환합니다. +응답의 `mileage_info` 에는 잔액(`available`)·사용 가능 최대(`max_usable`)·사용 가능 여부(`usable`)와 함께 사용 정책 `usage_policy`(`min_use_amount`, `use_unit`, `max_use_amount`)가 포함됩니다. `max_usable` 과 `usage_policy.max_use_amount` 는 저장 API 가 판정하는 기준(마일리지 차감 전 결제금액)과 동일한 기준으로 산출되므로, 입력 UI 는 이 값을 그대로 상·하한으로 사용하면 됩니다. 조회는 사용 한도를 초과한 임시 주문이 있어도 200 을 반환합니다(차단은 저장 시점에서만). + ### POST /api/modules/sirsoft-ecommerce/checkout @@ -153,7 +155,9 @@ Content-Type: application/json -**설명** 장바구니에서 선택한 아이템으로 임시 주문을 생성해 주문서 작성 단계로 진입합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@store`가 `direct_items`가 있으면 `TempOrderService::createTempOrderFromDirectItems()`(바로 구매, 장바구니 미경유), 없으면 `item_ids`로 `createTempOrderFromSelectedItems()`(장바구니 경유)를 호출합니다. 응답에는 임시 주문 ID·계산 결과·만료 시각(`expires_at`)이 포함됩니다. 재고 부족·판매 중지·구매 제한 상품이 있으면 400(cart_unavailable), 보유 잔액을 넘는 마일리지 사용은 422, 빈 장바구니는 400을 반환합니다. +**설명** 장바구니에서 선택한 아이템으로 임시 주문을 생성해 주문서 작성 단계로 진입합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@store`가 `direct_items`가 있으면 `TempOrderService::createTempOrderFromDirectItems()`(바로 구매, 장바구니 미경유), 없으면 `item_ids`로 `createTempOrderFromSelectedItems()`(장바구니 경유)를 호출합니다. 응답에는 임시 주문 ID·계산 결과·만료 시각(`expires_at`)이 포함됩니다. 재고 부족·판매 중지·구매 제한 상품이 있으면 400(cart_unavailable), 빈 장바구니는 400을 반환합니다. + +마일리지 사용은 보유 잔액뿐 아니라 관리자 설정 사용 정책(최소 사용액 `min_use_amount`, 사용 단위 `use_unit`, 최대 한도 `max_use_percent`/`max_use_value`)까지 서버에서 강제하며, 위반 시 422 를 반환합니다. 한도 초과 응답 메시지에는 사용 가능한 최대 금액이 함께 안내됩니다. 판정 기준 금액은 마일리지 차감 전 결제금액이며, 비회원의 `use_points` 는 예외 없이 0 으로 처리됩니다. ### PUT /api/modules/sirsoft-ecommerce/checkout @@ -214,7 +218,7 @@ Content-Type: application/json -**설명** 주문서 작성 중 쿠폰·마일리지·배송 주소가 변경될 때 임시 주문 금액을 재계산합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@update`가 전송된 프로모션 필드(`item_coupons`·`order_coupon_issue_id`·`shipping_coupon_issue_id`)와 `use_points`만 반영하고 미전송 필드는 `TempOrderService::updateTempOrder()`에서 기존 값을 유지합니다. `zipcode`/`country_code`로 배송 주소를 함께 넘기면 배송비가 다시 계산됩니다. 임시 주문이 만료·미존재면 404, 보유 잔액 초과 마일리지는 422를 반환합니다. +**설명** 주문서 작성 중 쿠폰·마일리지·배송 주소가 변경될 때 임시 주문 금액을 재계산합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@update`가 전송된 프로모션 필드(`item_coupons`·`order_coupon_issue_id`·`shipping_coupon_issue_id`)와 `use_points`만 반영하고 미전송 필드는 `TempOrderService::updateTempOrder()`에서 기존 값을 유지합니다. `zipcode`/`country_code`로 배송 주소를 함께 넘기면 배송비가 다시 계산됩니다. 임시 주문이 만료·미존재면 404, 보유 잔액 초과 또는 사용 정책(최소 사용액·사용 단위·최대 한도) 위반 마일리지는 422를 반환합니다. ### POST /api/modules/sirsoft-ecommerce/checkout/extend diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index 0a875faa..a5292864 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -1394,6 +1394,8 @@ Content-Type: application/json **설명** 주문서 작성을 마치고 실제 주문을 생성(결제하기)하는 회원/비회원 공용 엔드포인트입니다. PG 플러그인의 fetch 인터셉터가 이 한 경로만 매칭하므로 회원/비회원이 동일 URL로 진입하고, `Public\OrderController@store`가 `Auth::id()`로 분기합니다(회원은 `OrderResource`, 비회원은 민감 필드를 가린 `GuestOrderResource`). `optional.sanctum` + `sirsoft-ecommerce.user-orders.create` 권한이 필요하며, `expected_total_amount`로 금액 위변조를 검증하고 비회원은 `guest_lookup_password`로 이후 조회 비밀번호를 설정합니다. 회원이 `save_shipping_address`를 켜면 배송지가 자동 저장(PG 결제는 결제완료 시점) 됩니다. +주문 확정 시점에는 재고·구매대상제한·배송국가·쿠폰 유효성과 함께 **마일리지 사용 정책**도 현재 설정 기준으로 재검증합니다. 임시 주문을 만든 뒤 관리자가 한도를 강화했거나 임시 주문이 조작된 경우 `422`(`errors.code = mileage_usage_not_allowed`)로 차단되며 주문은 생성되지 않습니다. 반대로 정상 생성된 주문에는 그 시점의 사용 정책이 `mileage_policy_snapshot` 으로 고정되어, 이후 설정이 바뀌어도 해당 주문의 판정 근거를 재현할 수 있습니다(통화·프로모션·배송정책 스냅샷과 동일 취지). + ### GET /api/modules/sirsoft-ecommerce/user/orders/{id} @@ -1851,7 +1853,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | added_count | integer | `0` | added 개수 (집계) | -| skipped | array | `[]` | 장바구니에 담지 못한 항목 목록 (품절·단종 등으로 재주문에서 건너뛴 옵션) | +| skipped | array | `[]` | 장바구니에 담지 못한 항목 목록 (품절·단종·구매수량 한도 초과 등으로 재주문에서 건너뛴 옵션) | | cart_count | integer | `0` | cart 개수 (집계) | **응답 예시** @@ -1881,7 +1883,7 @@ HTTP/1.1 200 -**설명** 회원이 과거 주문(`id`)의 옵션들을 현재 장바구니에 다시 담는 재주문 기능입니다. `auth:sanctum` 인증이 필요하며, `User\OrderController@reorder`가 `CartService::reorderFromOrder()`로 처리해 담긴 수량(`added_count`), 담지 못한 항목(`skipped[]`), 현재 장바구니 총 개수(`cart_count`)를 반환합니다. 취소된 주문도 재주문 대상이 되며, 품절·단종 등으로 추가 불가한 항목은 건너뛰어 `skipped` 배열로 안내합니다. 마이페이지 주문내역의 "재주문" 버튼에 사용합니다. +**설명** 회원이 과거 주문(`id`)의 옵션들을 현재 장바구니에 다시 담는 재주문 기능입니다. `auth:sanctum` 인증이 필요하며, `User\OrderController@reorder`가 `CartService::reorderFromOrder()`로 처리해 담긴 수량(`added_count`), 담지 못한 항목(`skipped[]`), 현재 장바구니 총 개수(`cart_count`)를 반환합니다. 취소된 주문도 재주문 대상이 되며, 품절·단종·상품별 구매수량 한도 초과 등으로 추가 불가한 항목은 건너뛰어 `skipped` 배열로 안내합니다. 한 항목이 담기지 못해도 나머지 항목은 그대로 담기며, 응답은 200 입니다(항목 하나 때문에 재주문 전체를 실패시키지 않습니다). 마이페이지 주문내역의 "재주문" 버튼에 사용합니다. ### PUT /api/modules/sirsoft-ecommerce/user/orders/{id}/shipping-address diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md index 3f578d88..5ed274e5 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md @@ -29,7 +29,7 @@ | --- | --- | --- | --- | --- | --- | | search | query | string | 아니오 | max 200 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | | active_only | query | boolean | 아니오 | — | true 시 활성(is_active) 템플릿만 조회 | -| per_page | query | string | 아니오 | — | 페이지당 항목 수 | +| per_page | query | string | 아니오 | 정수 1~100 또는 `all` | 페이지당 항목 수 (`all` 은 전체 조회, 숫자는 최대 100) | | page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | **요청 예시** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md index ac8b4adf..2ce1b968 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md @@ -2003,6 +2003,17 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | product | path | string | 예 | — | 대상 product의 식별자 | +| copy_images | query | boolean | 아니오 | true/false (기본 true) | 상품 이미지 복사 여부 | +| copy_options | query | boolean | 아니오 | true/false (기본 true) | 옵션 복사 여부 | +| copy_categories | query | boolean | 아니오 | true/false (기본 true) | 카테고리 연결 복사 여부 | +| copy_sales_info | query | boolean | 아니오 | true/false (기본 true) | 판매 정보(가격·재고·상태) 복사 여부 | +| copy_description | query | boolean | 아니오 | true/false (기본 true) | 상세 설명 복사 여부 | +| copy_notice | query | boolean | 아니오 | true/false (기본 true) | 상품 공지 복사 여부 | +| copy_common_info | query | boolean | 아니오 | true/false (기본 true) | 상품 공통정보 복사 여부 | +| copy_other_info | query | boolean | 아니오 | true/false (기본 true) | 기타 정보 복사 여부 | +| copy_shipping | query | boolean | 아니오 | true/false (기본 true) | 배송 정책 복사 여부 | +| copy_seo | query | boolean | 아니오 | true/false (기본 **false**) | SEO 메타 복사 여부 — 메타 제목·설명은 상품마다 달라야 검색 결과에서 구분되므로 기본 제외 | +| copy_identification | query | boolean | 아니오 | true/false (기본 true) | 식별 정보(SKU·상품코드 등) 복사 여부 | **요청 예시** @@ -2280,6 +2291,8 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | product | path | string | 예 | — | 대상 product의 식별자 | +| per_page | query | integer | 아니오 | 1~100 (기본 10) | 페이지당 로그 수 | +| sort_order | query | string | 아니오 | `asc` / `desc` (기본 `desc`) | 작성 시각 정렬 방향 | **요청 예시** diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx index 6aa2cfeb..93bc1346 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx @@ -245,13 +245,17 @@ describe('주문설정 탭 구조 검증 (_tab_order_settings.json)', () => { expect(daysSection.if).toContain('auto_cancel_expired'); }); - it('자동취소일 Input 이 min=1, max=30 으로 정의되어야 한다 (위치 의존 제거)', () => { + // 경계값은 서버가 내려주는 한계값(_meta.limits)을 바인딩한다 — 화면이 리터럴을 들면 + // 저장 규칙이 바뀔 때 따라오지 못해 "화면은 받는데 저장에서 422" 가 된다. + it('자동취소일 Input 이 서버 한계값을 바인딩해야 한다 (위치 의존 제거)', () => { const input = findFirst(card, (n: any) => n?.name === 'Input' && n?.props?.name === 'order_settings.auto_cancel_days', ); expect(input).not.toBeNull(); - expect(input.props.min).toBe(1); - expect(input.props.max).toBe(30); + expect(String(input.props.min)).toContain('_meta?.limits?.auto_cancel_days_min'); + expect(String(input.props.max)).toContain('_meta?.limits?.auto_cancel_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 30'); }); it('입금기한 단일화: 구 vbank_due_days Input 이 더 이상 존재하지 않는다', () => { @@ -272,13 +276,15 @@ describe('주문설정 탭 구조 검증 (_tab_order_settings.json)', () => { describe('장바구니 유효기간 카드 구조', () => { const card = findById(tab, 'cart_expiry_card'); - it('cart_expiry_days Input이 min=1, max=365이어야 한다', () => { + it('cart_expiry_days Input 이 서버 한계값을 바인딩해야 한다', () => { const input = findFirst(card, (n: any) => n?.name === 'Input' && n?.props?.name === 'order_settings.cart_expiry_days', ); expect(input).not.toBeNull(); - expect(input.props.min).toBe(1); - expect(input.props.max).toBe(365); + expect(String(input.props.min)).toContain('_meta?.limits?.cart_expiry_days_min'); + expect(String(input.props.max)).toContain('_meta?.limits?.cart_expiry_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 365'); }); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx index c300a70a..7f4fb022 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx @@ -164,12 +164,14 @@ describe('리뷰설정 탭 구조 검증 (_tab_review_settings.json)', () => { ); }); - it('Input이 type=number, min=1, max=365여야 한다', () => { + it('Input이 type=number 이고 허용 범위가 서버 한계값을 바인딩해야 한다', () => { const input = writeDeadlineSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); expect(input.props.type).toBe('number'); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('365'); + expect(String(input.props.min)).toContain('limits?.write_deadline_days_min'); + expect(String(input.props.max)).toContain('limits?.write_deadline_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 365'); }); it('Input 기본값이 30을 참조해야 한다', () => { @@ -224,12 +226,16 @@ describe('리뷰설정 탭 구조 검증 (_tab_review_settings.json)', () => { // 구분선(Div, index 1) 다음 max_images 항목(index 2) const maxImagesSection = settingsArea.children[2]; - it('Input이 type=number, min=0, max=10여야 한다', () => { + // 경계값은 서버 검증(StoreEcommerceSettingsRequest)이 SSoT — 화면이 더 좁으면 + // 사용자가 저장 가능한 값을 입력조차 못 하고, 더 넓으면 저장 단계에서만 실패한다. + it('Input이 type=number 이고 허용 범위가 서버 한계값을 바인딩해야 한다 (max_images)', () => { const input = maxImagesSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); expect(input.props.type).toBe('number'); - expect(String(input.props.min)).toBe('0'); - expect(String(input.props.max)).toBe('10'); + expect(String(input.props.min)).toContain('limits?.max_images_min'); + expect(String(input.props.max)).toContain('limits?.max_images_max'); + expect(String(input.props.min)).toContain('?? 0'); + expect(String(input.props.max)).toContain('?? 20'); }); it('Input 기본값이 5를 참조해야 한다', () => { @@ -264,13 +270,17 @@ describe('리뷰설정 탭 구조 검증 (_tab_review_settings.json)', () => { // 구분선(index 3) 다음 max_image_size_mb 항목(index 4) const maxSizeSection = settingsArea.children[4]; - it('Input이 type=number, min=1, max=20, step=0.5여야 한다', () => { + // 서버 규칙이 integer 이므로 step 은 1 이어야 한다 — 0.5 는 화면에서만 허용되고 + // 저장 단계에서 422 가 되는 값이다. + it('Input이 type=number, step=1 이고 허용 범위가 서버 한계값을 바인딩해야 한다', () => { const input = maxSizeSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); expect(input.props.type).toBe('number'); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('20'); - expect(String(input.props.step)).toBe('0.5'); + expect(String(input.props.min)).toContain('limits?.max_image_size_mb_min'); + expect(String(input.props.max)).toContain('limits?.max_image_size_mb_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 50'); + expect(String(input.props.step)).toBe('1'); }); it('Input 기본값이 5를 참조해야 한다', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx index a0b389cd..fb8e5827 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx @@ -161,31 +161,39 @@ describe('리뷰 설정 탭 (_tab_review_settings.json) 렌더링', () => { expect(cardBody.children.length).toBeGreaterThanOrEqual(3); }); - it('write_deadline_days Input이 min=1, max=365다', () => { + it('write_deadline_days Input 범위가 서버 한계값을 바인딩한다', () => { const cardBody = (tabReviewSettings as any).children[0].children[2]; const deadlineSection = cardBody.children[0]; const input = deadlineSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('365'); + expect(String(input.props.min)).toContain('limits?.write_deadline_days_min'); + expect(String(input.props.max)).toContain('limits?.write_deadline_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 365'); }); - it('max_images Input이 min=0, max=10이다', () => { + // 경계값은 서버 검증(StoreEcommerceSettingsRequest)이 SSoT — 화면이 더 좁으면 저장 가능한 + // 값을 입력조차 못 하고, 더 넓으면 저장 단계에서만 실패한다. + it('max_images Input 범위가 서버 한계값을 바인딩한다', () => { const cardBody = (tabReviewSettings as any).children[0].children[2]; const maxImagesSection = cardBody.children[2]; // 구분선(index 1) 건너뜀 const input = maxImagesSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); - expect(String(input.props.min)).toBe('0'); - expect(String(input.props.max)).toBe('10'); + expect(String(input.props.min)).toContain('limits?.max_images_min'); + expect(String(input.props.max)).toContain('limits?.max_images_max'); + expect(String(input.props.min)).toContain('?? 0'); + expect(String(input.props.max)).toContain('?? 20'); }); - it('max_image_size_mb Input이 min=1, max=20이다', () => { + it('max_image_size_mb Input 범위가 서버 한계값을 바인딩한다', () => { const cardBody = (tabReviewSettings as any).children[0].children[2]; const maxSizeSection = cardBody.children[4]; // 구분선(index 3) 건너뜀 const input = maxSizeSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('20'); + expect(String(input.props.min)).toContain('limits?.max_image_size_mb_min'); + expect(String(input.props.max)).toContain('limits?.max_image_size_mb_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 50'); }); it('모든 Input의 onChange가 setState + hasChanges: true를 사용한다', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/ecommerce-settings-range-contract.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/ecommerce-settings-range-contract.test.ts new file mode 100644 index 00000000..60b548a5 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/ecommerce-settings-range-contract.test.ts @@ -0,0 +1,237 @@ +/** + * 설정 화면 ↔ 서버 검증 규칙 경계값 계약 테스트 (E10) + * + * @description + * 설정 입력 UI 의 min/max/step 은 서버 검증 규칙(StoreEcommerceSettingsRequest)과 같은 값을 + * 말해야 한다. 화면이 더 좁으면 저장 가능한 값을 입력조차 못 하고, 더 넓으면 입력은 되는데 + * 저장 단계에서만 422 가 난다. 어느 쪽도 화면만 보고는 알 수 없어 조용히 어긋난 채로 남는다. + * + * 양쪽 모두 `config('sirsoft-ecommerce.limits')` 한 곳을 읽도록 배선했으므로, 세 지점 + * (설정 파일 · 서버 규칙 · 화면 바인딩)이 같은 한계값 키와 같은 폴백을 가리키는지 대조한다. + * 어느 한 쪽만 고쳐도 실패한다. + * + * @vitest-environment node + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import tabMileageBasic from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_basic_card.json'; +import tabOrderSettings from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json'; +import tabReviewSettings from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_review_settings.json'; + +const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const requestSource = fs.readFileSync( + path.join(moduleRoot, 'src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php'), + 'utf-8', +); +const configSource = fs.readFileSync(path.join(moduleRoot, 'config/ecommerce.php'), 'utf-8'); + +/** 한 경계(min 또는 max)의 출처: 어떤 한계값 키를 읽고, 그 키가 없을 때 무엇으로 떨어지는가. */ +interface Bound { + limitKey: string | null; + fallback: number; +} + +/** + * `config/ecommerce.php` 의 `limits` 블록을 키 → 값으로 읽는다. + * + * @returns 한계값 키 맵 + */ +function configuredLimits(): Record { + const block = /'limits'\s*=>\s*\[([\s\S]*?)\n\s*\],/.exec(configSource); + expect(block, "config/ecommerce.php 에서 'limits' 블록을 찾지 못했습니다.").not.toBeNull(); + + const limits: Record = {}; + + for (const line of block![1].split('\n')) { + const matched = /'([a-z0-9_]+)'\s*=>\s*(-?[\d.]+)\s*,/.exec(line); + if (matched) { + limits[matched[1]] = Number(matched[2]); + } + } + + expect(Object.keys(limits).length).toBeGreaterThan(0); + + return limits; +} + +const LIMITS = configuredLimits(); + +/** + * 규칙/바인딩 문자열에서 한 경계의 출처를 뽑는다. + * + * 서버 규칙: `'min:'.config('sirsoft-ecommerce.limits.KEY', N)` 또는 `'min:N'` + * 화면 바인딩: `{{_local?.form?._meta?.limits?.KEY ?? N}}` 또는 `"N"` + * + * @param source 규칙 배열 본문 또는 prop 값 + * @param bound 'min' | 'max' + * @param isRule 서버 규칙 문자열이면 true + * @returns 경계 출처 (해당 경계가 없으면 null) + */ +function extractBound(source: string, bound: 'min' | 'max', isRule: boolean): Bound | null { + if (isRule) { + const viaConfig = new RegExp( + `'${bound}:'\\s*\\.\\s*config\\(\\s*'sirsoft-ecommerce\\.limits\\.([a-z0-9_]+)'\\s*,\\s*(-?[\\d.]+)\\s*\\)`, + ).exec(source); + + if (viaConfig) { + return { limitKey: viaConfig[1], fallback: Number(viaConfig[2]) }; + } + + const literal = new RegExp(`'${bound}:(-?[\\d.]+)'`).exec(source); + + return literal ? { limitKey: null, fallback: Number(literal[1]) } : null; + } + + if (source === undefined || source === null || source === '') { + return null; + } + + const viaBinding = /limits\?\.([a-z0-9_]+)\s*\?\?\s*(-?[\d.]+)/.exec(source); + + if (viaBinding) { + return { limitKey: viaBinding[1], fallback: Number(viaBinding[2]) }; + } + + return Number.isNaN(Number(source)) ? null : { limitKey: null, fallback: Number(source) }; +} + +/** + * FormRequest 원본에서 해당 설정 키의 규칙 배열 본문을 읽는다. + * + * @param key 설정 키 + * @returns 규칙 배열 본문 문자열 + */ +function ruleBody(key: string): string { + const escaped = key.replace(/[.]/g, '\\.'); + const matched = new RegExp(`'${escaped}'\\s*=>\\s*\\[(.*)\\],\\n`).exec(requestSource); + + expect(matched, `${key} 규칙을 FormRequest 에서 찾지 못했습니다.`).not.toBeNull(); + + return matched![1]; +} + +/** + * 레이아웃 트리에서 해당 설정 키를 다루는 Input 노드를 찾는다. + * + * 자동 바인딩을 쓰는 화면은 `props.name`, 수동 바인딩 화면은 `props.value` 의 표현식 경로로 + * 필드를 식별한다 — 두 형태가 같은 탭 안에 섞여 있으므로 둘 다 본다. + * + * @param node 탐색 시작 노드 + * @param key 설정 키 + * @returns 매칭된 Input 노드 (없으면 null) + */ +function findInput(node: any, key: string): any { + if (!node || typeof node !== 'object') return null; + + const boundPath = key.split('.').join('?.'); + const value = node.props?.value; + + if ( + node.name === 'Input' && + (node.props?.name === key || (typeof value === 'string' && value.includes(boundPath))) + ) { + return node; + } + + for (const child of Object.values(node)) { + if (Array.isArray(child)) { + for (const item of child) { + const found = findInput(item, key); + if (found) return found; + } + } else if (child && typeof child === 'object') { + const found = findInput(child, key); + if (found) return found; + } + } + + return null; +} + +const FIELDS: Array<{ key: string; layout: any }> = [ + { key: 'review_settings.write_deadline_days', layout: tabReviewSettings }, + { key: 'review_settings.max_images', layout: tabReviewSettings }, + { key: 'review_settings.max_image_size_mb', layout: tabReviewSettings }, + { key: 'order_settings.auto_cancel_days', layout: tabOrderSettings }, + { key: 'order_settings.cart_expiry_days', layout: tabOrderSettings }, + { key: 'mileage.default_earn_rate', layout: tabMileageBasic }, +]; + +describe('설정 화면 경계값 ↔ 서버 규칙 계약', () => { + it.each(FIELDS)('$key 의 min/max 가 서버 규칙과 같은 한계값을 가리킨다', ({ key, layout }) => { + const input = findInput(layout, key); + expect(input, `${key} Input 을 레이아웃에서 찾지 못했습니다.`).not.toBeNull(); + + const body = ruleBody(key); + let compared = 0; + + for (const bound of ['min', 'max'] as const) { + const server = extractBound(body, bound, true); + + if (server === null) { + continue; + } + + const screen = extractBound(String(input.props[bound] ?? ''), bound, false); + + expect(screen, `${key} 화면에 ${bound} 이 없습니다 — 서버는 ${bound} 을 강제합니다.`).not.toBeNull(); + expect(screen!.limitKey, `${key} 의 ${bound} 이 서버 규칙과 다른 한계값을 읽습니다.`).toBe( + server.limitKey, + ); + expect(screen!.fallback, `${key} 의 ${bound} 폴백이 서버 규칙과 다릅니다.`).toBe(server.fallback); + + compared++; + } + + // 규칙 형태가 바뀌어 아무 경계도 못 읽으면 이 테스트는 아무것도 검증하지 못한 채 통과한다. + expect(compared, `${key} 에서 서버 경계를 하나도 읽지 못했습니다 — 규칙 추출이 깨졌습니다.`).toBeGreaterThan(0); + }); + + it.each(FIELDS)('$key 의 폴백이 설정 파일의 한계값과 같다', ({ key }) => { + const body = ruleBody(key); + let checked = 0; + + for (const bound of ['min', 'max'] as const) { + const server = extractBound(body, bound, true); + + if (server === null || server.limitKey === null) { + continue; + } + + expect(LIMITS, `${server.limitKey} 가 config/ecommerce.php 의 limits 에 없습니다.`).toHaveProperty( + server.limitKey, + ); + expect(LIMITS[server.limitKey], `${key} 의 ${bound} 폴백이 설정 파일 값과 다릅니다.`).toBe( + server.fallback, + ); + + checked++; + } + + expect(checked).toBeGreaterThan(0); + }); + + it.each(FIELDS)('$key 의 step 이 서버 규칙의 정수 여부와 어긋나지 않는다', ({ key, layout }) => { + const input = findInput(layout, key); + const body = ruleBody(key); + + if (!/'integer'/.test(body)) { + return; + } + + // step 자체가 사라지면 브라우저 기본값 1 로 동작해 당장은 문제가 없지만, + // 다음 사람이 소수 step 을 다시 넣어도 막을 근거가 없어진다. + // 부재를 통과시키면 이 가드가 그 순간 무력화되므로 명시 실패시킨다. + expect( + input.props.step, + `${key} 는 서버가 integer 를 요구하므로 step 을 명시해야 합니다 — 부재는 소수 step 회귀를 막지 못합니다.`, + ).toBeDefined(); + + // 서버가 integer 를 요구하는데 소수 step 을 주면, 화면이 권하는 값이 곧 422 가 된다. + expect(Number(input.props.step)).toBe(1); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/radio-keyboard-operable.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/radio-keyboard-operable.test.ts new file mode 100644 index 00000000..5d68b937 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/radio-keyboard-operable.test.ts @@ -0,0 +1,118 @@ +/** + * 라디오 선택이 키보드로도 조작 가능한지 계약 테스트 (#493 후속) + * + * 배경: 라디오를 커스텀 스타일로 그리기 위해 `