fix(core,board,ecommerce,page,kginicis): 정책값 하드코딩·계약 분리 결함 일괄 수정

관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다.
설정 화면(min/max)·저장 검증(FormRequest)·런타임 적용(Service) 세 계층이 각자
리터럴을 들고 있어 서로 조용히 갈라지던 것이 공통 원인이다.

경계값 SSoT 도입:
- config/core.php `settings_limits` 를 단일 출처로 두고, FormRequest 는 이 값으로
 규칙을 만들고 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다.
- 게시판·이커머스도 각 모듈 config 의 limits 를 동일 구조로 노출한다.

적용되지 않던 정책을 실제로 적용:
- 비밀번호 최소 길이·특수문자 필수(신규 지정 7개 경로. 로그인은 제외 — 정책을
 올렸을 때 기존 회원이 자기 계정에 접근조차 못 하는 데드락을 막기 위함)
- 계정 잠금 시간 0 = 무한대(전용 컬럼 분리. timestamp 상한과 NULL 규약 때문에
 기존 컬럼 재사용이 불가능하다) + 관리자 수동 해제 경로
- 업로드 최대 크기·허용 확장자, 게시판 첨부 개수·댓글 깊이, 페이지 첨부 정책,
 장바구니 수량·구매 한도, 마일리지 사용 한도, 주문 부가세

파생 결함:
- 큐 Job(sync 드라이버)이 진행 중인 요청의 Request 바인딩을 덮어써, 뒤이어 실행되는
 훅 리스너가 헤더를 잃고 조용히 건너뛰었다. Job 이 원 바인딩을 복원하도록 고치고,
 전송 헤더가 필요한 리스너는 동기 실행으로 선언한다.
- 확장 업데이트 실패 시 상태만 되돌리고 상태 캐시를 비우지 않아, 그 확장의 관리자
 화면만 캐시 TTL(기본 하루) 동안 404 로 남았다. 실패 경로에도 무효화를 대칭으로
 두고, 빈 목록 자체를 캐시하지 않는다.
- 글 작성 시 첨부 파일이 Service 로 전달되지 않아 검증·권한은 통과하고 첨부만 사라졌다.
- 환경설정 저장 실패 안내가 내부 설정 키를 그대로 노출했다(105개 항목에 라벨 부여).

재발 방지:
- audit 룰 3종(레이아웃 경계 리터럴 / FormRequest 경계 불일치 / Service 재클램프)
- 테스트 실행 범위 강제 훅(check-test-scope)
- 트러블슈팅 사례 2건(큐 Job 요청 오염 / 확장 상태 캐시 고착)

공개 이슈: gnuboard/g7
This commit is contained in:
HeuJung committed 2026-07-30 07:52:24 +09:00
1 parent 5d140dfbe6
commit d17ea28da5
272 files changed
+12431 -1006

No files matched your search

+16
View File
@@ -6,6 +6,18 @@
## [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 님께서 제보해주셨습니다.)
- 환경설정 > 본인인증의 '가입 후 본인확인' 정책을 켜면 회원가입 자체가 막혀 아무도 가입할 수 없던 문제를 수정했습니다. 이 정책은 가입은 받아들이고 계정을 '인증 대기' 상태로 둔 뒤 본인확인 안내를 보내는 설정인데, 안내를 보낸 직후 가입이 거절되어 '가입 전 본인확인' 정책과 구분이 없었습니다. 이제 설정한 대로 가입이 완료되고, 본인확인을 마치면 계정이 활성화됩니다. (안내 발송이 실패해도 가입은 유지되며 실패 사실은 기록에 남습니다.)
- 회원 상세 화면에 계정 잠금 상태 표시와 잠금 해제 버튼을 추가했습니다. 무기한 잠긴 계정은 로그인 성공으로 풀 수 없으므로 관리자가 직접 해제할 수 있어야 합니다. 해제는 활동 로그에 남습니다. (사용자 수정 권한이 필요합니다.) (#81 @jiwonpapa 님께서 제보해주셨습니다.)
### Added
- 회원가입 시 휴대폰번호와 전화번호를 선택 항목으로 입력할 수 있습니다. 두 항목 모두 필수가 아니며, 입력하면 내 정보에서 확인·수정할 수 있습니다.
@@ -35,6 +47,7 @@
### Changed
- 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다.
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다.
### Fixed
@@ -59,6 +72,8 @@
- 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다.
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
- 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다.
- 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다.
#### 설정 저장·입력 검증
@@ -72,6 +87,7 @@
#### 안내 문구·주소 처리
- 일부 안내가 실제 문장 대신 내부 식별자(`messages.auth.unauthenticated` 같은 문자열)로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때, 메뉴에 역할을 지정할 때, 확장 식별자를 잘못 입력했을 때, 활동 로그를 기간·유형으로 검색할 때가 해당됩니다.
- 환경설정 저장이 실패했을 때 안내 문구에 항목 이름이 `security.password min length` 같은 내부 식별자로 표시되던 문제를 수정했습니다. 보안·업로드·SEO·고급·드라이버·메일 등 모든 설정 항목이 화면에 보이는 한글 항목명으로 안내됩니다.
- 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다.
- 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다.
- 주소 끝에 슬래시가 붙은 형태(예: `/admin/`)로 접속하면 화면이 "페이지 없음"(404)으로 뜨던 문제를 수정했습니다. 특히 로그인이 풀린 상태에서 이렇게 접속하면 로그인 화면으로 넘어가지 않고 404가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다.
@@ -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<int, int> 사용자 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;
}
+19 -4
View File
@@ -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;
}
}
@@ -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);
+13
View File
@@ -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,
+13
View File
@@ -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,
@@ -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);
}
/**
* 가드 활성 여부를 반환합니다.
*
+39 -8
View File
@@ -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<string> 조회된 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()
);
}
+39 -8
View File
@@ -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<string> 조회된 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()
);
}
+41 -12
View File
@@ -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<string> 조회된 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) 호출해야 합니다.
+14 -2
View File
@@ -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
{
@@ -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');
@@ -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,
@@ -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()]);
}
}
/**
* 사용자를 삭제합니다.
*
@@ -4,6 +4,7 @@ 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;
@@ -12,7 +13,6 @@ 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
@@ -52,10 +52,17 @@ class AuthController extends AuthBaseController
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');
}
@@ -84,10 +91,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 +104,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 +117,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 +138,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());
@@ -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'])) {
@@ -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<int, string> 허용 확장자 목록
*/
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)
));
}
}
@@ -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<int, string> 허용 확장자 목록
*/
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)
));
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
/**
* 본문 입력이 없는 인증 세션 요청
*
* 로그아웃 · 현재 사용자 조회 · 토큰 갱신처럼 토큰으로 식별되는 주체만 있으면 되고
* 본문 입력을 받지 않는 엔드포인트가 공유합니다. 검증 규칙이 비어 있다는 사실 자체가
* "이 엔드포인트는 어떤 입력도 받지 않는다" 는 선언이며, 나중에 입력이 생기면 그 규칙을
* 둘 자리가 이미 마련되어 있습니다.
*
* 인증은 라우트의 `auth:sanctum` 미들웨어가, 권한은 permission 미들웨어가 담당합니다.
*/
class AuthenticatedRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인합니다.
*
* @return bool 항상 true (인증·권한은 미들웨어에서 검증)
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 빈 배열 (본문 입력 없음)
*/
public function rules(): array
{
return [];
}
}
+7 -3
View File
@@ -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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|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'),
];
}
}
+2 -1
View File
@@ -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'])),
@@ -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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|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를 동적으로 추가할 수 있도록 훅 제공
@@ -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,17 @@ 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'],
// 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송 시 기본값 false 유지
'security.allow_internal_outbound_urls' => ['nullable', 'boolean'],
@@ -258,7 +290,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 +316,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 +406,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 +468,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 +812,119 @@ 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.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'),
];
}
}
@@ -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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|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'),
];
}
}
}
+6 -5
View File
@@ -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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|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
{
@@ -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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|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
]);
}
}
}
+6 -5
View File
@@ -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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|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'],
+8
View File
@@ -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 훅: 모듈이 자신의 데이터를 응답에 병합
+36 -12
View File
@@ -1,10 +1,14 @@
<?php
// audit:allow job-generator-needs-scale-test 이번 변경은 리스너 1건을 해석하는 지점에 가드를 더한 것으로,
// 처리 건수에 따라 동작이 달라지는 경로(배치·생성기)를 도입하지 않는다. 디스패치 단위 의미는 종전과 동일.
namespace App\Jobs;
use App\Extension\HookArgumentSerializer;
use App\Extension\HookContextCapture;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
@@ -23,8 +27,6 @@ class DispatchHookListenerJob implements ShouldQueue
/**
* 최대 재시도 횟수
*
* @var int
*/
public int $tries = 3;
@@ -45,24 +47,48 @@ class DispatchHookListenerJob implements ShouldQueue
/**
* Job을 실행합니다.
*
* @return void
*/
public function handle(): void
{
// 큐 워커 컨텍스트에 원래 요청의 Auth/Request/Locale 복원
// (Job 종료 시 컨테이너 폐기되므로 finally 원복 불필요)
HookContextCapture::restore($this->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
{
+159 -136
View File
@@ -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
],
]);
}
}
@@ -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<string> 전담 소유 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;
@@ -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,
]));
}
}
+12 -9
View File
@@ -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<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
/** @var array<string, 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()
{
+12 -4
View File
@@ -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,19 @@ 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'])
);
}
}
}
+78 -4
View File
@@ -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<int, int> 사용자 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();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Rules;
use Illuminate\Validation\Rules\Password;
/**
* 비밀번호 정책 팩토리
*
* 보안 환경설정(`security.password_min_length`, `security.require_password_special_char`)을
* 실제 검증 규칙으로 변환합니다.
*
* 기존에는 7개 FormRequest 가 각자 `min:8` / `min:6` 리터럴을 들고 있어 값이 서로 어긋났고,
* 관리자가 설정한 정책은 어디에도 적용되지 않는 완전 고아였습니다.
*
* 로그인(`LoginRequest`)에는 적용하지 않습니다 — 로그인은 기존 비밀번호를 *대조* 하는
* 행위이지 새 정책을 강제하는 지점이 아닙니다. 정책을 올리면 기존 사용자가 자기 계정에
* 접근조차 못 하고 재설정 흐름도 로그인 뒤에 있어 데드락이 되며, 길이 규칙은 미인증
* 공격자에게 "이 비밀번호는 정책 미달" 이라는 사실을 누설합니다.
*/
class PasswordPolicy
{
/**
* 기본 최소 길이 (설정 미지정 시)
*/
public const DEFAULT_MIN_LENGTH = 8;
/**
* 현재 보안 설정을 반영한 비밀번호 규칙을 반환합니다.
*
* @return Password 비밀번호 검증 규칙
*/
public static function rule(): Password
{
$minLength = (int) g7_core_settings('security.password_min_length', self::DEFAULT_MIN_LENGTH);
$minLength = $minLength > 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;
}
}
+8 -4
View File
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Enums\AttachmentSourceType;
use App\Extension\HookManager;
use App\Models\Attachment;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
@@ -58,8 +59,11 @@ class AttachmentService
// 필터 훅 - 파일 데이터 변형 (압축, 리사이즈 등 확장 포인트)
$file = HookManager::applyFilters('core.attachment.filter_upload_file', $file);
// 저장 경로 생성 (날짜별 디렉토리)
$storedFilename = Str::uuid().'.'.$file->getClientOriginalExtension();
// 저장 경로 생성 (날짜별 디렉토리).
// 확장자는 클라이언트가 보낸 파일명이 아니라 MIME 추론값을 우선 사용한다 —
// 파일명 확장자는 사용자가 임의로 바꿀 수 있어 저장 확장자의 근거가 될 수 없다.
$extension = strtolower($file->extension() ?: $file->getClientOriginalExtension());
$storedFilename = Str::uuid().($extension !== '' ? '.'.$extension : '');
$datePath = date('Y/m/d');
$path = "{$datePath}/{$storedFilename}";
@@ -217,7 +221,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 +267,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 기능 레벨 권한이 없는 경우
+5 -1
View File
@@ -99,7 +99,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,
+5 -1
View File
@@ -182,6 +182,7 @@ class IdentityVerificationService
*
* @param string $challengeId Challenge UUID
* @return array<string, mixed>|null 공개 상태 또는 null (없는 경우)
*
* @since engine-v1.46.0
*/
public function getStatus(string $challengeId): ?array
@@ -205,7 +206,9 @@ 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,
];
@@ -222,6 +225,7 @@ class IdentityVerificationService
* @param array<string, mixed> $input provider 가 보낸 페이로드 (code/token/state 등)
* @param array<string, mixed> $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
+29
View File
@@ -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;
}
+37 -14
View File
@@ -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;
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Support;
/**
* 허용 확장자 목록 정규화 (#493 C2)
*
* 허용 확장자는 관리자 화면에서 콤마로 구분한 한 줄 텍스트로 들어오고, API 를 직접 쓰는
* 경로에서는 배열로 들어온다. 저장 계층과 적용 계층이 각자 형태를 판단하면 한쪽이
* 받아들이는 값을 다른 쪽이 조용히 버리게 되므로(관리자가 확장자를 바꿔도 업로드 제한은
* 그대로), 형태 판정과 정규화를 이 한 곳에서만 수행한다.
*/
final class AllowedExtensions
{
/**
* 문자열/배열 입력을 소문자 확장자 배열로 정규화합니다.
*
* @param mixed $value 콤마 구분 문자열 또는 배열
* @return array<int, string> 정규화된 확장자 목록 (빈 배열 = 제한 없음)
*/
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;
}
}
+23 -1
View File
@@ -32,8 +32,30 @@ 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',
],
];
+76
View File
@@ -1,6 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| 환경설정 입력 한계값
|--------------------------------------------------------------------------
| 관리자 환경설정 화면의 숫자 입력 경계값(min/max)과 저장 검증 규칙이 공유하는 단일 출처.
|
| 화면과 규칙이 각자 리터럴을 들면 두 값이 조용히 갈라져 "화면이 허용한 값인데 저장에서
| 422" 또는 그 반대가 됩니다. 규칙(SaveSettingsRequest)은 이 값을 읽어 규칙 문자열을
| 만들고, 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩합니다.
|
| 키 이름은 `{카테고리}_{필드}_{min|max}` 로, 설정 키(`upload.max_file_size`)와 1:1 대응합니다.
| 카테고리를 접두사로 두는 이유는 `seo.cache_ttl` 과 `advanced.seo_cache_ttl` 처럼 필드명이
| 겹치는 조합이 실제로 있기 때문입니다.
*/
'settings_limits' => [
// 업로드
'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,
],
/*
|--------------------------------------------------------------------------
| 코어 권한 정의
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 영구 계정 잠금 상태를 별도 컬럼으로 분리합니다.
*
* 보안 환경설정의 `login_lockout_time = 0` 은 관리자 UI 가 "무한대" 로 안내하는 값이지만
* `locked_until` 은 이미 `NULL = 잠금 없음` 규약을 점유하고 있어 NULL 을 "무기한" 으로
* 재사용할 수 없고, timestamp 상한(2038)으로 센티넬 시각도 쓸 수 없습니다.
* 따라서 영구 잠금 여부를 명시 컬럼으로 분리합니다.
*
* - locked_permanently: true 면 `locked_until` 과 무관하게 잠금 유지 (해제는 성공 로그인 또는 관리자 수동 해제)
*/
public function up(): void
{
if (Schema::hasColumn('users', 'locked_permanently')) {
return;
}
Schema::table('users', function (Blueprint $table) {
$table->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');
});
}
};
+4
View File
@@ -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` 필터로 목록을 조정할 수 있습니다 (허용 목록이 비어 있으면 확장자를 제한하지 않습니다).
**요청 예시**
+1 -1
View File
@@ -1432,7 +1432,7 @@ 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` 인접)에서 소비한다.
### POST /api/auth/logout
+72
View File
@@ -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}
<!-- @generated:start:api.admin.users.show -->
- **라우트명**: `api.admin.users.show`
@@ -8,10 +8,13 @@
### Added
- 계정이 무기한 잠겼을 때의 로그인 안내와 관리자 잠금 해제 안내, 그리고 해당 활동 기록 문구의 일본어 번역 추가.
- 허용되지 않는 파일 형식을 업로드했을 때의 안내 문구 일본어 번역 추가.
- 확장 식별자·메뉴 역할·활동 로그 검색 조건의 안내 문구 일본어 번역 추가 — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 모듈·플러그인 파일 업로드 시 허용되지 않는 파일 형식 안내 문구 일본어 번역 추가.
- 플러그인을 GitHub 주소 또는 ZIP 파일로 설치할 때의 입력 검증 안내 문구 일본어 번역 추가 — 주소 형식이 잘못됐거나 ZIP 이 아닌 파일을 올렸을 때, 파일 용량을 초과했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 회원가입 검증 메시지의 휴대폰·전화번호 항목(`validation.auth.mobile.*`, `validation.auth.phone.*`) 일본어 번역 추가 — 회원가입 시 연락처 형식 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 환경설정 저장 시 오류 안내에 나오는 항목 이름의 일본어 번역 추가 — 보안·업로드·SEO·고급·드라이버 등 각 설정 항목이 내부 식별자 대신 일본어 항목명으로 표시됩니다.
## [1.0.6] - 2026-07-22
@@ -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)',
@@ -20,6 +20,7 @@ return [
'file_required' => 'ファイルを選択してください。',
'file_invalid' => '有効なファイルではありません。',
'file_max' => 'ファイルサイズは :maxMB を超えることができません。',
'file_extension_invalid' => '許可されていないファイル形式です。許可形式: :extensions',
'files_required' => 'ファイルを選択してください。',
'files_array' => 'ファイル形式が正しくありません。',
'files_min' => '最低1つ以上のファイルを選択してください。',
@@ -32,6 +32,8 @@ return [
'account_withdrawn' => '退会したアカウントです。',
'account_pending_verification' => '本人認証が完了していないアカウントです。メール認証を完了してください。',
'account_locked' => 'ログイン試行回数の超過によりアカウントがロックされました。:minutes分後に再度お試しください。',
'account_locked_permanently' => 'ログイン試行回数の超過によりアカウントがロックされました。管理者にお問い合わせください。',
'account_unlocked' => 'アカウントのロックを解除しました。',
'reset_token_invalid' => '無効なパスワードリセットトークンです。',
'reset_token_expired' => 'パスワードリセットトークンが有効期限切れです。再度リクエストしてください。',
'password_reset_email_sent' => 'パスワードリセットメールが送信されました。',
@@ -1074,5 +1074,65 @@ 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' => 'パスワード特殊文字必須',
'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' => 'ログ保持日数',
],
];
@@ -8,6 +8,7 @@
### Added
- 첨부파일 개수 상한 초과 안내 문구 일본어 번역 추가.
- 게시판 대시보드 조회 안내 문구 일본어 번역 추가.
## [1.0.3] - 2026-07-22
@@ -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' => '入力値が正しくありません。',
@@ -8,12 +8,14 @@
### Added
- 장바구니 수량 상한 초과, 정률 적립률 상한 초과, 판매가·가격 하한 안내 문구 일본어 번역 추가.
- 쇼핑몰 환경설정·카테고리 이미지·결제 설정 안내 문구 일본어 번역 추가 — 저장/조회 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 검색 프리셋·브랜드·배송사·배송정책 목록 안내 문구 일본어 번역 추가.
- 카테고리를 자기 자신이나 하위 카테고리 아래로 옮기려 할 때, 주문에 속하지 않은 옵션을 변경하려 할 때, 상품에 속하지 않은 이미지를 대표로 지정하려 할 때 나오는 안내 문구 일본어 번역 추가.
### Fixed
- 마일리지 사용 한도 초과 안내에 사용 가능한 최대 금액이 함께 표시되도록 일본어 문구를 갱신했습니다.
- 주문 관리자 메모 글자 수 안내가 실제 입력 한도(2000자)와 다른 "1000자"로 표시되던 문제를 수정했습니다.
## [1.0.5] - 2026-07-19
@@ -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' => '基本通貨のマイレージ使用単位が設定されていないため、マイレージを使用できません。',
],
@@ -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つ以上の商品を選択してください。',
@@ -8,6 +8,7 @@
### Added
- 첨부파일 개수 상한 초과 안내와 허용되지 않는 파일 형식 안내 문구 일본어 번역 추가.
- 페이지 활동 로그의 "제목"·"내용" 변경 항목 라벨 일본어 번역을 추가했습니다. 일본어 로케일에서 항목명이 비어 보이던 문제가 해소됩니다.
## [1.0.1] - 2026-07-08
@@ -35,5 +35,6 @@ return [
'version_belongs_to_different_page' => '指定されたバージョンは該当ページに属していません。',
'permission_denied' => '権限がありません。',
'validation_failed' => '入力値が正しくありません。',
'attachment_limit_exceeded' => '添付ファイルは最大:limit個まで登録できます。(リクエスト: :attempted個)',
],
];
@@ -56,6 +56,7 @@ return [
'file' => '正しいファイル形式ではありません。',
'max' => 'ファイルサイズは:maxKBを超えることはできません。',
'mimes' => '許可されていないファイル形式です。',
'mimetypes' => '許可されていないファイル形式です。',
],
'order' => [
'required' => '順序情報は必須です。',
@@ -8,6 +8,7 @@
### Added
- 회원 상세의 계정 잠금 상태 표시와 잠금 해제 버튼 문구 일본어 번역 추가 (`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_*`) — 안내 제목과 설명, 환경설정 이동 문구가 일본어 로케일에서 자연스럽게 표시됩니다.
@@ -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,13 @@
"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つ必要です。",
"ip_throttle_floor_hint": "IP単位のリクエスト制限はこの値の6倍で適用され、1分あたり最低30回は常に保証されます。"
},
"identity": {
"sub_tabs": {
+3
View File
@@ -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',
+1
View File
@@ -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.',
+2
View File
@@ -33,6 +33,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.',
+65
View File
@@ -1200,5 +1200,70 @@ 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',
'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',
],
];
+3
View File
@@ -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' => '스케줄 목록 조회',
+1
View File
@@ -27,6 +27,7 @@ return [
'file_required' => '파일을 선택해주세요.',
'file_invalid' => '유효한 파일이 아닙니다.',
'file_max' => '파일 크기는 :maxMB를 초과할 수 없습니다.',
'file_extension_invalid' => '허용되지 않는 파일 형식입니다. 허용 형식: :extensions',
'files_required' => '파일을 선택해주세요.',
'files_array' => '파일 형식이 올바르지 않습니다.',
'files_min' => '최소 1개 이상의 파일을 선택해주세요.',
+2
View File
@@ -33,6 +33,8 @@ return [
'account_withdrawn' => '탈퇴한 계정입니다.',
'account_pending_verification' => '본인인증이 완료되지 않은 계정입니다. 이메일 인증을 완료해주세요.',
'account_locked' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. :minutes분 후 다시 시도해주세요.',
'account_locked_permanently' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의해주세요.',
'account_unlocked' => '계정 잠금이 해제되었습니다.',
// 비밀번호 재설정
'reset_token_invalid' => '유효하지 않은 비밀번호 재설정 토큰입니다.',
+65
View File
@@ -1193,5 +1193,70 @@ 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' => '비밀번호 특수문자 필수',
'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' => '로그 보관 일수',
],
];
@@ -6,6 +6,11 @@
## [1.0.3] - 2026-07-16
### Security
- 게시판에 설정한 대댓글 최대 깊이가 모든 댓글 등록 경로에서 적용되도록 수정했습니다. 이전에는 글쓰기 화면을 거치는 요청에서만 깊이를 확인해, 연동 기능이 댓글을 직접 등록하면 설정한 깊이를 넘겨 달릴 수 있었습니다. 깊이를 넘긴 답글은 다른 위치로 옮겨 붙이지 않고 사유와 함께 거절합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시판에 설정한 첨부파일 최대 개수가 모든 첨부 경로에서 적용되도록 수정했습니다. 이전에는 글쓰기 화면에서 파일을 직접 첨부할 때만 개수를 확인해, 파일을 먼저 올려 두고 글을 저장하는 방식으로는 상한을 넘겨 첨부할 수 있었습니다. 이제 직접 첨부·미리 올린 파일 연결·이미 첨부된 파일까지 합산해 판정합니다. 이미 상한을 넘겨 등록된 기존 첨부는 그대로 두고 신규 등록만 차단합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
### Added
- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
@@ -19,11 +24,14 @@
### Fixed
- 글을 쓰면서 파일을 함께 첨부하면 글은 저장되는데 첨부파일만 사라지던 문제를 수정했습니다. 첨부 개수·용량·형식 검사와 권한 확인은 모두 통과한 뒤 저장 단계에서만 빠져, 등록된 글에 첨부가 하나도 남지 않았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시판 신고 정책의 자동 숨김 기준 횟수를 0(자동 숨김 사용 안 함)으로 입력할 수 없던 문제를 수정했습니다. 서버는 0을 "사용 안 함"으로 처리하는데 화면에서만 1 이상을 요구해, 0을 입력하면 저장은 되면서도 입력칸이 계속 오류 상태로 남았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 게시글 목록에서 글을 열어 이전글·다음글로 넘어가거나 수정 후 돌아올 때 보고 있던 페이지·검색어·분류가 사라지던 문제를 수정했습니다. 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 작성·수정 폼 어디로 오가든 목록 상태가 유지되며, 신고 관리와 게시판 목록도 동일하게 개선됐습니다. 새로고침 버튼도 걸어 둔 검색·필터를 지운 채 새로 부르지 않고 보고 있던 목록을 그대로 다시 부릅니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 게시글 목록에서 뒤쪽 페이지를 보던 중 검색하거나 게시 상태를 바꾸면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색과 상태 변경은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. 검색창에서 엔터를 치는 경우와 검색 버튼을 누르는 경우가 서로 다르게 동작하던 것도 함께 맞췄습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.)
- 새 글 표시 기간을 "0시간"(새 글 표시 안 함)으로 저장할 수 없던 문제를 수정했습니다. 0은 원래 새 글 배지를 끄는 값으로 동작하도록 되어 있었으나 저장 단계에서 막혀 있었습니다.
- 게시판 환경설정에서 제목·댓글 최소 길이를 0(제한 없음)으로 저장할 수 없던 문제를 수정했습니다. 내용 최소 길이는 0이 허용되어 같은 화면에서 항목마다 동작이 달랐습니다.
- 댓글 계층 정리 시 서로가 서로의 부모로 얽혀 최상위 댓글에서 닿을 수 없는 댓글이 아무 기록 없이 넘어가던 문제를 수정했습니다. 이런 댓글은 내용과 위치를 그대로 보존하되, 정리되지 않았다는 사실이 업데이트 기록에 남아 확인할 수 있습니다.
- 허용 확장자를 지정하지 않은 게시판에서 어떤 파일도 첨부할 수 없던 문제를 수정했습니다. 파일 선택 단계에서 막히던 것과 서버에서 거부되던 것을 함께 고쳐, 확장자를 지정하지 않은 게시판은 기본 확장자 목록을 사용합니다. 첨부 안내에 확장자가 비어 표시되던 것도 함께 해소됩니다. 확장자를 지정한 게시판은 지금처럼 목록에 없는 파일을 계속 거부합니다.
- 게시판 알림 설정에서 저장에 실패해도 아무 안내 없이 넘어가던 화면을 수정했습니다. 실패하면 그 이유가 화면에 표시됩니다.
- 일부 안내 문구가 실제 문장 대신 내부 식별자(`sirsoft-board::messages....` 같은 문자열)로 표시되던 문제를 수정했습니다. 첨부파일이 있는 게시판을 찾지 못했을 때, 댓글 목록을 불러오지 못했을 때, 비회원 댓글의 비밀번호를 확인할 때, 삭제·블라인드된 글에 댓글을 달려 할 때, 권한이 없어 거절될 때가 해당됩니다.
@@ -37,6 +45,7 @@
- 다른 게시글의 댓글을 답글의 부모로 지정할 수 있던 문제를 수정했습니다. 이렇게 만들어진 답글은 어느 게시글 목록에도 나타나지 않았습니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
- 대댓글 깊이가 게시판 설정값을 넘어 계속 달리던 문제를 수정했습니다. 예전에는 6단계부터 들여쓰기가 더 들어가지 않고 설정한 최대 깊이도 지켜지지 않았습니다. 이제 설정한 깊이까지만 답글을 달 수 있고 화면의 단계 표시도 정확해집니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
- 기존에 쌓인 댓글 중 깊이가 어긋났거나 다른 게시글의 댓글을 부모로 가진 것을 업데이트 시 자동으로 정리합니다. 잘못 연결된 댓글은 삭제하지 않고 최상위 댓글로 옮기며, 내용은 그대로 보존됩니다.
- 연동 기능이 블라인드·삭제된 글에 댓글을 등록하려 할 때 "댓글 등록에 실패했습니다" 라는 서버 오류만 표시되던 문제를 수정했습니다. 이제 글쓰기 화면에서와 똑같이 왜 등록할 수 없는지 사유가 표시됩니다.
## [1.0.2] - 2026-07-14
@@ -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,
],
/*
@@ -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}
<!-- @generated:start:api.modules.sirsoft-board.admin.board.attachments.download -->
@@ -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 타입이다', () => {
@@ -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<string, number> {
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<string, number> = {};
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();
/**
* 이 화면에는 네이티브 `<form>` 이 없다. 저장은 `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}`]);
}
});
});
@@ -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}}"
}
@@ -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": {}
}
]
}
@@ -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": {}
}
]
}
@@ -0,0 +1,64 @@
<?php
namespace Modules\Sirsoft\Board\Exceptions;
use App\Helpers\ResponseHelper;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 첨부파일 개수 상한 초과 예외
*
* 게시판 설정 `max_file_count` 를 초과해 첨부를 연결하려 할 때 발생합니다.
* 직접 업로드·`attachment_ids` 연결·`temp_key` 연결 등 어떤 경로로 들어와도 동일하게
* 이 예외로 차단되며, 컨트롤러가 422 로 매핑합니다.
*
* `render()` 는 컨트롤러 밖(요청 단계 선차단)에서 던져진 경우를 위한 것입니다 — 응답 형태를
* 컨트롤러 catch 와 **동일하게** 유지해, 같은 조건에 두 가지 응답 형태가 생기지 않게 합니다.
*/
class AttachmentLimitExceededException extends Exception
{
/**
* @param int $limit 게시판이 허용하는 최대 첨부 개수
* @param int $attempted 연결하려 한 총 첨부 개수
*/
public function __construct(
private int $limit,
private int $attempted,
) {
parent::__construct(__('sirsoft-board::messages.errors.attachment_limit_exceeded', [
'limit' => $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']);
}
}
@@ -0,0 +1,51 @@
<?php
namespace Modules\Sirsoft\Board\Exceptions;
use Exception;
/**
* 대댓글 깊이 상한 초과 예외
*
* 게시판 설정 `max_comment_depth` 를 넘는 깊이로 댓글을 달려 할 때 발생합니다.
* `CommentValidationRule` 이 요청 단계에서 선차단하지만, 훅이나 Service 직접 호출처럼
* FormRequest 를 거치지 않는 경로가 있으므로 최종 불변조건은 Service 가 보장합니다.
*
* 클램프하지 않고 예외를 던집니다 — 요청한 위치와 다른 자리에 조용히 댓글이 붙으면
* 사용자는 자기 답글이 엉뚱한 대화에 달린 것을 알 수 없습니다.
*/
class CommentDepthExceededException extends Exception
{
/**
* @param int $limit 게시판이 허용하는 최대 깊이
* @param int $attempted 달려고 한 깊이
*/
public function __construct(
private int $limit,
private int $attempted,
) {
parent::__construct(__('sirsoft-board::validation.comment.depth.exceeded', [
'max' => $limit,
]));
}
/**
* 허용 최대 깊이를 반환합니다.
*
* @return int 최대 깊이
*/
public function getLimit(): int
{
return $this->limit;
}
/**
* 시도한 깊이를 반환합니다.
*
* @return int 시도 깊이
*/
public function getAttempted(): int
{
return $this->attempted;
}
}
@@ -0,0 +1,59 @@
<?php
namespace Modules\Sirsoft\Board\Exceptions;
use Exception;
/**
* 댓글 작성 불가 게시글 상태 예외
*
* 블라인드되었거나 삭제된 게시글에 댓글을 달려 할 때 발생합니다.
* `CommentValidationRule` 이 요청 단계에서 선차단하지만, 훅이나 Service 직접 호출처럼
* FormRequest 를 거치지 않는 경로가 있으므로 최종 불변조건은 Service 가 보장합니다.
*
* 베이스 `\Exception` 을 던지면 컨트롤러의 제네릭 catch 에 흡수되어 500 + 일반 문구가 되고
* 거절 사유가 사라집니다. 사용자가 고칠 수 있는 상태 문제이므로 422 로 매핑합니다.
*/
class PostNotCommentableException extends Exception
{
/**
* @param string $reason 거절 사유 (blinded | deleted)
* @param string $message 사용자에게 보일 메시지
*/
public function __construct(
private string $reason,
string $message,
) {
parent::__construct($message);
}
/**
* 블라인드된 게시글에 대한 예외를 생성합니다.
*
* @return self 블라인드 사유 예외
*/
public static function blinded(): self
{
return new self('blinded', __('sirsoft-board::messages.comment.post_blinded'));
}
/**
* 삭제된 게시글에 대한 예외를 생성합니다.
*
* @return self 삭제 사유 예외
*/
public static function deleted(): self
{
return new self('deleted', __('sirsoft-board::messages.comment.post_deleted'));
}
/**
* 거절 사유를 반환합니다.
*
* @return string 거절 사유 (blinded | deleted)
*/
public function getReason(): string
{
return $this->reason;
}
}
@@ -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) {
@@ -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());
}
@@ -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) {
@@ -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) {
@@ -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);
}
@@ -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) {
@@ -383,6 +391,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) {
@@ -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 설정) 카테고리
@@ -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);
}
}
@@ -0,0 +1,120 @@
<?php
namespace Modules\Sirsoft\Board\Http\Requests\Concerns;
use Illuminate\Contracts\Validation\Validator;
use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException;
use Modules\Sirsoft\Board\Models\Attachment;
use Modules\Sirsoft\Board\Models\Board;
/**
* 게시글 첨부 개수 선차단 Trait
*
* 총합은 **직접 업로드 + `attachment_ids` + `temp_key` 임시첨부 + 기존 첨부** 4소스라
* 필드 단위 규칙(`files` 의 `max:`)만으로는 판정할 수 없습니다. 최종 불변조건은
* `AttachmentService::assertAttachmentCountWithin()` 이 담당하며, 이 Trait 은
* "글을 다 쓰고 저장 단계에서 실패" 를 막는 UX 선차단입니다 (역할이 다른 중복).
*/
trait ValidatesAttachmentCount
{
/**
* 첨부 총합이 게시판 상한을 넘으면 검증 오류를 추가합니다.
*
* @param Validator $validator 검증기
* @param Board|null $board 대상 게시판
* @param int|null $postId 수정 대상 게시글 ID (신규 작성이면 null)
*/
protected function validateAttachmentTotal(Validator $validator, ?Board $board, ?int $postId = null): void
{
if (! $board) {
return;
}
$limit = (int) ($board->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);
}
}
}
@@ -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);
});
}
}
@@ -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'),
];
}
}
/**
* 필드 단위 규칙으로 판정할 수 없는 첨부 총합을 검증합니다.
*
* 수정은 이미 연결된 첨부와 합산해 판정합니다.
*
* @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);
});
}
}
@@ -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')
);
});
}
/**
* 검증 전 데이터 준비
*/
@@ -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);
});
}
}
@@ -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);
});
}
}
@@ -4,12 +4,14 @@ namespace Modules\Sirsoft\Board\Services;
use App\Contracts\Extension\StorageInterface;
use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
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 +37,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 +107,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);
@@ -182,6 +237,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 +290,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 +318,6 @@ class AttachmentService
*
* @param string $slug 게시판 슬러그
* @param Attachment $attachment 첨부파일 모델
* @return void
*
* @throws AccessDeniedHttpException 삭제글 첨부에 권한 없이 접근한 경우
*/
@@ -402,7 +461,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)
{
@@ -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;
@@ -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
{
@@ -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.',
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff. Show more