Merge pull request from gnuboard:HeuJung/issue493
HeuJung/issue493
This commit is contained in:
@@ -6,6 +6,20 @@
|
||||
|
||||
## [7.0.6] - 2026-07-19
|
||||
|
||||
### Security
|
||||
|
||||
- 보안 환경설정의 계정 잠금 시간을 `0`(무한대)으로 설정하면 실제로 무기한 잠기도록 수정했습니다. 화면 안내는 "0을 입력하면 무한대"였지만 실제로는 1분 뒤 자동 해제되어, 무차별 대입 시도를 영구 차단할 수 없었습니다. 무기한 잠긴 계정에는 남은 시간 대신 관리자 문의 안내가 표시됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 환경설정 > 보안에 비밀번호 최소 길이와 특수문자 필수 여부를 추가했습니다. 두 항목은 설정 화면에만 있고 실제로는 어디에도 적용되지 않던 값으로, 이제 비밀번호를 새로 정하는 모든 화면(회원가입·비밀번호 변경·재설정·프로필 수정·관리자의 회원 등록과 수정)에 적용됩니다. 로그인에는 적용하지 않습니다 — 정책을 올렸을 때 기존 비밀번호를 쓰는 회원이 자기 계정에 로그인조차 못 하게 되는 것을 막기 위함입니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 본인인증 메일의 인증번호 자리수 설정이 4~10 범위를 벗어나면 조용히 잘려 저장 값과 다르게 동작하던 문제를 수정했습니다. 이제 허용 범위가 설정 화면에 표시되고, 범위를 벗어난 값은 기본값(6자리)으로 처리하며 기록을 남깁니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 환경설정 > 보안의 로그인 시도 제한 항목에 IP 단위 요청 제한이 어떻게 계산되는지(설정값의 6배, 분당 최소 30회 보장) 안내를 추가했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 환경설정 > 업로드의 최대 파일 크기와 허용 파일 형식이 실제 업로드에 적용되도록 수정했습니다. 이전에는 설정을 바꿔도 어느 경로에서도 반영되지 않아, 허용 형식 목록에 없는 확장자(예: `.php`)도 업로드할 수 있었습니다. 허용 형식 목록을 비워 두면 종전처럼 형식을 제한하지 않습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 허용 파일 형식을 저장해도 업로드 제한에 반영되지 않던 문제를 수정했습니다. 입력 칸은 `jpg,png,pdf` 처럼 쉼표로 구분한 한 줄이지만 적용 단계가 그 형태를 받아들이지 못해, 업로드 탭을 한 번이라도 저장하면 형식 제한이 저장 이전 목록으로 남았습니다. 저장은 성공하고 화면에도 값이 보여 어긋난 것을 알 방법이 없었습니다. 대문자나 앞의 점(`.JPG`, `.png`)은 자동으로 정리되며, 이미 저장되어 있던 값도 그대로 적용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 업로드된 파일의 저장 확장자를 파일명이 아니라 파일 내용에서 판별하도록 바꿨습니다. 확장자만 바꿔 올린 파일이 원래 형식과 다르게 저장되던 문제가 해소됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 환경설정 > 본인인증의 '가입 후 본인확인' 정책을 켜면 회원가입 자체가 막혀 아무도 가입할 수 없던 문제를 수정했습니다. 이 정책은 가입은 받아들이고 계정을 '인증 대기' 상태로 둔 뒤 본인확인 안내를 보내는 설정인데, 안내를 보낸 직후 가입이 거절되어 '가입 전 본인확인' 정책과 구분이 없었습니다. 이제 설정한 대로 가입이 완료되고, 본인확인을 마치면 계정이 활성화됩니다. (안내 발송이 실패해도 가입은 유지되며 실패 사실은 기록에 남습니다.)
|
||||
- 보안 환경설정의 '2단계 인증'이 실제로 동작합니다. 이전에는 항목만 있고 켜도 아무 일이 일어나지 않아, 관리자는 2단계 인증이 걸린 줄 알지만 비밀번호 하나로 로그인됐습니다. 이제 켜 두면 비밀번호 확인 뒤 인증번호를 메일로 보내고, 그 번호를 확인해야 로그인이 완료됩니다. 번호를 확인하기 전에는 로그인 상태가 만들어지지 않습니다. 안내 메일 문구는 환경설정 > 본인인증에서 '로그인 2단계 인증' 목적을 골라 따로 지정할 수 있습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 환경설정 > 업로드의 이미지 최대 가로/세로 크기와 품질이 실제 업로드에 적용됩니다. 이전에는 값을 저장해도 이미지를 줄이는 동작이 없어 원본이 그대로 저장됐습니다. 크기를 지정하지 않으면 종전처럼 줄이지 않으며, 지정한 크기 이내인 이미지는 다시 변환하지 않아 불필요한 화질 손실이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 회원 상세 화면에 계정 잠금 상태 표시와 잠금 해제 버튼을 추가했습니다. 무기한 잠긴 계정은 로그인 성공으로 풀 수 없으므로 관리자가 직접 해제할 수 있어야 합니다. 해제는 활동 로그에 남습니다. (사용자 수정 권한이 필요합니다.) (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
|
||||
### Added
|
||||
|
||||
- 회원가입 시 휴대폰번호와 전화번호를 선택 항목으로 입력할 수 있습니다. 두 항목 모두 필수가 아니며, 입력하면 내 정보에서 확인·수정할 수 있습니다.
|
||||
@@ -35,6 +49,7 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다.
|
||||
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다.
|
||||
|
||||
### Fixed
|
||||
@@ -59,6 +74,8 @@
|
||||
- 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다.
|
||||
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
|
||||
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
|
||||
- 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다.
|
||||
- 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다.
|
||||
|
||||
#### 설정 저장·입력 검증
|
||||
|
||||
@@ -72,6 +89,7 @@
|
||||
#### 안내 문구·주소 처리
|
||||
|
||||
- 일부 안내가 실제 문장 대신 내부 식별자(`messages.auth.unauthenticated` 같은 문자열)로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때, 메뉴에 역할을 지정할 때, 확장 식별자를 잘못 입력했을 때, 활동 로그를 기간·유형으로 검색할 때가 해당됩니다.
|
||||
- 환경설정 저장이 실패했을 때 안내 문구에 항목 이름이 `security.password min length` 같은 내부 식별자로 표시되던 문제를 수정했습니다. 보안·업로드·SEO·고급·드라이버·메일 등 모든 설정 항목이 화면에 보이는 한글 항목명으로 안내됩니다.
|
||||
- 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다.
|
||||
- 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다.
|
||||
- 주소 끝에 슬래시가 붙은 형태(예: `/admin/`)로 접속하면 화면이 "페이지 없음"(404)으로 뜨던 문제를 수정했습니다. 특히 로그인이 풀린 상태에서 이렇게 접속하면 로그인 화면으로 넘어가지 않고 404가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@ namespace App\Enums;
|
||||
/**
|
||||
* 본인인증 코어 purpose Enum.
|
||||
*
|
||||
* 코어가 계약으로 보장하는 4종 — `MailIdentityProvider` 가 모두 지원합니다.
|
||||
* 코어가 계약으로 보장하는 5종 — `MailIdentityProvider` 가 모두 지원합니다.
|
||||
* 모듈/플러그인은 `AbstractModule::getIdentityPurposes()` / `AbstractPlugin::getIdentityPurposes()`
|
||||
* 로 추가 purpose 를 선언할 수 있으며, 그 값은 `IdentityVerificationManager::declaredPurposes`
|
||||
* 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 4종만 정의합니다.
|
||||
* 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 5종만 정의합니다.
|
||||
*
|
||||
* case 를 추가하면 `IdentityVerificationManager::$corePurposes` 등록과
|
||||
* `lang/{ko,en}/identity.php` 의 `purposes.{value}` 라벨을 함께 추가해야 합니다.
|
||||
* 하나라도 빠지면 `label()` 이 i18n 키 원문을 그대로 돌려주고, 관리자 화면의
|
||||
* 목적 목록에서 그 목적이 통째로 빠집니다.
|
||||
*
|
||||
* @since 7.0.0-beta.5
|
||||
*/
|
||||
@@ -26,6 +31,15 @@ enum IdentityVerificationPurpose: string
|
||||
/** 민감 작업 (결제 등) */
|
||||
case SensitiveAction = 'sensitive_action';
|
||||
|
||||
/**
|
||||
* 로그인 2단계 인증
|
||||
*
|
||||
* 비밀번호 확인을 통과한 뒤 한 단계를 더 요구하는 용도입니다. 다른 purpose 와 달리
|
||||
* 이 challenge 는 **아직 로그인하지 않은 주체**를 대상으로 하므로, 검증을 마치기
|
||||
* 전까지 토큰이 발급되지 않습니다.
|
||||
*/
|
||||
case Login = 'login';
|
||||
|
||||
/**
|
||||
* 코어 purpose 값 배열.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,13 @@ class IdentityVerificationManager
|
||||
/**
|
||||
* 코어 기본 purpose 목록.
|
||||
*
|
||||
* `signup` / `password_reset` / `self_update` / `sensitive_action` — 이 4종은
|
||||
* `signup` / `password_reset` / `self_update` / `sensitive_action` / `login` — 이 5종은
|
||||
* 코어가 계약으로 보장하며 `MailIdentityProvider` 가 모두 지원합니다.
|
||||
*
|
||||
* `IdentityVerificationPurpose` 에 case 를 추가하면 이 레지스트리에도 반드시 등록해야
|
||||
* 합니다. 등록하지 않으면 `getAllPurposes()` 에서 빠져 관리자가 그 목적의 메시지 템플릿·
|
||||
* 정책을 만들 수 없고, `hasPurpose()` 도 false 가 됩니다.
|
||||
*
|
||||
* @var array<string, array<string, mixed>>
|
||||
*/
|
||||
protected array $corePurposes = [
|
||||
@@ -76,6 +80,14 @@ class IdentityVerificationManager
|
||||
'source_type' => IdentityPolicySourceType::Core->value,
|
||||
'source_identifier' => 'core',
|
||||
],
|
||||
IdentityVerificationPurpose::Login->value => [
|
||||
'label' => 'identity.purposes.login.label',
|
||||
'description' => 'identity.purposes.login.description',
|
||||
'default_provider' => null,
|
||||
'allowed_channels' => [IdentityVerificationChannel::Email->value],
|
||||
'source_type' => IdentityPolicySourceType::Core->value,
|
||||
'source_identifier' => 'core',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -87,7 +99,6 @@ class IdentityVerificationManager
|
||||
* 프로바이더를 등록합니다.
|
||||
*
|
||||
* @param IdentityVerificationInterface $provider IDV 프로바이더 인스턴스
|
||||
* @return void
|
||||
*/
|
||||
public function register(IdentityVerificationInterface $provider): void
|
||||
{
|
||||
@@ -98,7 +109,6 @@ class IdentityVerificationManager
|
||||
* 프로바이더 등록을 해제합니다.
|
||||
*
|
||||
* @param string $id 프로바이더 식별자 (예: g7:core.mail)
|
||||
* @return void
|
||||
*/
|
||||
public function unregister(string $id): void
|
||||
{
|
||||
@@ -249,7 +259,6 @@ class IdentityVerificationManager
|
||||
* @param array<string, array<string, mixed>> $purposes key => metadata 매핑
|
||||
* @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin' 으로 마킹)
|
||||
* @param string|null $sourceIdentifier source 식별자 (module/plugin id; 미명시 시 'admin')
|
||||
* @return void
|
||||
*/
|
||||
public function registerDeclaredPurposes(array $purposes, ?string $sourceType = null, ?string $sourceIdentifier = null): void
|
||||
{
|
||||
@@ -284,7 +293,6 @@ class IdentityVerificationManager
|
||||
* @param array<string, mixed> $meta label/description/allowed_channels 등 메타데이터
|
||||
* @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin')
|
||||
* @param string|null $sourceIdentifier source 식별자 (미명시 시 'admin')
|
||||
* @return void
|
||||
*/
|
||||
public function registerPurpose(string $key, array $meta, ?string $sourceType = null, ?string $sourceIdentifier = null): void
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가드 활성 여부를 반환합니다.
|
||||
*
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) 호출해야 합니다.
|
||||
|
||||
@@ -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,15 +4,16 @@ namespace App\Http\Controllers\Api\Auth;
|
||||
|
||||
use App\Exceptions\Auth\AccountLockedException;
|
||||
use App\Http\Controllers\Api\Base\AuthBaseController;
|
||||
use App\Http\Requests\Auth\AuthenticatedRequest;
|
||||
use App\Http\Requests\Auth\ForgotPasswordRequest;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Http\Requests\Auth\ResetPasswordRequest;
|
||||
use App\Http\Requests\Auth\TwoFactorChallengeRequest;
|
||||
use App\Http\Requests\Auth\ValidateResetTokenRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Services\AuthService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends AuthBaseController
|
||||
@@ -26,6 +27,8 @@ class AuthController extends AuthBaseController
|
||||
// 공개 인증 엔드포인트를 제외한 나머지에만 인증 미들웨어 적용
|
||||
$this->middleware('auth:sanctum')->except([
|
||||
'login',
|
||||
// 2단계 인증 확인은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
|
||||
'verifyTwoFactor',
|
||||
'register',
|
||||
'forgotPassword',
|
||||
'resetPassword',
|
||||
@@ -47,20 +50,59 @@ class AuthController extends AuthBaseController
|
||||
$request->validated()['password']
|
||||
);
|
||||
|
||||
// 2단계 인증이 켜져 있으면 아직 토큰이 없다 — 인증 코드 확인 단계로 안내한다
|
||||
if ($data['two_factor_required'] ?? false) {
|
||||
return $this->success('auth.two_factor_required', $data);
|
||||
}
|
||||
|
||||
// 사용자 정보는 Resource로, 토큰은 그대로
|
||||
$data['user'] = new UserResource($data['user']);
|
||||
|
||||
return $this->success('auth.login_success', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
return $this->error('auth.account_locked', 423, [
|
||||
'locked_until' => $e->lockedUntil->toIso8601String(),
|
||||
'retry_after_seconds' => $e->remainingMinutes * 60,
|
||||
], ['minutes' => $e->remainingMinutes]);
|
||||
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
|
||||
return $this->error(
|
||||
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
|
||||
423,
|
||||
[
|
||||
'locked_until' => $e->lockedUntil?->toIso8601String(),
|
||||
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
|
||||
'permanent' => $e->isPermanent(),
|
||||
],
|
||||
['minutes' => $e->remainingMinutes]
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.login_failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드를 확인하고 로그인을 완료합니다.
|
||||
*
|
||||
* 비밀번호 확인 단계(`login`)는 토큰 대신 challenge 를 돌려주며, 이 엔드포인트가
|
||||
* 코드 확인에 성공해야 비로소 토큰이 발급됩니다.
|
||||
*
|
||||
* @param TwoFactorChallengeRequest $request challenge 확인 요청
|
||||
* @return JsonResponse 로그인 결과와 사용자 정보, 토큰을 포함한 JSON 응답
|
||||
*/
|
||||
public function verifyTwoFactor(TwoFactorChallengeRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
try {
|
||||
$data = $this->authService->completeTwoFactor(
|
||||
$validated['challenge_id'],
|
||||
['code' => $validated['code']]
|
||||
);
|
||||
|
||||
$data['user'] = new UserResource($data['user']);
|
||||
|
||||
return $this->success('auth.login_success', $data);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.two_factor_failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로운 사용자를 등록시킵니다.
|
||||
*
|
||||
@@ -84,10 +126,10 @@ class AuthController extends AuthBaseController
|
||||
/**
|
||||
* 사용자를 로그아웃시킵니다. (현재 디바이스만)
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
|
||||
* @return JsonResponse 로그아웃 성공 메시지
|
||||
*/
|
||||
public function logout(Request $request): JsonResponse
|
||||
public function logout(AuthenticatedRequest $request): JsonResponse
|
||||
{
|
||||
$this->authService->logout($request->user());
|
||||
|
||||
@@ -97,10 +139,10 @@ class AuthController extends AuthBaseController
|
||||
/**
|
||||
* 모든 디바이스에서 사용자를 로그아웃시킵니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
|
||||
* @return JsonResponse 로그아웃 성공 메시지
|
||||
*/
|
||||
public function logoutFromAllDevices(Request $request): JsonResponse
|
||||
public function logoutFromAllDevices(AuthenticatedRequest $request): JsonResponse
|
||||
{
|
||||
$this->authService->logoutFromAllDevices($request->user());
|
||||
|
||||
@@ -110,10 +152,10 @@ class AuthController extends AuthBaseController
|
||||
/**
|
||||
* 현재 로그인된 사용자의 정보를 반환합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
|
||||
* @return JsonResponse 사용자 정보를 포함한 JSON 응답
|
||||
*/
|
||||
public function user(Request $request): JsonResponse
|
||||
public function user(AuthenticatedRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -131,10 +173,10 @@ class AuthController extends AuthBaseController
|
||||
/**
|
||||
* 사용자의 인증 토큰을 갱신합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
|
||||
* @return JsonResponse 새로운 토큰과 사용자 정보를 포함한 JSON 응답
|
||||
*/
|
||||
public function refresh(Request $request): JsonResponse
|
||||
public function refresh(AuthenticatedRequest $request): JsonResponse
|
||||
{
|
||||
$data = $this->authService->refreshToken($request->user());
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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를 동적으로 추가할 수 있도록 훅 제공
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드 확인 요청
|
||||
*
|
||||
* 비밀번호 확인을 통과한 뒤 발급된 challenge 와 사용자가 받은 인증 코드를 받습니다.
|
||||
* 이 요청은 아직 로그인 전 상태에서 호출되므로 인증 미들웨어를 거치지 않으며,
|
||||
* 주체 식별은 challenge 에 기록된 사용자로만 이루어집니다.
|
||||
*/
|
||||
class TwoFactorChallengeRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 사용자가 이 요청을 수행할 권한이 있는지 확인합니다.
|
||||
*
|
||||
* @return bool 항상 true (주체 식별은 challenge 가 담당)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙을 반환합니다.
|
||||
*
|
||||
* @return array<string, mixed> 검증 규칙
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'challenge_id' => ['required', 'string', 'uuid'],
|
||||
// 코드 자리수는 프로바이더 설정에 따라 달라지므로 상·하한만 느슨하게 두고
|
||||
// 정확한 일치 판정은 프로바이더가 수행한다.
|
||||
'code' => ['required', 'string', 'min:4', 'max:16'],
|
||||
];
|
||||
|
||||
// 확장이 자체 2단계 수단(앱 OTP·SMS 등)을 붙일 때 입력 필드를 추가할 수 있도록 개방한다
|
||||
return HookManager::applyFilters('core.auth.two_factor_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 실패 메시지를 반환합니다.
|
||||
*
|
||||
* @return array<string, string> 검증 메시지
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'challenge_id.required' => __('validation.auth.two_factor.challenge_required'),
|
||||
'challenge_id.uuid' => __('validation.auth.two_factor.challenge_invalid'),
|
||||
'code.required' => __('validation.auth.two_factor.code_required'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,14 @@ use Illuminate\Foundation\Http\FormRequest;
|
||||
/**
|
||||
* 등록된 IDV purpose 목록 조회 요청 (GET /api/identity/purposes).
|
||||
*
|
||||
* 코어 4종 + 활성 모듈/플러그인 declarative purpose + filter 훅 확장 결과를 반환.
|
||||
* 코어 5종 + 활성 모듈/플러그인 declarative purpose + filter 훅 확장 결과를 반환.
|
||||
*/
|
||||
class PurposesIndexRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 요청 권한 — 공개 엔드포인트.
|
||||
*
|
||||
* @return bool
|
||||
* @return bool 항상 true (인증 불필요)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Requests\Settings;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\Attachment;
|
||||
use App\Search\Engines\DatabaseFulltextEngine;
|
||||
use App\Support\AllowedExtensions;
|
||||
use App\Support\AssetUrl;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
@@ -106,6 +107,28 @@ class SaveSettingsRequest extends FormRequest
|
||||
'advanced.geoip_enabled',
|
||||
'advanced.geoip_auto_update_enabled',
|
||||
]);
|
||||
|
||||
$this->normalizeAllowedExtensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용 확장자를 저장 형태(소문자 확장자 배열)로 정규화합니다.
|
||||
*
|
||||
* 화면 입력 칸은 콤마로 구분한 한 줄 텍스트라 이 값은 문자열로 들어온다. 문자열 그대로
|
||||
* 저장하면 업로드 제한을 적용하는 지점이 그 값을 쓰지 못해, 저장은 성공하는데 제한은
|
||||
* 종전 목록으로 남는다. 형태 판정은 저장 경로 진입 전 이 한 곳에서만 수행한다.
|
||||
*/
|
||||
private function normalizeAllowedExtensions(): void
|
||||
{
|
||||
$upload = $this->input('upload', []);
|
||||
|
||||
if (! is_array($upload) || ! array_key_exists('allowed_extensions', $upload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$upload['allowed_extensions'] = AllowedExtensions::normalize($upload['allowed_extensions']);
|
||||
|
||||
$this->merge(['upload' => $upload]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,11 +225,11 @@ class SaveSettingsRequest extends FormRequest
|
||||
'mail.from_name' => $this->getTabRules($tab, 'mail', 'string|max:255'),
|
||||
|
||||
// 업로드 설정
|
||||
'upload.max_file_size' => $this->getTabRules($tab, 'upload', 'integer|min:1|max:1024'),
|
||||
'upload.max_file_size' => $this->getTabRules($tab, 'upload', 'integer|min:'.config('core.settings_limits.upload_max_file_size_min', 1).'|max:'.config('core.settings_limits.upload_max_file_size_max', 1024)),
|
||||
'upload.allowed_extensions' => $this->getAllowedExtensionsRules($tab),
|
||||
'upload.image_max_width' => ['nullable', 'integer', 'min:100', 'max:10000'],
|
||||
'upload.image_max_height' => ['nullable', 'integer', 'min:100', 'max:10000'],
|
||||
'upload.image_quality' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'upload.image_max_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_width_min', 100), 'max:'.config('core.settings_limits.upload_image_max_width_max', 10000)],
|
||||
'upload.image_max_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_height_min', 100), 'max:'.config('core.settings_limits.upload_image_max_height_max', 10000)],
|
||||
'upload.image_quality' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_quality_min', 1), 'max:'.config('core.settings_limits.upload_image_quality_max', 100)],
|
||||
|
||||
// SEO 설정
|
||||
'seo.meta_title_suffix' => ['nullable', 'string', 'max:100'],
|
||||
@@ -220,16 +243,16 @@ class SaveSettingsRequest extends FormRequest
|
||||
'seo.bot_detection_enabled' => ['nullable', 'boolean'],
|
||||
'seo.bot_detection_library_enabled' => ['nullable', 'boolean'],
|
||||
'seo.og_default_site_name' => ['nullable', 'string', 'max:200'],
|
||||
'seo.og_image_default_width' => ['nullable', 'integer', 'min:0', 'max:8000'],
|
||||
'seo.og_image_default_height' => ['nullable', 'integer', 'min:0', 'max:8000'],
|
||||
'seo.og_image_default_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_width_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_width_max', 8000)],
|
||||
'seo.og_image_default_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_height_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_height_max', 8000)],
|
||||
'seo.twitter_default_card' => ['nullable', 'string', Rule::in(['summary', 'summary_large_image', 'app', 'player', ''])],
|
||||
'seo.twitter_default_site' => ['nullable', 'string', 'max:50'],
|
||||
'seo.cache_enabled' => ['nullable', 'boolean'],
|
||||
'seo.cache_ttl' => ['nullable', 'integer', 'min:60', 'max:86400'],
|
||||
'seo.cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_cache_ttl_min', 60), 'max:'.config('core.settings_limits.seo_cache_ttl_max', 86400)],
|
||||
'seo.sitemap_enabled' => ['nullable', 'boolean'],
|
||||
'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'],
|
||||
'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_sitemap_cache_ttl_min', 3600), 'max:'.config('core.settings_limits.seo_sitemap_cache_ttl_max', 604800)],
|
||||
// 분할 기준. 상한 50000 은 sitemaps.org 프로토콜 제한이며 SitemapWriter 가 동일 값으로 클램프합니다.
|
||||
'seo.sitemap_urls_per_file' => ['nullable', 'integer', 'min:1000', 'max:50000'],
|
||||
'seo.sitemap_urls_per_file' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_sitemap_urls_per_file_min', 1000), 'max:'.config('core.settings_limits.seo_sitemap_urls_per_file_max', 50000)],
|
||||
'seo.sitemap_gzip' => ['nullable', 'boolean'],
|
||||
'seo.sitemap_serve_stale_on_miss' => ['nullable', 'boolean'],
|
||||
'seo.sitemap_max_urls_per_contributor' => ['nullable', 'integer', 'min:0'],
|
||||
@@ -243,8 +266,18 @@ class SaveSettingsRequest extends FormRequest
|
||||
'security.force_https' => $this->getTabRules($tab, 'security', 'boolean'),
|
||||
'security.login_attempt_enabled' => $this->getTabRules($tab, 'security', 'boolean'),
|
||||
'security.auth_token_lifetime' => $this->getSecurityTabAuthTokenRules($tab),
|
||||
'security.max_login_attempts' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||
'security.login_lockout_time' => ['nullable', 'integer', 'min:0', 'max:1440'],
|
||||
'security.max_login_attempts' => ['nullable', 'integer', 'min:'.config('core.settings_limits.security_max_login_attempts_min', 0), 'max:'.config('core.settings_limits.security_max_login_attempts_max', 100)],
|
||||
'security.login_lockout_time' => ['nullable', 'integer', 'min:'.config('core.settings_limits.security_login_lockout_time_min', 0), 'max:'.config('core.settings_limits.security_login_lockout_time_max', 1440)],
|
||||
// 비밀번호 정책 — 저장 규칙이 없어 whitelist 에서 탈락하던 고아 설정을 배선.
|
||||
// 경계값은 config('core.settings_limits') 가 SSoT — 설정 화면도 같은 값을 받아 쓴다.
|
||||
'security.password_min_length' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:'.config('core.settings_limits.security_password_min_length_min', 6),
|
||||
'max:'.config('core.settings_limits.security_password_min_length_max', 64),
|
||||
],
|
||||
'security.require_password_special_char' => ['nullable', 'boolean'],
|
||||
'security.two_factor_auth' => ['nullable', 'boolean'],
|
||||
// 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송 시 기본값 false 유지
|
||||
'security.allow_internal_outbound_urls' => ['nullable', 'boolean'],
|
||||
|
||||
@@ -258,7 +291,7 @@ class SaveSettingsRequest extends FormRequest
|
||||
'advanced.seo_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab),
|
||||
// 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송/미지정 시 코드 기본값 유지.
|
||||
// 범위는 SEO 탭의 오버라이드 칸(seo.sitemap_cache_ttl)과 동일하게 맞춘다.
|
||||
'advanced.seo_sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'],
|
||||
'advanced.seo_sitemap_cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_seo_sitemap_cache_ttl_min', 3600), 'max:'.config('core.settings_limits.advanced_seo_sitemap_cache_ttl_max', 604800)],
|
||||
|
||||
// 디버그 설정 (advanced 탭)
|
||||
'advanced.debug_mode' => $this->getTabRules($tab, 'advanced', 'boolean'),
|
||||
@@ -284,35 +317,35 @@ class SaveSettingsRequest extends FormRequest
|
||||
'drivers.s3_url' => ['nullable', 'url', 'max:500'],
|
||||
'drivers.cache_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_CACHE_DRIVERS)]),
|
||||
'drivers.redis_host' => ['nullable', 'string', 'max:255'],
|
||||
'drivers.redis_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'drivers.redis_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_redis_port_min', 1), 'max:'.config('core.settings_limits.drivers_redis_port_max', 65535)],
|
||||
'drivers.redis_password' => ['nullable', 'string', 'max:255'],
|
||||
'drivers.redis_database' => ['nullable', 'integer', 'min:0', 'max:15'],
|
||||
'drivers.redis_database' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_redis_database_min', 0), 'max:'.config('core.settings_limits.drivers_redis_database_max', 15)],
|
||||
'drivers.memcached_host' => ['nullable', 'string', 'max:255'],
|
||||
'drivers.memcached_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'drivers.memcached_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_memcached_port_min', 1), 'max:'.config('core.settings_limits.drivers_memcached_port_max', 65535)],
|
||||
'drivers.session_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_SESSION_DRIVERS)]),
|
||||
'drivers.session_lifetime' => ['nullable', 'integer', 'min:1', 'max:43200'], // 1분 ~ 30일
|
||||
'drivers.session_lifetime' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_session_lifetime_min', 1), 'max:'.config('core.settings_limits.drivers_session_lifetime_max', 43200)], // 1분 ~ 30일
|
||||
'drivers.queue_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_QUEUE_DRIVERS)]),
|
||||
'drivers.websocket_enabled' => ['nullable', 'boolean'],
|
||||
'drivers.websocket_app_id' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
|
||||
'drivers.websocket_app_key' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
|
||||
'drivers.websocket_app_secret' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
|
||||
'drivers.websocket_host' => ['nullable', 'string', 'max:255'],
|
||||
'drivers.websocket_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'drivers.websocket_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_websocket_port_min', 1), 'max:'.config('core.settings_limits.drivers_websocket_port_max', 65535)],
|
||||
'drivers.websocket_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)],
|
||||
'drivers.websocket_verify_ssl' => ['nullable', 'boolean'],
|
||||
'drivers.websocket_server_host' => ['nullable', 'string', 'max:255'],
|
||||
'drivers.websocket_server_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'drivers.websocket_server_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_websocket_server_port_min', 1), 'max:'.config('core.settings_limits.drivers_websocket_server_port_max', 65535)],
|
||||
'drivers.websocket_server_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)],
|
||||
'drivers.search_engine_driver' => ['nullable', Rule::in($this->getSupportedSearchEngineDrivers())],
|
||||
'drivers.log_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_DRIVERS)]),
|
||||
'drivers.log_level' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_LEVELS)]),
|
||||
'drivers.log_days' => ['nullable', 'integer', 'min:1', 'max:365'],
|
||||
'drivers.log_days' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_log_days_min', 1), 'max:'.config('core.settings_limits.drivers_log_days_max', 365)],
|
||||
|
||||
// 본인인증(IDV) provider 기술 파라미터 — 정책 분기는 IdentityPolicy 로 흡수됨
|
||||
'identity.default_provider' => ['nullable', 'string', 'max:100'],
|
||||
'identity.purpose_providers' => ['sometimes', 'array'],
|
||||
'identity.challenge_ttl_minutes' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:1440'),
|
||||
'identity.max_attempts' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:20'),
|
||||
'identity.challenge_ttl_minutes' => $this->getTabRules($tab, 'identity', 'integer|min:'.config('core.settings_limits.identity_challenge_ttl_minutes_min', 1).'|max:'.config('core.settings_limits.identity_challenge_ttl_minutes_max', 1440)),
|
||||
'identity.max_attempts' => $this->getTabRules($tab, 'identity', 'integer|min:'.config('core.settings_limits.identity_max_attempts_min', 1).'|max:'.config('core.settings_limits.identity_max_attempts_max', 20)),
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
|
||||
@@ -374,8 +407,8 @@ class SaveSettingsRequest extends FormRequest
|
||||
return [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:3600',
|
||||
'min:'.config('core.settings_limits.security_auth_token_lifetime_min', 0),
|
||||
'max:'.config('core.settings_limits.security_auth_token_lifetime_max', 3600),
|
||||
function ($attribute, $value, $fail) {
|
||||
// 0은 무한대를 의미하므로 허용, 그 외에는 30 이상이어야 함
|
||||
if ($value !== null && $value !== 0 && $value < 30) {
|
||||
@@ -436,8 +469,8 @@ class SaveSettingsRequest extends FormRequest
|
||||
|
||||
return array_merge($baseRules, [
|
||||
'integer',
|
||||
'min:0',
|
||||
'max:14400',
|
||||
'min:'.config('core.settings_limits.advanced_cache_ttl_min', 0),
|
||||
'max:'.config('core.settings_limits.advanced_cache_ttl_max', 14400),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -780,6 +813,120 @@ class SaveSettingsRequest extends FormRequest
|
||||
'identity.purpose_providers' => __('validation.attributes.identity_purpose_providers'),
|
||||
'identity.challenge_ttl_minutes' => __('validation.attributes.identity_challenge_ttl_minutes'),
|
||||
'identity.max_attempts' => __('validation.attributes.identity_max_attempts'),
|
||||
// notifications
|
||||
'notifications.channels' => __('validation.attributes.channels'),
|
||||
// general
|
||||
'general.currency' => __('validation.attributes.currency'),
|
||||
'general.maintenance_mode' => __('validation.attributes.maintenance_mode'),
|
||||
'general.asset_url_mode' => __('validation.attributes.asset_url_mode'),
|
||||
'general.site_logo' => __('validation.attributes.site_logo'),
|
||||
// mail
|
||||
'mail.mailer' => __('validation.attributes.mailer'),
|
||||
'mail.host' => __('validation.attributes.host'),
|
||||
'mail.port' => __('validation.attributes.port'),
|
||||
'mail.username' => __('validation.attributes.username'),
|
||||
'mail.password' => __('validation.attributes.password'),
|
||||
'mail.encryption' => __('validation.attributes.encryption'),
|
||||
'mail.mailgun_domain' => __('validation.attributes.mailgun_domain'),
|
||||
'mail.mailgun_secret' => __('validation.attributes.mailgun_secret'),
|
||||
'mail.mailgun_endpoint' => __('validation.attributes.mailgun_endpoint'),
|
||||
'mail.ses_key' => __('validation.attributes.ses_key'),
|
||||
'mail.ses_secret' => __('validation.attributes.ses_secret'),
|
||||
'mail.ses_region' => __('validation.attributes.ses_region'),
|
||||
'mail.from_address' => __('validation.attributes.from_address'),
|
||||
'mail.from_name' => __('validation.attributes.from_name'),
|
||||
// upload
|
||||
'upload.max_file_size' => __('validation.attributes.max_file_size'),
|
||||
'upload.allowed_extensions' => __('validation.attributes.allowed_extensions'),
|
||||
'upload.image_max_width' => __('validation.attributes.image_max_width'),
|
||||
'upload.image_max_height' => __('validation.attributes.image_max_height'),
|
||||
'upload.image_quality' => __('validation.attributes.image_quality'),
|
||||
// seo
|
||||
'seo.meta_title_suffix' => __('validation.attributes.meta_title_suffix'),
|
||||
'seo.meta_description' => __('validation.attributes.meta_description'),
|
||||
'seo.meta_keywords' => __('validation.attributes.meta_keywords'),
|
||||
'seo.google_analytics_id' => __('validation.attributes.google_analytics_id'),
|
||||
'seo.google_site_verification' => __('validation.attributes.google_site_verification'),
|
||||
'seo.naver_site_verification' => __('validation.attributes.naver_site_verification'),
|
||||
'seo.bot_user_agents' => __('validation.attributes.bot_user_agents'),
|
||||
'seo.bot_detection_enabled' => __('validation.attributes.bot_detection_enabled'),
|
||||
'seo.bot_detection_library_enabled' => __('validation.attributes.bot_detection_library_enabled'),
|
||||
'seo.og_default_site_name' => __('validation.attributes.og_default_site_name'),
|
||||
'seo.og_image_default_width' => __('validation.attributes.og_image_default_width'),
|
||||
'seo.og_image_default_height' => __('validation.attributes.og_image_default_height'),
|
||||
'seo.twitter_default_card' => __('validation.attributes.twitter_default_card'),
|
||||
'seo.twitter_default_site' => __('validation.attributes.twitter_default_site'),
|
||||
'seo.cache_enabled' => __('validation.attributes.seo_page_cache_enabled'),
|
||||
'seo.cache_ttl' => __('validation.attributes.cache_ttl'),
|
||||
'seo.sitemap_enabled' => __('validation.attributes.sitemap_enabled'),
|
||||
'seo.sitemap_cache_ttl' => __('validation.attributes.sitemap_cache_ttl'),
|
||||
'seo.sitemap_urls_per_file' => __('validation.attributes.sitemap_urls_per_file'),
|
||||
'seo.sitemap_gzip' => __('validation.attributes.sitemap_gzip'),
|
||||
'seo.sitemap_serve_stale_on_miss' => __('validation.attributes.sitemap_serve_stale_on_miss'),
|
||||
'seo.sitemap_max_urls_per_contributor' => __('validation.attributes.sitemap_max_urls_per_contributor'),
|
||||
'seo.sitemap_hreflang_enabled' => __('validation.attributes.sitemap_hreflang_enabled'),
|
||||
'seo.sitemap_schedule' => __('validation.attributes.sitemap_schedule'),
|
||||
'seo.sitemap_schedule_time' => __('validation.attributes.sitemap_schedule_time'),
|
||||
'seo.generator_enabled' => __('validation.attributes.generator_enabled'),
|
||||
'seo.generator_content' => __('validation.attributes.generator_content'),
|
||||
// security
|
||||
'security.force_https' => __('validation.attributes.force_https'),
|
||||
'security.login_attempt_enabled' => __('validation.attributes.login_attempt_enabled'),
|
||||
'security.auth_token_lifetime' => __('validation.attributes.auth_token_lifetime'),
|
||||
'security.max_login_attempts' => __('validation.attributes.max_login_attempts'),
|
||||
'security.login_lockout_time' => __('validation.attributes.login_lockout_time'),
|
||||
'security.password_min_length' => __('validation.attributes.password_min_length'),
|
||||
'security.require_password_special_char' => __('validation.attributes.require_password_special_char'),
|
||||
'security.two_factor_auth' => __('validation.attributes.two_factor_auth'),
|
||||
'security.allow_internal_outbound_urls' => __('validation.attributes.allow_internal_outbound_urls'),
|
||||
// advanced
|
||||
'advanced.cache_enabled' => __('validation.attributes.advanced_cache_enabled'),
|
||||
'advanced.layout_cache_enabled' => __('validation.attributes.layout_cache_enabled'),
|
||||
'advanced.layout_cache_ttl' => __('validation.attributes.layout_cache_ttl'),
|
||||
'advanced.stats_cache_enabled' => __('validation.attributes.stats_cache_enabled'),
|
||||
'advanced.stats_cache_ttl' => __('validation.attributes.stats_cache_ttl'),
|
||||
'advanced.seo_cache_enabled' => __('validation.attributes.seo_cache_enabled'),
|
||||
'advanced.seo_cache_ttl' => __('validation.attributes.seo_cache_ttl'),
|
||||
'advanced.seo_sitemap_cache_ttl' => __('validation.attributes.seo_sitemap_cache_ttl'),
|
||||
'advanced.debug_mode' => __('validation.attributes.debug_mode'),
|
||||
'advanced.sql_query_log' => __('validation.attributes.sql_query_log'),
|
||||
'advanced.core_update_github_url' => __('validation.attributes.core_update_github_url'),
|
||||
'advanced.core_update_github_token' => __('validation.attributes.core_update_github_token'),
|
||||
'advanced.geoip_enabled' => __('validation.attributes.geoip_enabled'),
|
||||
'advanced.geoip_license_key' => __('validation.attributes.geoip_license_key'),
|
||||
'advanced.geoip_auto_update_enabled' => __('validation.attributes.geoip_auto_update_enabled'),
|
||||
// drivers
|
||||
'drivers.storage_driver' => __('validation.attributes.storage_driver'),
|
||||
'drivers.s3_bucket' => __('validation.attributes.s3_bucket'),
|
||||
'drivers.s3_region' => __('validation.attributes.s3_region'),
|
||||
'drivers.s3_access_key' => __('validation.attributes.s3_access_key'),
|
||||
'drivers.s3_secret_key' => __('validation.attributes.s3_secret_key'),
|
||||
'drivers.s3_url' => __('validation.attributes.s3_url'),
|
||||
'drivers.cache_driver' => __('validation.attributes.cache_driver'),
|
||||
'drivers.redis_host' => __('validation.attributes.redis_host'),
|
||||
'drivers.redis_port' => __('validation.attributes.redis_port'),
|
||||
'drivers.redis_password' => __('validation.attributes.redis_password'),
|
||||
'drivers.redis_database' => __('validation.attributes.redis_database'),
|
||||
'drivers.memcached_host' => __('validation.attributes.memcached_host'),
|
||||
'drivers.memcached_port' => __('validation.attributes.memcached_port'),
|
||||
'drivers.session_driver' => __('validation.attributes.session_driver'),
|
||||
'drivers.session_lifetime' => __('validation.attributes.session_lifetime'),
|
||||
'drivers.queue_driver' => __('validation.attributes.queue_driver'),
|
||||
'drivers.websocket_enabled' => __('validation.attributes.websocket_enabled'),
|
||||
'drivers.websocket_app_id' => __('validation.attributes.websocket_app_id'),
|
||||
'drivers.websocket_app_key' => __('validation.attributes.websocket_app_key'),
|
||||
'drivers.websocket_app_secret' => __('validation.attributes.websocket_app_secret'),
|
||||
'drivers.websocket_host' => __('validation.attributes.websocket_host'),
|
||||
'drivers.websocket_port' => __('validation.attributes.websocket_port'),
|
||||
'drivers.websocket_scheme' => __('validation.attributes.websocket_scheme'),
|
||||
'drivers.websocket_verify_ssl' => __('validation.attributes.websocket_verify_ssl'),
|
||||
'drivers.websocket_server_host' => __('validation.attributes.websocket_server_host'),
|
||||
'drivers.websocket_server_port' => __('validation.attributes.websocket_server_port'),
|
||||
'drivers.websocket_server_scheme' => __('validation.attributes.websocket_server_scheme'),
|
||||
'drivers.search_engine_driver' => __('validation.attributes.search_engine_driver'),
|
||||
'drivers.log_driver' => __('validation.attributes.log_driver'),
|
||||
'drivers.log_level' => __('validation.attributes.log_level'),
|
||||
'drivers.log_days' => __('validation.attributes.log_days'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 훅: 모듈이 자신의 데이터를 응답에 병합
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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()
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Repositories\JsonConfigRepository;
|
||||
use App\Support\AllowedExtensions;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
@@ -652,12 +653,27 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
Config::set('filesystems.default', $uploadSettings['disk']);
|
||||
}
|
||||
|
||||
if (! empty($uploadSettings['max_size'])) {
|
||||
Config::set('g7.upload.max_size', (int) $uploadSettings['max_size']);
|
||||
// 관리자 설정은 MB, config/attachment.* 는 KB — 변환은 이 지점 단 한 곳에서만 수행한다.
|
||||
// (기존에는 존재하지 않는 키 `max_size` 를 읽어 설정이 어디에도 반영되지 않았다)
|
||||
if (! empty($uploadSettings['max_file_size'])) {
|
||||
Config::set('attachment.max_file_size', (int) $uploadSettings['max_file_size'] * 1024);
|
||||
}
|
||||
|
||||
if (! empty($uploadSettings['allowed_extensions'])) {
|
||||
Config::set('g7.upload.allowed_extensions', $uploadSettings['allowed_extensions']);
|
||||
// 화면은 콤마 문자열을 보내고 API 직접 호출은 배열을 보낸다. 배열만 받아들이면
|
||||
// 문자열로 저장된 설정이 조용히 버려져 관리자가 확장자를 바꿔도 반영되지 않는다.
|
||||
if (isset($uploadSettings['allowed_extensions'])) {
|
||||
Config::set(
|
||||
'attachment.allowed_extensions',
|
||||
AllowedExtensions::normalize($uploadSettings['allowed_extensions'])
|
||||
);
|
||||
}
|
||||
|
||||
// 이미지 축소 한계값·품질 — 픽셀/퍼센트라 단위 변환이 없다.
|
||||
// 미설정(빈 값)은 "축소하지 않음" 이므로 그대로 넘겨 ImageResizer 가 판정하게 둔다.
|
||||
foreach (['image_max_width', 'image_max_height', 'image_quality'] as $key) {
|
||||
if (isset($uploadSettings[$key]) && $uploadSettings[$key] !== '') {
|
||||
Config::set("attachment.{$key}", (int) $uploadSettings[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
||||
use App\Enums\AttachmentSourceType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\User;
|
||||
use App\Support\ImageResizer;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -27,10 +29,12 @@ class AttachmentService
|
||||
*
|
||||
* @param AttachmentRepositoryInterface $repository 첨부파일 리포지토리
|
||||
* @param StorageInterface $storage 스토리지 드라이버
|
||||
* @param ImageResizer $imageResizer 업로드 이미지 축소기
|
||||
*/
|
||||
public function __construct(
|
||||
private AttachmentRepositoryInterface $repository,
|
||||
private StorageInterface $storage
|
||||
private StorageInterface $storage,
|
||||
private ImageResizer $imageResizer
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -58,8 +62,15 @@ class AttachmentService
|
||||
// 필터 훅 - 파일 데이터 변형 (압축, 리사이즈 등 확장 포인트)
|
||||
$file = HookManager::applyFilters('core.attachment.filter_upload_file', $file);
|
||||
|
||||
// 저장 경로 생성 (날짜별 디렉토리)
|
||||
$storedFilename = Str::uuid().'.'.$file->getClientOriginalExtension();
|
||||
// 환경설정 > 업로드의 최대 가로/세로·품질을 실제로 적용한다.
|
||||
// 임시 파일을 제자리에서 줄이므로 아래의 저장·크기·메타 계산이 모두 축소본을 본다.
|
||||
$this->imageResizer->resizeInPlace($file->getRealPath(), $file->getMimeType());
|
||||
|
||||
// 저장 경로 생성 (날짜별 디렉토리).
|
||||
// 확장자는 클라이언트가 보낸 파일명이 아니라 MIME 추론값을 우선 사용한다 —
|
||||
// 파일명 확장자는 사용자가 임의로 바꿀 수 있어 저장 확장자의 근거가 될 수 없다.
|
||||
$extension = strtolower($file->extension() ?: $file->getClientOriginalExtension());
|
||||
$storedFilename = Str::uuid().($extension !== '' ? '.'.$extension : '');
|
||||
$datePath = date('Y/m/d');
|
||||
$path = "{$datePath}/{$storedFilename}";
|
||||
|
||||
@@ -217,7 +228,7 @@ class AttachmentService
|
||||
* 기능 레벨 권한 체크 (permission_hooks) - 다운로드 기능 자체에 대한 접근 권한
|
||||
*
|
||||
* @param string $hash 첨부파일 해시
|
||||
* @param \App\Models\User|null $user 요청한 사용자 (null이면 비로그인)
|
||||
* @param User|null $user 요청한 사용자 (null이면 비로그인)
|
||||
* @return StreamedResponse|null 다운로드 응답 또는 null
|
||||
*
|
||||
* @throws AuthorizationException 기능 레벨 권한이 없는 경우
|
||||
@@ -263,7 +274,7 @@ class AttachmentService
|
||||
* 컨트롤러에서 fileResponse()로 캐싱 헤더와 함께 응답할 수 있습니다.
|
||||
*
|
||||
* @param string $hash 첨부파일 해시
|
||||
* @param \App\Models\User|null $user 요청한 사용자 (null이면 비로그인)
|
||||
* @param User|null $user 요청한 사용자 (null이면 비로그인)
|
||||
* @return array{path: string, mime_type: string, filename: string}|null 파일 정보 또는 null
|
||||
*
|
||||
* @throws AuthorizationException 기능 레벨 권한이 없는 경우
|
||||
|
||||
@@ -7,6 +7,8 @@ use App\Contracts\Repositories\RoleRepositoryInterface;
|
||||
use App\Contracts\Repositories\UserConsentRepositoryInterface;
|
||||
use App\Contracts\Repositories\UserRepositoryInterface;
|
||||
use App\Enums\ConsentType;
|
||||
use App\Enums\IdentityVerificationPurpose;
|
||||
use App\Enums\IdentityVerificationStatus;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Exceptions\Auth\AccountLockedException;
|
||||
use App\Extension\HookManager;
|
||||
@@ -14,6 +16,7 @@ use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
@@ -99,7 +102,11 @@ class AuthService
|
||||
if ((bool) g7_core_settings('security.login_attempt_enabled', true)) {
|
||||
$candidate = $this->userRepository->findByEmail($email);
|
||||
if ($candidate !== null && $this->userRepository->isLocked($candidate)) {
|
||||
$remaining = max(1, (int) ceil(now()->diffInSeconds($candidate->locked_until, false) / 60));
|
||||
// 영구 잠금은 해제 시각이 없다 — diffInSeconds(null) 로 폭발하지 않도록 분기.
|
||||
$remaining = $candidate->locked_until === null
|
||||
? null
|
||||
: max(1, (int) ceil(now()->diffInSeconds($candidate->locked_until, false) / 60));
|
||||
|
||||
throw new AccountLockedException(
|
||||
lockedUntil: $candidate->locked_until,
|
||||
remainingMinutes: $remaining,
|
||||
@@ -138,6 +145,142 @@ class AuthService
|
||||
]);
|
||||
}
|
||||
|
||||
// 2단계 인증이 켜져 있으면 여기서 토큰을 발급하지 않는다.
|
||||
// 비밀번호 확인만으로 세션이 열리면 2단계가 없는 것과 같으므로, 인증 코드를 확인할
|
||||
// 때까지 로그인 상태를 만들지 않고 challenge 만 돌려준다.
|
||||
if ($this->isTwoFactorRequired($user)) {
|
||||
Auth::logout();
|
||||
|
||||
return $this->startTwoFactorChallenge($user, $email);
|
||||
}
|
||||
|
||||
return $this->issueLoginSession($user, $email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 사용자에게 2단계 인증을 요구해야 하는지 판정합니다.
|
||||
*
|
||||
* @param User $user 로그인 시도 사용자
|
||||
* @return bool 2단계 인증 필요 여부
|
||||
*/
|
||||
private function isTwoFactorRequired(User $user): bool
|
||||
{
|
||||
if (! (bool) g7_core_settings('security.two_factor_auth', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 코드를 받을 수단이 없으면 요구할 수 없다 — 요구하면 그 계정은 영구히 잠긴다.
|
||||
return filled($user->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 challenge 를 발행합니다.
|
||||
*
|
||||
* @param User $user 대상 사용자
|
||||
* @param string $email 로그인에 사용한 이메일
|
||||
* @return array{two_factor_required: bool, challenge_id: string, provider_id: string, expires_at: mixed} challenge 정보
|
||||
*/
|
||||
private function startTwoFactorChallenge(User $user, string $email): array
|
||||
{
|
||||
$identity = app(IdentityVerificationService::class);
|
||||
|
||||
$challenge = $identity->start(
|
||||
IdentityVerificationPurpose::Login->value,
|
||||
$user,
|
||||
[
|
||||
'origin_type' => 'route',
|
||||
'origin_identifier' => 'auth.login',
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]
|
||||
);
|
||||
|
||||
// 코드를 보내지 못하면 사용자는 완료할 수 없는 challenge 를 들고 막다른 길에 선다.
|
||||
// "인증번호를 보냈습니다" 로 안내하면 원인을 알 방법이 없으므로, 발송 실패는 실패로 알린다.
|
||||
// 이때 2단계 인증을 건너뛰고 로그인시키지는 않는다 — 보안 통제가 조용히 열리면
|
||||
// 메일 설정이 깨진 동안 2단계 인증이 없는 것과 같아진다.
|
||||
if (($identity->getStatus($challenge->id)['status'] ?? null) === IdentityVerificationStatus::Failed->value) {
|
||||
Log::error('2단계 인증 코드 발송 실패 — 로그인을 차단합니다', [
|
||||
'user_id' => $user->id,
|
||||
'challenge_id' => $challenge->id,
|
||||
'provider_id' => $challenge->providerId,
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__('auth.two_factor_delivery_failed')],
|
||||
]);
|
||||
}
|
||||
|
||||
HookManager::doAction('core.auth.two_factor_requested', $user, [
|
||||
'email' => $email,
|
||||
'challenge_id' => $challenge->id,
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'two_factor_required' => true,
|
||||
'challenge_id' => $challenge->id,
|
||||
'provider_id' => $challenge->providerId,
|
||||
'expires_at' => $challenge->expiresAt ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드를 확인하고 로그인을 완료합니다.
|
||||
*
|
||||
* @param string $challengeId challenge UUID
|
||||
* @param array<string, mixed> $input 프로바이더 입력 (코드 등)
|
||||
* @return array{user: User, token: string, token_type: string} 로그인 결과
|
||||
*
|
||||
* @throws ValidationException 검증 실패 또는 challenge 불일치
|
||||
*/
|
||||
public function completeTwoFactor(string $challengeId, array $input): array
|
||||
{
|
||||
$identity = app(IdentityVerificationService::class);
|
||||
|
||||
$status = $identity->getStatus($challengeId);
|
||||
|
||||
// 이 흐름 전용 challenge 인지 먼저 확인한다. purpose 를 검사하지 않으면 다른 용도로
|
||||
// 발급된 challenge(가입·비밀번호 재설정 등)를 들고 와 로그인할 수 있다.
|
||||
if (($status['purpose'] ?? null) !== IdentityVerificationPurpose::Login->value) {
|
||||
throw ValidationException::withMessages([
|
||||
'challenge_id' => [__('auth.two_factor_invalid_challenge')],
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $identity->verify($challengeId, $input, [
|
||||
'origin_type' => 'route',
|
||||
'origin_identifier' => 'auth.login',
|
||||
]);
|
||||
|
||||
if (! $result->success) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => [__('auth.two_factor_failed')],
|
||||
]);
|
||||
}
|
||||
|
||||
$user = $identity->resolveVerifiedUser($challengeId, IdentityVerificationPurpose::Login->value);
|
||||
|
||||
if (! $user || $user->status !== UserStatus::Active->value) {
|
||||
throw ValidationException::withMessages([
|
||||
'challenge_id' => [__('auth.two_factor_invalid_challenge')],
|
||||
]);
|
||||
}
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return $this->issueLoginSession($user, (string) $user->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰을 발급하고 로그인 완료 훅을 실행합니다.
|
||||
*
|
||||
* @param User $user 로그인 사용자
|
||||
* @param string $email 로그인에 사용한 이메일
|
||||
* @return array{user: User, token: string, token_type: string} 로그인 결과
|
||||
*/
|
||||
private function issueLoginSession(User $user, string $email): array
|
||||
{
|
||||
$token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken;
|
||||
|
||||
// 세션에 사용자 저장 (/dev 대시보드 인증용)
|
||||
|
||||
@@ -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,12 +206,43 @@ class IdentityVerificationService
|
||||
'purpose' => $log->purpose,
|
||||
'render_hint' => $log->render_hint,
|
||||
'expires_at' => optional($log->expires_at)->toIso8601String(),
|
||||
'attempts' => (int) $log->attempts,
|
||||
// 시도 횟수(attempts)는 공개 폴링 응답에 싣지 않는다 — challenge id 만 알면 누구나
|
||||
// 조회할 수 있는 경로라, 남의 인증 시도가 몇 번 실패했는지가 그대로 드러난다.
|
||||
// 화면의 '남은 시도 횟수' 는 모달이 자기 시도를 세어 표시하므로 영향이 없다.
|
||||
'max_attempts' => (int) $log->max_attempts,
|
||||
'public_payload' => $publicPayload,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 purpose 로 검증을 마친 challenge 의 대상 사용자를 반환합니다.
|
||||
*
|
||||
* 로그인 2단계 인증처럼 **아직 인증되지 않은 주체**를 challenge 로만 식별해야 하는 흐름에서
|
||||
* 사용합니다. `getStatus()` 는 challenge id 만 알면 누구나 조회할 수 있는 공개 폴링 경로라
|
||||
* `user_id` 를 싣지 않으므로, 주체 해석은 이 메서드로 분리합니다.
|
||||
*
|
||||
* purpose 를 인자로 받아 대조하는 이유: 다른 용도(가입·비밀번호 재설정)로 발급된 challenge 를
|
||||
* 들고 와 로그인하는 것을 막기 위함입니다.
|
||||
*
|
||||
* @param string $challengeId Challenge UUID
|
||||
* @param string $purpose 기대하는 purpose
|
||||
* @return User|null 검증 완료된 대상 사용자 (조건 불일치 시 null)
|
||||
*/
|
||||
public function resolveVerifiedUser(string $challengeId, string $purpose): ?User
|
||||
{
|
||||
$log = $this->logRepository->findById($challengeId);
|
||||
|
||||
if (! $log || $log->purpose !== $purpose || $log->verified_at === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($log->user_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->userRepository->findById((int) $log->user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 IDV provider 의 redirect 콜백을 처리합니다.
|
||||
*
|
||||
@@ -222,6 +254,7 @@ class IdentityVerificationService
|
||||
* @param array<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
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Extension\StorageInterface;
|
||||
use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface;
|
||||
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
||||
use App\Models\TemplateLayoutAttachment;
|
||||
use App\Support\ImageResizer;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -56,6 +57,11 @@ class TemplateLayoutAttachmentService
|
||||
$relativePath = "{$templateIdentifier}/".date('Y/m/d')."/{$storedFilename}";
|
||||
|
||||
$disk = config('attachment.disk', 'attachments');
|
||||
|
||||
// 환경설정 > 업로드의 최대 가로/세로·품질 적용 (코어 설정이 모든 업로드 경로에 동일 적용).
|
||||
// 임시 파일을 제자리에서 줄이므로 아래의 저장·크기 기록이 모두 축소본을 본다.
|
||||
app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType());
|
||||
|
||||
$stored = $this->storage
|
||||
->withDisk($disk)
|
||||
->put(self::STORAGE_CATEGORY, $relativePath, file_get_contents($file->getRealPath()));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 업로드 이미지 축소 (환경설정 > 업로드)
|
||||
*
|
||||
* `upload.image_max_width` / `image_max_height` / `image_quality` 는 저장·검증만 되고
|
||||
* 실제로 이미지를 줄이는 코드가 없었습니다. 관리자는 값을 바꾸면 적용된다고 인지하지만
|
||||
* 원본이 그대로 저장돼, 설정 화면과 실제 동작이 어긋나 있었습니다.
|
||||
*
|
||||
* 단위 규약: config/attachment.image_* 는 픽셀, `image_quality` 는 1~100 입니다.
|
||||
* MB↔KB 처럼 변환이 필요한 값이 아니므로 `SettingsServiceProvider` 가 그대로 옮깁니다.
|
||||
*
|
||||
* 한계값이 없으면(설정 미지정) 아무 것도 하지 않습니다 — 기존 동작을 유지해,
|
||||
* 이 기능이 도입됐다는 이유만으로 기존 사이트의 이미지가 갑자기 줄어들지 않도록 합니다.
|
||||
*/
|
||||
class ImageResizer
|
||||
{
|
||||
/**
|
||||
* 축소를 지원하는 MIME 타입 → GD 로더/세이버 매핑
|
||||
*/
|
||||
private const SUPPORTED = [
|
||||
'image/jpeg' => 'jpeg',
|
||||
'image/jpg' => 'jpeg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
|
||||
/**
|
||||
* 기본 품질 (설정 미지정 시)
|
||||
*/
|
||||
private const DEFAULT_QUALITY = 85;
|
||||
|
||||
/**
|
||||
* 이미지가 설정 상한을 넘으면 비율을 유지한 채 파일을 제자리에서 축소합니다.
|
||||
*
|
||||
* 상한 이내이거나 지원하지 않는 형식이면 파일을 건드리지 않습니다 — 상한 이내인데도
|
||||
* 다시 인코딩하면 아무 이득 없이 화질만 떨어집니다.
|
||||
*
|
||||
* @param string $path 이미지 파일 절대 경로 (제자리 수정)
|
||||
* @param string|null $mimeType 파일 MIME 타입
|
||||
* @return bool 실제로 축소했으면 true
|
||||
*/
|
||||
public function resizeInPlace(string $path, ?string $mimeType): bool
|
||||
{
|
||||
$format = self::SUPPORTED[strtolower((string) $mimeType)] ?? null;
|
||||
|
||||
if ($format === null || ! extension_loaded('gd') || ! is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxWidth = $this->limit('image_max_width');
|
||||
$maxHeight = $this->limit('image_max_height');
|
||||
|
||||
if ($maxWidth === null && $maxHeight === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$size = @getimagesize($path);
|
||||
|
||||
if ($size === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$width, $height] = [(int) $size[0], (int) $size[1]];
|
||||
|
||||
if ($width <= 0 || $height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scale = min(
|
||||
$maxWidth !== null ? $maxWidth / $width : 1.0,
|
||||
$maxHeight !== null ? $maxHeight / $height : 1.0,
|
||||
1.0
|
||||
);
|
||||
|
||||
if ($scale >= 1.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 반올림으로 0 이 되는 극단 비율에서도 최소 1px 은 남긴다
|
||||
$targetWidth = max(1, (int) round($width * $scale));
|
||||
$targetHeight = max(1, (int) round($height * $scale));
|
||||
|
||||
try {
|
||||
return $this->write($path, $format, $targetWidth, $targetHeight);
|
||||
} catch (\Throwable $e) {
|
||||
// 축소 실패가 업로드 자체를 막지는 않는다 — 원본이 그대로 저장된다
|
||||
Log::warning('업로드 이미지 축소 실패 (원본을 그대로 저장합니다)', [
|
||||
'path' => $path,
|
||||
'mime_type' => $mimeType,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정된 픽셀 한계값을 반환합니다.
|
||||
*
|
||||
* @param string $key 설정 키 (image_max_width / image_max_height)
|
||||
* @return int|null 한계값 (미설정이면 null)
|
||||
*/
|
||||
private function limit(string $key): ?int
|
||||
{
|
||||
$value = config("attachment.{$key}");
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = (int) $value;
|
||||
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 축소한 이미지를 원본 경로에 다시 씁니다.
|
||||
*
|
||||
* @param string $path 대상 경로
|
||||
* @param string $format GD 포맷 (jpeg/png/gif/webp)
|
||||
* @param int $targetWidth 결과 가로
|
||||
* @param int $targetHeight 결과 세로
|
||||
* @return bool 성공 여부
|
||||
*/
|
||||
private function write(string $path, string $format, int $targetWidth, int $targetHeight): bool
|
||||
{
|
||||
$source = match ($format) {
|
||||
'jpeg' => @imagecreatefromjpeg($path),
|
||||
'png' => @imagecreatefrompng($path),
|
||||
'gif' => @imagecreatefromgif($path),
|
||||
'webp' => @imagecreatefromwebp($path),
|
||||
};
|
||||
|
||||
if (! $source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$target = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
|
||||
// PNG·GIF·WebP 의 투명도를 보존한다 (미처리 시 검은 배경으로 채워진다)
|
||||
if (in_array($format, ['png', 'gif', 'webp'], true)) {
|
||||
imagealphablending($target, false);
|
||||
imagesavealpha($target, true);
|
||||
imagefill($target, 0, 0, imagecolorallocatealpha($target, 0, 0, 0, 127));
|
||||
}
|
||||
|
||||
imagecopyresampled(
|
||||
$target, $source,
|
||||
0, 0, 0, 0,
|
||||
$targetWidth, $targetHeight,
|
||||
imagesx($source), imagesy($source)
|
||||
);
|
||||
|
||||
$quality = $this->quality();
|
||||
|
||||
$written = match ($format) {
|
||||
'jpeg' => imagejpeg($target, $path, $quality),
|
||||
// PNG 압축 레벨은 0~9 이고 값이 클수록 더 압축된다 — 품질(1~100)과 방향이 반대다
|
||||
'png' => imagepng($target, $path, (int) round((100 - $quality) / 100 * 9)),
|
||||
'gif' => imagegif($target, $path),
|
||||
'webp' => imagewebp($target, $path, $quality),
|
||||
};
|
||||
|
||||
imagedestroy($source);
|
||||
imagedestroy($target);
|
||||
|
||||
return (bool) $written;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정된 이미지 품질을 반환합니다.
|
||||
*
|
||||
* @return int 1~100 범위의 품질
|
||||
*/
|
||||
private function quality(): int
|
||||
{
|
||||
$quality = (int) config('attachment.image_quality', self::DEFAULT_QUALITY);
|
||||
|
||||
return max(1, min(100, $quality > 0 ? $quality : self::DEFAULT_QUALITY));
|
||||
}
|
||||
}
|
||||
+42
-1
@@ -32,8 +32,49 @@ return [
|
||||
| 최대 파일 크기
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 업로드 가능한 최대 파일 크기 (KB 단위)
|
||||
| 업로드 가능한 최대 파일 크기 (KB 단위 — Laravel `max:` 규칙과 동일 단위).
|
||||
|
|
||||
| 단위 규약: config/attachment.* 는 KB, 관리자 환경설정 upload.* 는 MB 다.
|
||||
| MB → KB 변환은 SettingsServiceProvider::applyUploadConfig() 단 한 곳에서만 수행하며,
|
||||
| 소비처(FormRequest 등)는 이 값을 변환 없이 그대로 사용한다.
|
||||
|
|
||||
*/
|
||||
'max_file_size' => env('ATTACHMENT_MAX_FILE_SIZE', 10240),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 허용 확장자
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 업로드를 허용할 파일 확장자 목록 (소문자, 점 없이).
|
||||
| 관리자 환경설정의 `upload.allowed_extensions` 가 저장되면 그 값으로 대체된다.
|
||||
|
|
||||
| 빈 배열은 "확장자 제한 없음" 을 의미한다 — 운영 중 예상치 못한 확장자로 업로드가
|
||||
| 막히는 상황의 탈출구다. 확장은 `core.attachment.allowed_extensions` 필터 훅으로
|
||||
| 목록을 조정할 수 있다.
|
||||
|
|
||||
*/
|
||||
'allowed_extensions' => [
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'zip',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 이미지 축소
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 업로드된 이미지가 이 크기(픽셀)를 넘으면 비율을 유지한 채 축소한다.
|
||||
| 관리자 환경설정의 `upload.image_max_width` / `image_max_height` / `image_quality`
|
||||
| 가 저장되면 그 값으로 대체된다.
|
||||
|
|
||||
| null = 축소하지 않음. 기본값을 null 로 두는 이유는, 이 기능이 도입됐다는 이유만으로
|
||||
| 기존 사이트의 업로드 이미지가 갑자기 줄어들지 않게 하기 위함이다.
|
||||
|
|
||||
| image_quality 는 1~100 이며 JPEG/WebP 에 그대로, PNG 에는 압축 레벨로 환산해 쓴다.
|
||||
|
|
||||
*/
|
||||
'image_max_width' => env('ATTACHMENT_IMAGE_MAX_WIDTH'),
|
||||
'image_max_height' => env('ATTACHMENT_IMAGE_MAX_HEIGHT'),
|
||||
'image_quality' => env('ATTACHMENT_IMAGE_QUALITY', 85),
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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` 필터로 목록을 조정할 수 있습니다 (허용 목록이 비어 있으면 확장자를 제한하지 않습니다).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -1432,7 +1432,65 @@ Content-Type: application/json
|
||||
|
||||
**설명**
|
||||
|
||||
일반 사용자 로그인(공개). 관리자 로그인과 달리 `isAdmin()` 검사가 없다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user` 를 반환하며 `message: auth.login_success`. 계정 잠금 시 `423 auth.account_locked` 를 잠금 해제까지 남은 정보와 함께 반환한다. 프론트 로그인 폼(`partials/auth/_register_form.json` 인접)에서 소비한다.
|
||||
일반 사용자 로그인(공개). 관리자 로그인과 달리 `isAdmin()` 검사가 없다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user` 를 반환하며 `message: auth.login_success`. 계정 잠금 시 `423 auth.account_locked` 를 잠금 해제까지 남은 정보(`errors.locked_until`, `errors.retry_after_seconds`)와 함께 반환한다. 보안 환경설정의 잠금 시간이 `0`(무한대)이면 무기한 잠금이 되어 `423 auth.account_locked_permanently` 와 함께 `errors.permanent=true`, `errors.locked_until=null`, `errors.retry_after_seconds=null` 을 반환하며 `Retry-After` 헤더도 붙지 않는다. 이 경우 해제 수단은 관리자 해제 API(`POST /api/admin/users/{user}/unlock`) 뿐이다. 프론트 로그인 폼(`partials/auth/_register_form.json` 인접)에서 소비한다.
|
||||
|
||||
**2단계 인증이 켜져 있는 경우**: 보안 환경설정 `security.two_factor_auth` 가 켜져 있으면 비밀번호가 맞아도 **토큰을 발급하지 않는다**. 대신 `200` 과 함께 `message: auth.two_factor_required` 및 아래 필드를 반환하며, 클라이언트는 `POST /api/auth/login/two-factor` 로 코드를 확인해야 로그인이 완료된다.
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| `two_factor_required` | boolean | 항상 `true` — 이 응답이 추가 확인 단계임을 나타낸다 |
|
||||
| `challenge_id` | string(uuid) | 확인 단계에 그대로 전달할 challenge 식별자 |
|
||||
| `provider_id` | string | 코드를 발송한 본인인증 프로바이더 |
|
||||
| `expires_at` | string(ISO8601)\|null | challenge 만료 시각 |
|
||||
|
||||
이 응답에는 `data.token` 과 `data.user` 가 없다. 토큰 존재 여부로 로그인 완료를 판정하는 클라이언트는 그대로 동작한다.
|
||||
|
||||
|
||||
### POST /api/auth/login/two-factor
|
||||
|
||||
- **라우트명**: `api.auth.login.two-factor`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@verifyTwoFactor`
|
||||
- **인증/권한**: 공개 (인증 불필요 — 주체는 challenge 가 식별)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| challenge_id | body | uuid | 예 | — | 로그인 응답이 돌려준 challenge 식별자 |
|
||||
| code | body | string | 예 | 4~16자 | 사용자가 받은 인증 코드 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/auth/login/two-factor HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"challenge_id": "0f8c2b6e-1a2b-4c3d-9e8f-7a6b5c4d3e2f",
|
||||
"code": "135790"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
로그인 성공과 동일하다 — `token`(Sanctum Bearer) · `token_type` · `user`.
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthorized | 코드 불일치·만료·시도 횟수 초과, 또는 challenge 가 로그인 용도(`login`)가 아니거나 대상 사용자가 활성 상태가 아닌 경우 (`auth.two_factor_failed`) |
|
||||
| 422 | Unprocessable Entity | `challenge_id`/`code` 형식 위반 |
|
||||
|
||||
**설명**
|
||||
|
||||
비밀번호 단계가 돌려준 challenge 를 확인해 로그인을 완료한다. 로그인과 동일한 요청 제한(`throttle:auth-login`)이 걸려 코드 대입 시도도 함께 억제된다.
|
||||
|
||||
challenge 의 `purpose` 가 `login` 인지 먼저 대조한다 — 대조하지 않으면 회원가입·비밀번호 재설정 등 다른 흐름에서 발급된 challenge 로 로그인할 수 있다. 코드 확인에 성공하기 전에는 어떤 경우에도 토큰이 발급되지 않는다.
|
||||
|
||||
|
||||
### POST /api/auth/logout
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| provider_id | query | string | 아니오 | max 64 | provider 식별자 |
|
||||
| purpose | query | string | 아니오 | max 64 | 인증 목적 필터 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) |
|
||||
| purpose | query | string | 아니오 | max 64 | 인증 목적 필터 (signup/password_reset/self_update/sensitive_action/login 또는 모듈 정의 목적) |
|
||||
| status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) |
|
||||
| channel | query | string | 아니오 | max 16 | 전송 채널 필터 (email 등 — 해당 채널로 시도한 이력만) |
|
||||
| origin_type | query | string | 아니오 | — | 인증 트리거 출처 유형 필터 (route/hook/policy/middleware/api/custom/system — IdentityOriginType) |
|
||||
@@ -66,7 +66,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
|
||||
| --- | --- | --- | --- |
|
||||
| id | string | `e6ab6cd6-cdff-46cd-b4c2-b89dfda8745b` | 기본 키 (내부 식별자) |
|
||||
| provider_id | string | `inicis` | provider 식별자 (연관 리소스 참조) |
|
||||
| purpose | string | `sensitive_action` | 인증 목적 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) |
|
||||
| purpose | string | `sensitive_action` | 인증 목적 (signup/password_reset/self_update/sensitive_action/login 또는 모듈 정의 목적) |
|
||||
| channel | string | `ipin` | 인증에 사용된 전송 채널 (email 등 코어 채널 또는 모듈 provider 자체 식별자) |
|
||||
| user_id | integer | `130` | user 식별자 (연관 리소스 참조) |
|
||||
| target_hash | string | `d88d36166bbeffc41eb6994e390f4715280c1…` | 인증 대상 해시 (SHA256(email\|phone) — PII 원본 저장 회피) |
|
||||
@@ -1806,7 +1806,7 @@ Content-Type: application/json
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| purpose | body | string | 예 | max 64 | 인증 목적 (signup/password_reset/self_update/sensitive_action 또는 모듈 정의 목적) |
|
||||
| purpose | body | string | 예 | max 64 | 인증 목적 (signup/password_reset/self_update/sensitive_action/login 또는 모듈 정의 목적) |
|
||||
| target | body | array | 아니오 | — | 비로그인 게스트의 인증 대상 (target.email 또는 target.phone — 로그인 사용자는 본인으로 자동 설정) |
|
||||
| provider_id | body | string | 아니오 | max 64 | provider 식별자 |
|
||||
|
||||
@@ -1880,7 +1880,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| id | string | `00484973-8cd3-4a1d-85f2-78361feb6f0d` | 기본 키 (내부 식별자) |
|
||||
| status | string | `verified` | requested\|sent\|processing\|verified\|failed\|expired\|cancelled\|policy_violation_logged |
|
||||
| provider_id | string | `inicis` | 프로바이더 식별자 (예: g7:core.mail, kcp) |
|
||||
| purpose | string | `sensitive_action` | 인증 목적 (signup\|password_reset\|self_update\|sensitive_action\|*module-defined*) — 코어 4종은 App\Enums\IdentityVerificationPurpose enum, 모듈/플러그인은 declaredPurposes 레지스트리 |
|
||||
| purpose | string | `sensitive_action` | 인증 목적 (signup\|password_reset\|self_update\|sensitive_action\|login\|*module-defined*) — 코어 5종은 App\Enums\IdentityVerificationPurpose enum, 모듈/플러그인은 declaredPurposes 레지스트리 |
|
||||
| render_hint | string | `text_code` | 프론트 렌더 힌트 (text_code\|link\|external_redirect) |
|
||||
| expires_at | string | `2026-05-12T18:14:19+00:00` | expires 일시 |
|
||||
| attempts | integer | `3` | 시도 횟수 |
|
||||
@@ -2132,7 +2132,7 @@ Accept: application/json
|
||||
| description | string | `신규 가입자의 이메일/전화번호 소유 확인.` | 설명 (다국어 필드는 로케일별 값 객체) |
|
||||
| default_provider | string | `g7:core.mail` | 이 목적에 매핑된 기본 프로바이더 ID (요청 시 provider_id 미지정이면 이 값 사용, 미설정 시 null) |
|
||||
| allowed_channels | array | `["mail","sms"]` | 이 목적에서 사용 가능한 전송 채널 목록 |
|
||||
| source_type | string | `core` | 목적 출처 (core: 코어 기본 4종 / module / plugin — 어느 확장이 선언했는지) |
|
||||
| source_type | string | `core` | 목적 출처 (core: 코어 기본 5종 / module / plugin — 어느 확장이 선언했는지) |
|
||||
| source_identifier | string | `core` | 출처 식별자 (목적을 선언한 확장의 identifier) |
|
||||
|
||||
**응답 예시**
|
||||
@@ -2194,6 +2194,18 @@ HTTP/1.1 200
|
||||
"source_type": "core",
|
||||
"source_identifier": "core"
|
||||
},
|
||||
{
|
||||
"id": "login",
|
||||
"label": "로그인 2단계 인증",
|
||||
"description": "2단계 인증을 켰을 때 비밀번호 확인 뒤 한 단계를 더 요구.",
|
||||
"default_provider": "g7:core.mail",
|
||||
"allowed_channels": [
|
||||
"mail",
|
||||
"sms"
|
||||
],
|
||||
"source_type": "core",
|
||||
"source_identifier": "core"
|
||||
},
|
||||
{
|
||||
"id": "checkout_verification",
|
||||
"label": "결제 시 본인 확인",
|
||||
@@ -2217,6 +2229,6 @@ _대표 에러 없음 (공개 조회). 인증·권한 미요구 엔드포인트
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 등록된 인증 목적(purpose) 목록을 반환합니다. 공개 엔드포인트로 인증이 필요하지 않습니다. `IdentityVerificationManager::getAllPurposes()` 로 코어 기본 4종(signup/password_reset/self_update/sensitive_action) + 활성 모듈/플러그인의 `getIdentityPurposes()` 선언 + `core.identity.purposes` 필터 훅(서드파티 동적 확장) 을 병합하며, 각 항목의 label/description 은 i18n 키·다국어 배열·평문 세 형태를 현재 로케일 문자열로 정규화해 내려줍니다. 각 목적의 기본 프로바이더·허용 채널·출처(source_type/source_identifier) 를 함께 반환하므로, 인증 UI 가 목적별 선택지와 문구를 구성할 때 사용합니다.
|
||||
**설명** 등록된 인증 목적(purpose) 목록을 반환합니다. 공개 엔드포인트로 인증이 필요하지 않습니다. `IdentityVerificationManager::getAllPurposes()` 로 코어 기본 5종(signup/password_reset/self_update/sensitive_action/login) + 활성 모듈/플러그인의 `getIdentityPurposes()` 선언 + `core.identity.purposes` 필터 훅(서드파티 동적 확장) 을 병합하며, 각 항목의 label/description 은 i18n 키·다국어 배열·평문 세 형태를 현재 로케일 문자열로 정규화해 내려줍니다. 각 목적의 기본 프로바이더·허용 채널·출처(source_type/source_identifier) 를 함께 반환하므로, 인증 UI 가 목적별 선택지와 문구를 구성할 때 사용합니다.
|
||||
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -112,7 +112,7 @@ IdentityMessageResolver::resolve(
|
||||
|
||||
| 변수 | 설명 | 적용 |
|
||||
|------|------|------|
|
||||
| `{code}` | 인증 코드 (text_code 흐름) | signup / self_update / sensitive_action |
|
||||
| `{code}` | 인증 코드 (text_code 흐름) | signup / self_update / sensitive_action / login |
|
||||
| `{action_url}` | 검증 링크 URL (link 흐름) | password_reset |
|
||||
| `{expire_minutes}` | 만료까지 남은 분 | 모든 정의 |
|
||||
| `{purpose_label}` | 인증 목적 라벨 (다국어 해석) | 모든 정의 |
|
||||
@@ -136,6 +136,8 @@ IdentityMessageResolver::resolve(
|
||||
|
||||
**시드 위치**: `database/seeders/IdentityMessageDefinitionSeeder.php`
|
||||
|
||||
로그인 2단계 인증(`purpose=login`)은 전용 시드가 없어 `provider_default` 템플릿으로 발송됩니다. 전용 문구가 필요하면 관리자 화면에서 `scope_type=purpose` / `scope_value=login` 정의를 추가합니다 — 목적 목록에 `login` 이 등록되어 있으므로 선택할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 클래스 계층
|
||||
|
||||
@@ -22,7 +22,7 @@ applies_to 는 permission 기반 admin 판정 (User::isAdmin() — type='admin'
|
||||
| `key` | 고유 식별자 (시드 기준) | `core.auth.signup_before_submit` |
|
||||
| `scope` | 매칭 단위 | `route` (라우트 미들웨어) / `hook` (action 훅) |
|
||||
| `target` | scope 별 대상 식별자 | route name 또는 hook name |
|
||||
| `purpose` | IDV 목적 | `signup` / `password_reset` / `self_update` / `sensitive_action` 등 |
|
||||
| `purpose` | IDV 목적 | `signup` / `password_reset` / `self_update` / `sensitive_action` / `login` 등 |
|
||||
| `provider_id` | 강제할 IDV provider | `null` 이면 매니저 default |
|
||||
| `applies_to` | 강제 대상 사용자군 | `self` (관리자 제외) / `admin` (관리자만) / `both` |
|
||||
| `enabled` | 활성 여부 | bool |
|
||||
|
||||
@@ -100,7 +100,7 @@ final class SmsIdentityProvider implements VerificationProviderInterface
|
||||
| --- | --- | --- |
|
||||
| `id` | string | challenge UUID |
|
||||
| `providerId` | string | provider 식별자 |
|
||||
| `purpose` | string | signup / password_reset / self_update / sensitive_action / 확장 정의 |
|
||||
| `purpose` | string | signup / password_reset / self_update / sensitive_action / login / 확장 정의 |
|
||||
| `channel` | string | email / sms / ipin / 확장 채널 |
|
||||
| `targetHash` | string | SHA256(식별자) |
|
||||
| `expiresAt` | CarbonInterface | 만료 시각 |
|
||||
@@ -210,7 +210,7 @@ public static function providerMatrix(): array
|
||||
| `target_hash` 를 raw 식별자로 저장 | 반드시 SHA256(lower(식별자)) 로 PII 보호 |
|
||||
| `verify()` 성공 시 `consumed_at = now()` 자동 set | 다운스트림 listener (가입/비번리셋 등) 가 사용 시점에 set |
|
||||
| provider 가 listener 책임을 가져가서 직접 사용자 생성 | provider 는 verify 만 담당. 사용자 생성/세션은 도메인 service |
|
||||
| `purpose` 를 정책 purpose 와 다르게 임의 부여 | 정책-provider purpose 일치 (`signup`, `password_reset`, `sensitive_action`, `self_update`) |
|
||||
| `purpose` 를 정책 purpose 와 다르게 임의 부여 | 정책-provider purpose 일치 (`signup`, `password_reset`, `sensitive_action`, `self_update`, `login`) |
|
||||
|
||||
## 8. 관련 코드 진입점
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ public function getIdentityPolicies(): array
|
||||
|
||||
### 신규 purpose 등록
|
||||
|
||||
코어가 제공하는 4 종(`signup` / `password_reset` / `self_update` / `sensitive_action`) 외에 도메인 특화 purpose 를 도입하려면 `getIdentityPurposes()` 를 오버라이드합니다.
|
||||
코어가 제공하는 5 종(`signup` / `password_reset` / `self_update` / `sensitive_action` / `login`) 외에 도메인 특화 purpose 를 도입하려면 `getIdentityPurposes()` 를 오버라이드합니다.
|
||||
|
||||
```php
|
||||
public function getIdentityPurposes(): array
|
||||
|
||||
@@ -8,10 +8,14 @@
|
||||
|
||||
### Added
|
||||
|
||||
- 계정이 무기한 잠겼을 때의 로그인 안내와 관리자 잠금 해제 안내, 그리고 해당 활동 기록 문구의 일본어 번역 추가.
|
||||
- 허용되지 않는 파일 형식을 업로드했을 때의 안내 문구 일본어 번역 추가.
|
||||
- 확장 식별자·메뉴 역할·활동 로그 검색 조건의 안내 문구 일본어 번역 추가 — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 모듈·플러그인 파일 업로드 시 허용되지 않는 파일 형식 안내 문구 일본어 번역 추가.
|
||||
- 플러그인을 GitHub 주소 또는 ZIP 파일로 설치할 때의 입력 검증 안내 문구 일본어 번역 추가 — 주소 형식이 잘못됐거나 ZIP 이 아닌 파일을 올렸을 때, 파일 용량을 초과했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 회원가입 검증 메시지의 휴대폰·전화번호 항목(`validation.auth.mobile.*`, `validation.auth.phone.*`) 일본어 번역 추가 — 회원가입 시 연락처 형식 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 환경설정 저장 시 오류 안내에 나오는 항목 이름의 일본어 번역 추가 — 보안·업로드·SEO·고급·드라이버 등 각 설정 항목이 내부 식별자 대신 일본어 항목명으로 표시됩니다.
|
||||
- 로그인 2단계 인증 안내(인증번호 발송·불일치·요청 만료)와 인증번호 입력 검증 문구의 일본어 번역 추가.
|
||||
|
||||
## [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つ以上のファイルを選択してください。',
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
return [
|
||||
'login_success' => 'ログインが成功しました。',
|
||||
'login_failed' => '認証に失敗しました。',
|
||||
'two_factor_required' => '認証番号を送信しました。受け取った番号を入力してログインを完了してください。',
|
||||
'two_factor_failed' => '認証番号が正しくないか、有効期限が過ぎています。',
|
||||
'two_factor_invalid_challenge' => '認証リクエストが見つかりません。最初からログインし直してください。',
|
||||
'register_success' => '会員登録が成功しました。',
|
||||
'register_failed' => '会員登録に失敗しました。',
|
||||
'logout_success' => 'ログアウトが成功しました。',
|
||||
@@ -32,8 +35,11 @@ return [
|
||||
'account_withdrawn' => '退会したアカウントです。',
|
||||
'account_pending_verification' => '本人認証が完了していないアカウントです。メール認証を完了してください。',
|
||||
'account_locked' => 'ログイン試行回数の超過によりアカウントがロックされました。:minutes分後に再度お試しください。',
|
||||
'account_locked_permanently' => 'ログイン試行回数の超過によりアカウントがロックされました。管理者にお問い合わせください。',
|
||||
'account_unlocked' => 'アカウントのロックを解除しました。',
|
||||
'reset_token_invalid' => '無効なパスワードリセットトークンです。',
|
||||
'reset_token_expired' => 'パスワードリセットトークンが有効期限切れです。再度リクエストしてください。',
|
||||
'password_reset_email_sent' => 'パスワードリセットメールが送信されました。',
|
||||
'email_send_failed' => 'メール送信に失敗しました。しばらく後に再度お試しください。',
|
||||
'two_factor_delivery_failed' => '認証番号を送信できませんでした。管理者にお問い合わせください。',
|
||||
];
|
||||
|
||||
@@ -61,6 +61,10 @@ return [
|
||||
'label' => '機密操作',
|
||||
'description' => 'アカウント削除·管理者操作など再認証が必要な時点。',
|
||||
],
|
||||
'login' => [
|
||||
'label' => 'ログイン2段階認証',
|
||||
'description' => '2段階認証を有効にした場合、パスワード確認後にもう一段階を要求。',
|
||||
],
|
||||
],
|
||||
'channels' => [
|
||||
'email' => 'メール',
|
||||
|
||||
@@ -692,6 +692,11 @@ return [
|
||||
'outside_base_directory' => '基準ディレクトリ外へのアクセスは許可されません。',
|
||||
],
|
||||
'auth' => [
|
||||
'two_factor' => [
|
||||
'challenge_required' => '認証リクエスト情報がありません。最初からログインし直してください。',
|
||||
'challenge_invalid' => '認証リクエスト情報が正しくありません。',
|
||||
'code_required' => '認証番号を入力してください。',
|
||||
],
|
||||
'email' => [
|
||||
'required' => 'メールアドレスは必須です。',
|
||||
'email' => '正しいメールアドレス形式ではありません。',
|
||||
@@ -1074,5 +1079,66 @@ return [
|
||||
'websocket_host' => 'WebSocket ホスト',
|
||||
'websocket_port' => 'WebSocket ポート',
|
||||
'websocket_scheme' => 'WebSocket プロトコル',
|
||||
'mailgun_endpoint' => 'Mailgun エンドポイント',
|
||||
'channels' => '通知チャンネル',
|
||||
'currency' => 'デフォルト通貨',
|
||||
'maintenance_mode' => 'メンテナンスモード',
|
||||
'asset_url_mode' => 'アセットURL方式',
|
||||
'site_logo' => 'サイトロゴ',
|
||||
'bot_user_agents' => 'ボット User-Agent 一覧',
|
||||
'bot_detection_enabled' => 'ボット検出の使用',
|
||||
'bot_detection_library_enabled' => 'ボット検出ライブラリの使用',
|
||||
'og_default_site_name' => 'OG デフォルトサイト名',
|
||||
'og_image_default_width' => 'OG 画像デフォルト幅',
|
||||
'og_image_default_height' => 'OG 画像デフォルト高さ',
|
||||
'twitter_default_card' => 'Twitter デフォルトカード種類',
|
||||
'twitter_default_site' => 'Twitter デフォルトアカウント',
|
||||
'seo_page_cache_enabled' => 'SEO ページキャッシュの使用',
|
||||
'cache_ttl' => 'SEO ページキャッシュ保持時間',
|
||||
'sitemap_enabled' => 'サイトマップの使用',
|
||||
'sitemap_cache_ttl' => 'サイトマップキャッシュ保持時間',
|
||||
'sitemap_urls_per_file' => 'サイトマップ1ファイルあたりのURL数',
|
||||
'sitemap_gzip' => 'サイトマップ圧縮',
|
||||
'sitemap_serve_stale_on_miss' => '未生成時に既存サイトマップを提供',
|
||||
'sitemap_max_urls_per_contributor' => '項目別サイトマップ最大URL数',
|
||||
'sitemap_hreflang_enabled' => 'サイトマップ代替言語リンクの使用',
|
||||
'sitemap_schedule' => 'サイトマップ自動生成の周期',
|
||||
'sitemap_schedule_time' => 'サイトマップ自動生成の時刻',
|
||||
'generator_enabled' => 'SEO ページジェネレーターの使用',
|
||||
'generator_content' => 'SEO ページジェネレーターのコンテンツ',
|
||||
'force_https' => 'HTTPS 強制適用',
|
||||
'login_attempt_enabled' => 'ログイン試行制限',
|
||||
'auth_token_lifetime' => '認証トークン有効期限',
|
||||
'max_login_attempts' => '最大ログイン試行回数',
|
||||
'login_lockout_time' => 'ログインブロック時間',
|
||||
'password_min_length' => 'パスワード最小長',
|
||||
'require_password_special_char' => 'パスワード特殊文字必須',
|
||||
'two_factor_auth' => '2段階認証',
|
||||
'allow_internal_outbound_urls' => '内部ネットワークアドレス呼び出しの許可',
|
||||
'advanced_cache_enabled' => 'キャッシュの使用',
|
||||
'layout_cache_enabled' => 'レイアウトキャッシュの使用',
|
||||
'layout_cache_ttl' => 'レイアウトキャッシュ保持時間',
|
||||
'stats_cache_enabled' => '統計キャッシュの使用',
|
||||
'stats_cache_ttl' => '統計キャッシュ保持時間',
|
||||
'seo_cache_enabled' => 'SEO キャッシュの使用',
|
||||
'seo_cache_ttl' => 'SEO キャッシュ保持時間',
|
||||
'seo_sitemap_cache_ttl' => 'SEO サイトマップキャッシュ保持時間',
|
||||
'debug_mode' => 'デバッグモード',
|
||||
'sql_query_log' => 'SQL クエリログ',
|
||||
'core_update_github_url' => 'コア更新 GitHub アドレス',
|
||||
'core_update_github_token' => 'コア更新 GitHub トークン',
|
||||
'geoip_enabled' => 'GeoIP の使用',
|
||||
'geoip_license_key' => 'GeoIP ライセンスキー',
|
||||
'geoip_auto_update_enabled' => 'GeoIP 自動更新',
|
||||
'websocket_app_id' => 'WebSocket アプリ ID',
|
||||
'websocket_app_secret' => 'WebSocket アプリ シークレット',
|
||||
'websocket_verify_ssl' => 'WebSocket SSL 証明書の検証',
|
||||
'websocket_server_host' => 'WebSocket サーバー ホスト',
|
||||
'websocket_server_port' => 'WebSocket サーバー ポート',
|
||||
'websocket_server_scheme' => 'WebSocket サーバー プロトコル',
|
||||
'search_engine_driver' => '検索エンジン ドライバー',
|
||||
'log_driver' => 'ログ ドライバー',
|
||||
'log_level' => 'ログレベル',
|
||||
'log_days' => 'ログ保持日数',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- 환경설정 > 보안의 2단계 인증 항목 문구 일본어 번역 추가 (`settings.security.two_factor_auth*`) — 항목 이름과 설명이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 회원 상세의 계정 잠금 상태 표시와 잠금 해제 버튼 문구 일본어 번역 추가 (`users.detail.unlock*`, `users.detail.lock_status`) — 무기한 잠금·기한부 잠금·정상 상태 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 언어팩 관리 목록의 "파일 없음" 배지와 다시 설치 버튼 문구 일본어 번역 추가 (`language_packs.badge.files_missing`, `language_packs.actions.reinstall`).
|
||||
- 일반 설정의 에셋 파일 서빙 방식 항목 문구 일본어 번역 추가 (`settings.general.asset_url_mode*`) — 항목 이름과 설명, 선택지, 자동 감지 결과(확장자를 가로채는 서버 규칙 안내 포함)가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 대시보드의 에셋 파일 서빙 방식 불일치 안내 문구 일본어 번역 추가 (`dashboard.asset_url_mode_drift_*`) — 안내 제목과 설명, 환경설정 이동 문구가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
@@ -256,7 +256,8 @@
|
||||
"last_login_at": "最後のログイン",
|
||||
"ip_address": "IP",
|
||||
"country_select": "国を選択",
|
||||
"country_search": "国を検索..."
|
||||
"country_search": "国を検索...",
|
||||
"lock_status": "アカウントロック状態"
|
||||
},
|
||||
"verification": {
|
||||
"method": "本人確認方法",
|
||||
@@ -338,7 +339,13 @@
|
||||
"profile_memo": "プロフィール/メモ",
|
||||
"activity_info": "アクティビティ情報",
|
||||
"consent_info": "同意履歴"
|
||||
}
|
||||
},
|
||||
"unlock": "ロック解除",
|
||||
"unlock_success": "アカウントのロックを解除しました。",
|
||||
"lock_status": "アカウントロック状態",
|
||||
"locked_permanently_value": "ロック中 (無期限 — 管理者による解除が必要)",
|
||||
"locked_until_value": "ロック中 ({{until}} まで)",
|
||||
"not_locked_value": "正常"
|
||||
},
|
||||
"bulk_status_change": "ステータス変更"
|
||||
},
|
||||
@@ -1479,7 +1486,16 @@
|
||||
"regenerate_key": "キー 再生成",
|
||||
"app_key_warning": "キーを変更すると、既存の暗号化されたデータにアクセスできなくなる可能性があります。",
|
||||
"allow_internal_outbound_urls": "内部ネットワークアドレス呼び出し許可",
|
||||
"allow_internal_outbound_urls_desc": "外部API連携·スケジュールURL呼び出しなどで、プライベートIPやlocalhostなどの内部アドレスを使用できるようにします。社内サーバーを呼び出す必要がある場合のみ有効にしてください。有効にするとサーバーが内部ネットワークにリクエストを送信できるようになるため、デフォルトは無効です。"
|
||||
"allow_internal_outbound_urls_desc": "外部API連携·スケジュールURL呼び出しなどで、プライベートIPやlocalhostなどの内部アドレスを使用できるようにします。社内サーバーを呼び出す必要がある場合のみ有効にしてください。有効にするとサーバーが内部ネットワークにリクエストを送信できるようになるため、デフォルトは無効です。",
|
||||
"password_min_length": "パスワードの最小文字数",
|
||||
"password_min_length_hint": "会員登録・パスワード変更・再設定に適用されます。ログインには適用されません(既存のパスワードでログインできなくなるのを防ぐため)。",
|
||||
"require_password_special_char": "特殊文字必須",
|
||||
"require_password_special_char_label": "パスワードに特殊文字を必ず含める",
|
||||
"require_password_special_char_hint": "有効にすると、新しく設定するパスワードに特殊文字が少なくとも1つ必要です。",
|
||||
"two_factor_auth": "2段階認証",
|
||||
"two_factor_auth_label": "ログイン時に認証番号をもう一度確認",
|
||||
"two_factor_auth_hint": "有効にすると、パスワード確認後に登録メールへ認証番号を送信し、その番号を入力してログインが完了します。",
|
||||
"ip_throttle_floor_hint": "IP単位のリクエスト制限はこの値の6倍で適用され、1分あたり最低30回は常に保証されます。"
|
||||
},
|
||||
"identity": {
|
||||
"sub_tabs": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
### Added
|
||||
|
||||
- 회원가입 화면의 휴대폰번호·전화번호 입력란 라벨·안내 문구(`auth.mobile`, `auth.phone`) 일본어 번역 추가 — 회원가입 시 연락처 입력란이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 모달 닫기 버튼의 안내 라벨(`common.close_modal`) 일본어 번역 추가 — 화면 낭독기 사용자에게 버튼 용도가 일본어로 안내됩니다.
|
||||
|
||||
## [1.0.1] - 2026-07-08
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"prev": "前へ",
|
||||
"more": "もっと見る",
|
||||
"close": "閉じる",
|
||||
"close_modal": "モーダルを閉じる",
|
||||
"submit": "送信",
|
||||
"required": "必須",
|
||||
"optional": "選択",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -4,6 +4,10 @@ return [
|
||||
// Authentication related messages
|
||||
'login_success' => 'Login successful.',
|
||||
'login_failed' => 'Authentication failed.',
|
||||
'two_factor_required' => 'A verification code has been sent. Enter it to finish signing in.',
|
||||
'two_factor_failed' => 'The verification code is incorrect or has expired.',
|
||||
'two_factor_invalid_challenge' => 'Verification request not found. Please sign in again.',
|
||||
'two_factor_delivery_failed' => 'Could not send the verification code. Please contact the administrator.',
|
||||
'register_success' => 'Registration successful.',
|
||||
'register_failed' => 'Registration failed.',
|
||||
'logout_success' => 'Logout successful.',
|
||||
@@ -33,6 +37,8 @@ return [
|
||||
'account_withdrawn' => 'This account has been withdrawn.',
|
||||
'account_pending_verification' => 'Identity verification is not complete. Please complete the email verification.',
|
||||
'account_locked' => 'Too many failed login attempts. Your account is locked for :minutes minute(s).',
|
||||
'account_locked_permanently' => 'Too many failed login attempts. Your account has been locked. Please contact an administrator.',
|
||||
'account_unlocked' => 'The account lock has been released.',
|
||||
|
||||
// Password reset
|
||||
'reset_token_invalid' => 'Invalid password reset token.',
|
||||
|
||||
@@ -65,6 +65,10 @@ return [
|
||||
'label' => 'Sensitive Action',
|
||||
'description' => 'Re-verify before sensitive actions such as account deletion or admin operations.',
|
||||
],
|
||||
'login' => [
|
||||
'label' => 'Two-Factor Login',
|
||||
'description' => 'Require one more step after the password when two-factor authentication is enabled.',
|
||||
],
|
||||
],
|
||||
|
||||
'channels' => [
|
||||
|
||||
@@ -762,6 +762,11 @@ return [
|
||||
|
||||
// Auth validation messages
|
||||
'auth' => [
|
||||
'two_factor' => [
|
||||
'challenge_required' => 'Verification request is missing. Please sign in again.',
|
||||
'challenge_invalid' => 'Verification request is not valid.',
|
||||
'code_required' => 'Enter the verification code.',
|
||||
],
|
||||
'email' => [
|
||||
'required' => 'Email is required.',
|
||||
'email' => 'Please enter a valid email address.',
|
||||
@@ -1200,5 +1205,71 @@ return [
|
||||
'ses_key' => 'SES access key',
|
||||
'ses_secret' => 'SES secret key',
|
||||
'ses_region' => 'SES region',
|
||||
'mailgun_endpoint' => 'Mailgun endpoint',
|
||||
// General settings (additional)
|
||||
'channels' => 'notification channels',
|
||||
'currency' => 'default currency',
|
||||
'maintenance_mode' => 'maintenance mode',
|
||||
'asset_url_mode' => 'asset URL mode',
|
||||
'site_logo' => 'site logo',
|
||||
// SEO settings (additional)
|
||||
'bot_user_agents' => 'bot user agents',
|
||||
'bot_detection_enabled' => 'bot detection',
|
||||
'bot_detection_library_enabled' => 'bot detection library',
|
||||
'og_default_site_name' => 'OG default site name',
|
||||
'og_image_default_width' => 'OG image default width',
|
||||
'og_image_default_height' => 'OG image default height',
|
||||
'twitter_default_card' => 'Twitter default card type',
|
||||
'twitter_default_site' => 'Twitter default account',
|
||||
'seo_page_cache_enabled' => 'SEO page cache',
|
||||
'cache_ttl' => 'SEO page cache lifetime',
|
||||
'sitemap_enabled' => 'sitemap',
|
||||
'sitemap_cache_ttl' => 'sitemap cache lifetime',
|
||||
'sitemap_urls_per_file' => 'sitemap URLs per file',
|
||||
'sitemap_gzip' => 'sitemap compression',
|
||||
'sitemap_serve_stale_on_miss' => 'serve stale sitemap on miss',
|
||||
'sitemap_max_urls_per_contributor' => 'sitemap max URLs per contributor',
|
||||
'sitemap_hreflang_enabled' => 'sitemap hreflang links',
|
||||
'sitemap_schedule' => 'sitemap schedule',
|
||||
'sitemap_schedule_time' => 'sitemap schedule time',
|
||||
'generator_enabled' => 'SEO page generator',
|
||||
'generator_content' => 'SEO page generator content',
|
||||
// Security settings (additional)
|
||||
'force_https' => 'force HTTPS',
|
||||
'login_attempt_enabled' => 'login attempt limit',
|
||||
'auth_token_lifetime' => 'auth token lifetime',
|
||||
'max_login_attempts' => 'max login attempts',
|
||||
'login_lockout_time' => 'login lockout time',
|
||||
'password_min_length' => 'minimum password length',
|
||||
'require_password_special_char' => 'require special character in password',
|
||||
'two_factor_auth' => 'two-factor authentication',
|
||||
'allow_internal_outbound_urls' => 'allow internal outbound URLs',
|
||||
// Advanced settings (additional)
|
||||
'advanced_cache_enabled' => 'cache',
|
||||
'layout_cache_enabled' => 'layout cache',
|
||||
'layout_cache_ttl' => 'layout cache lifetime',
|
||||
'stats_cache_enabled' => 'statistics cache',
|
||||
'stats_cache_ttl' => 'statistics cache lifetime',
|
||||
'seo_cache_enabled' => 'SEO cache',
|
||||
'seo_cache_ttl' => 'SEO cache lifetime',
|
||||
'seo_sitemap_cache_ttl' => 'SEO sitemap cache lifetime',
|
||||
'debug_mode' => 'debug mode',
|
||||
'sql_query_log' => 'SQL query log',
|
||||
'core_update_github_url' => 'core update GitHub URL',
|
||||
'core_update_github_token' => 'core update GitHub token',
|
||||
'geoip_enabled' => 'GeoIP',
|
||||
'geoip_license_key' => 'GeoIP license key',
|
||||
'geoip_auto_update_enabled' => 'GeoIP auto update',
|
||||
// Driver settings (additional)
|
||||
'websocket_app_id' => 'WebSocket app ID',
|
||||
'websocket_app_secret' => 'WebSocket app secret',
|
||||
'websocket_verify_ssl' => 'WebSocket SSL verification',
|
||||
'websocket_server_host' => 'WebSocket server host',
|
||||
'websocket_server_port' => 'WebSocket server port',
|
||||
'websocket_server_scheme' => 'WebSocket server scheme',
|
||||
'search_engine_driver' => 'search engine driver',
|
||||
'log_driver' => 'log driver',
|
||||
'log_level' => 'log level',
|
||||
'log_days' => 'log retention days',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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' => '스케줄 목록 조회',
|
||||
|
||||
@@ -27,6 +27,7 @@ return [
|
||||
'file_required' => '파일을 선택해주세요.',
|
||||
'file_invalid' => '유효한 파일이 아닙니다.',
|
||||
'file_max' => '파일 크기는 :maxMB를 초과할 수 없습니다.',
|
||||
'file_extension_invalid' => '허용되지 않는 파일 형식입니다. 허용 형식: :extensions',
|
||||
'files_required' => '파일을 선택해주세요.',
|
||||
'files_array' => '파일 형식이 올바르지 않습니다.',
|
||||
'files_min' => '최소 1개 이상의 파일을 선택해주세요.',
|
||||
|
||||
@@ -4,6 +4,10 @@ return [
|
||||
// 인증 관련 메시지
|
||||
'login_success' => '로그인이 성공했습니다.',
|
||||
'login_failed' => '인증에 실패했습니다.',
|
||||
'two_factor_required' => '인증번호를 보냈습니다. 받은 번호를 입력해 로그인을 완료해주세요.',
|
||||
'two_factor_failed' => '인증번호가 올바르지 않거나 유효시간이 지났습니다.',
|
||||
'two_factor_invalid_challenge' => '인증 요청을 찾을 수 없습니다. 처음부터 다시 로그인해주세요.',
|
||||
'two_factor_delivery_failed' => '인증번호를 보내지 못했습니다. 관리자에게 문의해주세요.',
|
||||
'register_success' => '회원가입이 성공했습니다.',
|
||||
'register_failed' => '회원가입에 실패했습니다.',
|
||||
'logout_success' => '로그아웃이 성공했습니다.',
|
||||
@@ -33,6 +37,8 @@ return [
|
||||
'account_withdrawn' => '탈퇴한 계정입니다.',
|
||||
'account_pending_verification' => '본인인증이 완료되지 않은 계정입니다. 이메일 인증을 완료해주세요.',
|
||||
'account_locked' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. :minutes분 후 다시 시도해주세요.',
|
||||
'account_locked_permanently' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의해주세요.',
|
||||
'account_unlocked' => '계정 잠금이 해제되었습니다.',
|
||||
|
||||
// 비밀번호 재설정
|
||||
'reset_token_invalid' => '유효하지 않은 비밀번호 재설정 토큰입니다.',
|
||||
|
||||
@@ -65,6 +65,10 @@ return [
|
||||
'label' => '민감 작업',
|
||||
'description' => '계정 탈퇴·관리자 작업 등 재인증이 필요한 시점.',
|
||||
],
|
||||
'login' => [
|
||||
'label' => '로그인 2단계 인증',
|
||||
'description' => '2단계 인증을 켰을 때 비밀번호 확인 뒤 한 단계를 더 요구.',
|
||||
],
|
||||
],
|
||||
|
||||
'channels' => [
|
||||
|
||||
@@ -763,6 +763,11 @@ return [
|
||||
|
||||
// 인증 관련 검증 메시지
|
||||
'auth' => [
|
||||
'two_factor' => [
|
||||
'challenge_required' => '인증 요청 정보가 없습니다. 처음부터 다시 로그인해주세요.',
|
||||
'challenge_invalid' => '인증 요청 정보가 올바르지 않습니다.',
|
||||
'code_required' => '인증번호를 입력해주세요.',
|
||||
],
|
||||
'email' => [
|
||||
'required' => '이메일은 필수입니다.',
|
||||
'email' => '올바른 이메일 형식이 아닙니다.',
|
||||
@@ -1193,5 +1198,71 @@ return [
|
||||
'ses_key' => 'SES 액세스 키',
|
||||
'ses_secret' => 'SES 시크릿 키',
|
||||
'ses_region' => 'SES 리전',
|
||||
'mailgun_endpoint' => 'Mailgun 엔드포인트',
|
||||
// 일반 설정 (추가)
|
||||
'channels' => '알림 채널',
|
||||
'currency' => '기본 통화',
|
||||
'maintenance_mode' => '점검 모드',
|
||||
'asset_url_mode' => '자산 주소 방식',
|
||||
'site_logo' => '사이트 로고',
|
||||
// SEO 설정 (추가)
|
||||
'bot_user_agents' => '봇 User-Agent 목록',
|
||||
'bot_detection_enabled' => '봇 감지 사용',
|
||||
'bot_detection_library_enabled' => '봇 감지 라이브러리 사용',
|
||||
'og_default_site_name' => 'OG 기본 사이트 이름',
|
||||
'og_image_default_width' => 'OG 이미지 기본 너비',
|
||||
'og_image_default_height' => 'OG 이미지 기본 높이',
|
||||
'twitter_default_card' => '트위터 기본 카드 유형',
|
||||
'twitter_default_site' => '트위터 기본 계정',
|
||||
'seo_page_cache_enabled' => 'SEO 페이지 캐시 사용',
|
||||
'cache_ttl' => 'SEO 페이지 캐시 유지시간',
|
||||
'sitemap_enabled' => '사이트맵 사용',
|
||||
'sitemap_cache_ttl' => '사이트맵 캐시 유지시간',
|
||||
'sitemap_urls_per_file' => '사이트맵 파일당 URL 수',
|
||||
'sitemap_gzip' => '사이트맵 압축',
|
||||
'sitemap_serve_stale_on_miss' => '사이트맵 미생성 시 이전 파일 제공',
|
||||
'sitemap_max_urls_per_contributor' => '사이트맵 항목별 최대 URL 수',
|
||||
'sitemap_hreflang_enabled' => '사이트맵 대체 언어 링크 사용',
|
||||
'sitemap_schedule' => '사이트맵 자동 생성 주기',
|
||||
'sitemap_schedule_time' => '사이트맵 자동 생성 시각',
|
||||
'generator_enabled' => 'SEO 페이지 생성기 사용',
|
||||
'generator_content' => 'SEO 페이지 생성기 콘텐츠',
|
||||
// 보안 설정 (추가)
|
||||
'force_https' => 'HTTPS 강제 적용',
|
||||
'login_attempt_enabled' => '로그인 시도 제한',
|
||||
'auth_token_lifetime' => '인증 토큰 유지시간',
|
||||
'max_login_attempts' => '최대 로그인 시도 횟수',
|
||||
'login_lockout_time' => '로그인 차단 시간',
|
||||
'password_min_length' => '비밀번호 최소 길이',
|
||||
'require_password_special_char' => '비밀번호 특수문자 필수',
|
||||
'two_factor_auth' => '2단계 인증',
|
||||
'allow_internal_outbound_urls' => '내부 네트워크 주소 호출 허용',
|
||||
// 고급 설정 (추가)
|
||||
'advanced_cache_enabled' => '캐시 사용',
|
||||
'layout_cache_enabled' => '레이아웃 캐시 사용',
|
||||
'layout_cache_ttl' => '레이아웃 캐시 유지시간',
|
||||
'stats_cache_enabled' => '통계 캐시 사용',
|
||||
'stats_cache_ttl' => '통계 캐시 유지시간',
|
||||
'seo_cache_enabled' => 'SEO 캐시 사용',
|
||||
'seo_cache_ttl' => 'SEO 캐시 유지시간',
|
||||
'seo_sitemap_cache_ttl' => 'SEO 사이트맵 캐시 유지시간',
|
||||
'debug_mode' => '디버그 모드',
|
||||
'sql_query_log' => 'SQL 쿼리 로그',
|
||||
'core_update_github_url' => '코어 업데이트 GitHub 주소',
|
||||
'core_update_github_token' => '코어 업데이트 GitHub 토큰',
|
||||
'geoip_enabled' => 'GeoIP 사용',
|
||||
'geoip_license_key' => 'GeoIP 라이선스 키',
|
||||
'geoip_auto_update_enabled' => 'GeoIP 자동 업데이트',
|
||||
// 드라이버 설정 (추가)
|
||||
'websocket_app_id' => '웹소켓 앱 ID',
|
||||
'websocket_app_secret' => '웹소켓 앱 시크릿',
|
||||
'websocket_verify_ssl' => '웹소켓 SSL 인증서 검증',
|
||||
'websocket_server_host' => '웹소켓 서버 호스트',
|
||||
'websocket_server_port' => '웹소켓 서버 포트',
|
||||
'websocket_server_scheme' => '웹소켓 서버 프로토콜',
|
||||
'search_engine_driver' => '검색 엔진 드라이버',
|
||||
'log_driver' => '로그 드라이버',
|
||||
'log_level' => '로그 레벨',
|
||||
'log_days' => '로그 보관 일수',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
## [1.0.3] - 2026-07-16
|
||||
|
||||
### Security
|
||||
|
||||
- 게시판에 설정한 대댓글 최대 깊이가 모든 댓글 등록 경로에서 적용되도록 수정했습니다. 이전에는 글쓰기 화면을 거치는 요청에서만 깊이를 확인해, 연동 기능이 댓글을 직접 등록하면 설정한 깊이를 넘겨 달릴 수 있었습니다. 깊이를 넘긴 답글은 다른 위치로 옮겨 붙이지 않고 사유와 함께 거절합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 게시판에 설정한 첨부파일 최대 개수가 모든 첨부 경로에서 적용되도록 수정했습니다. 이전에는 글쓰기 화면에서 파일을 직접 첨부할 때만 개수를 확인해, 파일을 먼저 올려 두고 글을 저장하는 방식으로는 상한을 넘겨 첨부할 수 있었습니다. 이제 직접 첨부·미리 올린 파일 연결·이미 첨부된 파일까지 합산해 판정합니다. 이미 상한을 넘겨 등록된 기존 첨부는 그대로 두고 신규 등록만 차단합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
|
||||
### Added
|
||||
|
||||
- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
|
||||
@@ -19,11 +24,17 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 글을 쓰면서 파일을 함께 첨부하면 글은 저장되는데 첨부파일만 사라지던 문제를 수정했습니다. 첨부 개수·용량·형식 검사와 권한 확인은 모두 통과한 뒤 저장 단계에서만 빠져, 등록된 글에 첨부가 하나도 남지 않았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 게시판 신고 정책의 자동 숨김 기준 횟수를 0(자동 숨김 사용 안 함)으로 입력할 수 없던 문제를 수정했습니다. 서버는 0을 "사용 안 함"으로 처리하는데 화면에서만 1 이상을 요구해, 0을 입력하면 저장은 되면서도 입력칸이 계속 오류 상태로 남았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 글을 수정하면서 미리 올려 둔 파일을 첨부하면 저장은 되는데 첨부만 연결되지 않던 문제를 수정했습니다. 관리자 화면에서는 정상 연결되어 같은 요청이 화면에 따라 다르게 동작했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 개별 게시판을 수정할 때 목록 표시 개수·제목/내용 길이·댓글 깊이·첨부 개수 등 숫자 항목의 허용 범위가 화면에 표시되지 않던 문제를 수정했습니다. 게시판 환경설정(기본값) 화면에는 범위가 표시되는데 개별 게시판 화면에는 없어, 범위를 벗어난 값을 넣어도 저장을 눌러 봐야 알 수 있었습니다. 이제 두 화면이 같은 범위를 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 관리자 게시글 목록에서 글을 열어 이전글·다음글로 넘어가거나 수정 후 돌아올 때 보고 있던 페이지·검색어·분류가 사라지던 문제를 수정했습니다. 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 작성·수정 폼 어디로 오가든 목록 상태가 유지되며, 신고 관리와 게시판 목록도 동일하게 개선됐습니다. 새로고침 버튼도 걸어 둔 검색·필터를 지운 채 새로 부르지 않고 보고 있던 목록을 그대로 다시 부릅니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 관리자 게시글 목록에서 뒤쪽 페이지를 보던 중 검색하거나 게시 상태를 바꾸면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색과 상태 변경은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. 검색창에서 엔터를 치는 경우와 검색 버튼을 누르는 경우가 서로 다르게 동작하던 것도 함께 맞췄습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 새 글 표시 기간을 "0시간"(새 글 표시 안 함)으로 저장할 수 없던 문제를 수정했습니다. 0은 원래 새 글 배지를 끄는 값으로 동작하도록 되어 있었으나 저장 단계에서 막혀 있었습니다.
|
||||
- 게시판 환경설정에서 제목·댓글 최소 길이를 0(제한 없음)으로 저장할 수 없던 문제를 수정했습니다. 내용 최소 길이는 0이 허용되어 같은 화면에서 항목마다 동작이 달랐습니다.
|
||||
- 댓글 계층 정리 시 서로가 서로의 부모로 얽혀 최상위 댓글에서 닿을 수 없는 댓글이 아무 기록 없이 넘어가던 문제를 수정했습니다. 이런 댓글은 내용과 위치를 그대로 보존하되, 정리되지 않았다는 사실이 업데이트 기록에 남아 확인할 수 있습니다.
|
||||
- 허용 확장자를 지정하지 않은 게시판에서 어떤 파일도 첨부할 수 없던 문제를 수정했습니다. 파일 선택 단계에서 막히던 것과 서버에서 거부되던 것을 함께 고쳐, 확장자를 지정하지 않은 게시판은 기본 확장자 목록을 사용합니다. 첨부 안내에 확장자가 비어 표시되던 것도 함께 해소됩니다. 확장자를 지정한 게시판은 지금처럼 목록에 없는 파일을 계속 거부합니다.
|
||||
- 게시판 알림 설정에서 저장에 실패해도 아무 안내 없이 넘어가던 화면을 수정했습니다. 실패하면 그 이유가 화면에 표시됩니다.
|
||||
- 일부 안내 문구가 실제 문장 대신 내부 식별자(`sirsoft-board::messages....` 같은 문자열)로 표시되던 문제를 수정했습니다. 첨부파일이 있는 게시판을 찾지 못했을 때, 댓글 목록을 불러오지 못했을 때, 비회원 댓글의 비밀번호를 확인할 때, 삭제·블라인드된 글에 댓글을 달려 할 때, 권한이 없어 거절될 때가 해당됩니다.
|
||||
@@ -37,6 +48,7 @@
|
||||
- 다른 게시글의 댓글을 답글의 부모로 지정할 수 있던 문제를 수정했습니다. 이렇게 만들어진 답글은 어느 게시글 목록에도 나타나지 않았습니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 대댓글 깊이가 게시판 설정값을 넘어 계속 달리던 문제를 수정했습니다. 예전에는 6단계부터 들여쓰기가 더 들어가지 않고 설정한 최대 깊이도 지켜지지 않았습니다. 이제 설정한 깊이까지만 답글을 달 수 있고 화면의 단계 표시도 정확해집니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 기존에 쌓인 댓글 중 깊이가 어긋났거나 다른 게시글의 댓글을 부모로 가진 것을 업데이트 시 자동으로 정리합니다. 잘못 연결된 댓글은 삭제하지 않고 최상위 댓글로 옮기며, 내용은 그대로 보존됩니다.
|
||||
- 연동 기능이 블라인드·삭제된 글에 댓글을 등록하려 할 때 "댓글 등록에 실패했습니다" 라는 서버 오류만 표시되던 문제를 수정했습니다. 이제 글쓰기 화면에서와 똑같이 왜 등록할 수 없는지 사유가 표시됩니다.
|
||||
|
||||
## [1.0.2] - 2026-07-14
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -3148,9 +3148,13 @@ HTTP/1.1 200
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) |
|
||||
| id | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
| attachment_ids | body | integer[] | 아니오 | — | 미리 업로드해 둔 첨부 ID 목록. 게시글에 연결된다 (관리자 수정 경로와 동일) |
|
||||
| temp_key | body | string | 아니오 | 최대 64자 | 임시 업로드 묶음 키. 해당 묶음의 첨부가 게시글로 이동·연결된다 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.user_post.update_validation_rules`).
|
||||
|
||||
`attachment_ids` 는 **연결 대상**이며, 목록에서 뺀 기존 첨부를 삭제하지는 않습니다. 첨부 개수 상한은 이미 연결된 첨부까지 합산해 판정하므로 상한을 넘기면 `422`(`errors.code = attachment_limit_exceeded`)로 거부됩니다.
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
|
||||
+8
-3
@@ -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 타입이다', () => {
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 개별 게시판 수정 화면 — 숫자 입력 경계값 계약 테스트 (#493 T8)
|
||||
*
|
||||
* 배경: 같은 게시판 설정 항목이 두 화면에 있다.
|
||||
* - 일괄(모듈 기본값): `admin_board_settings` → 데이터소스 `settings`(initLocal "form")
|
||||
* - 개별(게시판별) : `admin_board_form` → 데이터소스 `boards`(initLocal "form")
|
||||
*
|
||||
* 두 화면 모두 서버가 `data._meta.limits` 로 같은 경계값을 내려준다
|
||||
* (`BoardSettingsController::index()` / `BoardController::getFormData()`).
|
||||
* 그런데 일괄 화면만 그 값을 min/max 로 바인딩하고 개별 화면은 아무 경계도 렌더하지 않아,
|
||||
* 같은 항목인데 한 화면에서만 범위를 알 수 있는 비대칭이 생겼다.
|
||||
*
|
||||
* 저장은 `StoreBoardRequest`/`UpdateBoardRequest` 가 `ReadsBoardLimits` 로 동일하게 막으므로
|
||||
* 잘못된 값이 저장되지는 않는다. 문제는 사용자가 **제출 전에** 알 수 없다는 것이다.
|
||||
*
|
||||
* 회귀 차단 포인트:
|
||||
* 1. 개별 화면의 숫자 입력은 min/max 를 가진다 (미지정 금지).
|
||||
* 2. 그 값은 리터럴이 아니라 `_local.form._meta.limits.*` 표현식이다
|
||||
* (리터럴이면 config 를 바꿔도 화면이 따라오지 않아 원래 결함으로 되돌아간다).
|
||||
* 3. 참조하는 limits 키는 일괄 화면이 쓰는 키와 동일하다 (두 화면이 갈라지지 않도록).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import formTabList from '../../../layouts/admin/partials/admin_board_form/_tab_list.json';
|
||||
import formTabPost from '../../../layouts/admin/partials/admin_board_form/_tab_post.json';
|
||||
|
||||
/**
|
||||
* [필드명, partial, limits 키 prefix]
|
||||
*
|
||||
* per_page_mobile 은 per_page 와 같은 범위를 공유한다(일괄 화면과 동일 규약).
|
||||
*/
|
||||
const fields: Array<[string, unknown, string]> = [
|
||||
['per_page', formTabList, 'per_page'],
|
||||
['per_page_mobile', formTabList, 'per_page'],
|
||||
['min_title_length', formTabPost, 'min_title_length'],
|
||||
['max_title_length', formTabPost, 'max_title_length'],
|
||||
['min_content_length', formTabPost, 'min_content_length'],
|
||||
['max_content_length', formTabPost, 'max_content_length'],
|
||||
['new_display_hours', formTabPost, 'new_display_hours'],
|
||||
['max_reply_depth', formTabPost, 'max_reply_depth'],
|
||||
['min_comment_length', formTabPost, 'min_comment_length'],
|
||||
['max_comment_length', formTabPost, 'max_comment_length'],
|
||||
['max_comment_depth', formTabPost, 'max_comment_depth'],
|
||||
['max_file_size', formTabPost, 'max_file_size'],
|
||||
['max_file_count', formTabPost, 'max_file_count'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 레이아웃 트리에서 지정한 name 의 number 입력 props 를 찾습니다.
|
||||
*
|
||||
* @param node 탐색 대상 노드
|
||||
* @param name 찾을 입력 name
|
||||
* @returns 찾은 props (없으면 null)
|
||||
*/
|
||||
function findNumberInput(node: unknown, name: string): Record<string, unknown> | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findNumberInput(child, name);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node && typeof node === 'object') {
|
||||
const props = (node as Record<string, unknown>).props as Record<string, unknown> | undefined;
|
||||
|
||||
if (props && props.type === 'number' && props.name === name) {
|
||||
return props;
|
||||
}
|
||||
|
||||
for (const value of Object.values(node as Record<string, unknown>)) {
|
||||
const found = findNumberInput(value, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('개별 게시판 수정 화면 — 숫자 입력 경계값 계약 (#493 T8)', () => {
|
||||
it.each(fields)('%s — min/max 가 지정되어 있다', (field, layout) => {
|
||||
const props = findNumberInput(layout, field);
|
||||
|
||||
expect(props, `${field} 입력을 찾지 못했습니다`).not.toBeNull();
|
||||
expect(props?.min, `${field}: min 미지정 — 사용자가 하한을 알 수 없습니다`).toBeDefined();
|
||||
expect(props?.max, `${field}: max 미지정 — 사용자가 상한을 알 수 없습니다`).toBeDefined();
|
||||
});
|
||||
|
||||
it.each(fields)('%s — 경계값이 _meta.limits 표현식이다 (리터럴 금지)', (field, layout, limitsKey) => {
|
||||
const props = findNumberInput(layout, field);
|
||||
|
||||
expect(String(props?.min)).toBe(
|
||||
`{{_local?.form?._meta?.limits?.${limitsKey}_min ?? ${fallbacks[`${limitsKey}_min`]}}}`
|
||||
);
|
||||
expect(String(props?.max)).toBe(
|
||||
`{{_local?.form?._meta?.limits?.${limitsKey}_max ?? ${fallbacks[`${limitsKey}_max`]}}}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 표현식 폴백 값 — `config/board.php` 의 limits 및 `ReadsBoardLimits` 기본치와 같은 값.
|
||||
*
|
||||
* 폴백까지 고정하는 이유: 폴백이 서버 기본치와 다르면 설정 응답이 늦거나 실패한 순간
|
||||
* 화면이 서버와 다른 경계를 보여 준다.
|
||||
*/
|
||||
const fallbacks: Record<string, number> = {
|
||||
per_page_min: 5,
|
||||
per_page_max: 100,
|
||||
min_title_length_min: 0,
|
||||
min_title_length_max: 200,
|
||||
max_title_length_min: 1,
|
||||
max_title_length_max: 1000,
|
||||
min_content_length_min: 0,
|
||||
min_content_length_max: 10000,
|
||||
max_content_length_min: 1,
|
||||
max_content_length_max: 100000,
|
||||
new_display_hours_min: 0,
|
||||
new_display_hours_max: 720,
|
||||
max_reply_depth_min: 1,
|
||||
max_reply_depth_max: 10,
|
||||
min_comment_length_min: 0,
|
||||
min_comment_length_max: 1000,
|
||||
max_comment_length_min: 1,
|
||||
max_comment_length_max: 10000,
|
||||
max_comment_depth_min: 0,
|
||||
max_comment_depth_max: 10,
|
||||
max_file_size_min: 1,
|
||||
max_file_size_max: 200,
|
||||
max_file_count_min: 1,
|
||||
max_file_count_max: 20,
|
||||
};
|
||||
+151
@@ -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}`]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+4
@@ -180,6 +180,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "per_page",
|
||||
"min": "{{_local?.form?._meta?.limits?.per_page_min ?? 5}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.per_page_max ?? 100}}",
|
||||
"required": true,
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
@@ -235,6 +237,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "per_page_mobile",
|
||||
"min": "{{_local?.form?._meta?.limits?.per_page_min ?? 5}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.per_page_max ?? 100}}",
|
||||
"required": true,
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
|
||||
+22
-1
@@ -72,6 +72,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "min_title_length",
|
||||
"min": "{{_local?.form?._meta?.limits?.min_title_length_min ?? 0}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.min_title_length_max ?? 200}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
}
|
||||
@@ -126,6 +128,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "max_title_length",
|
||||
"min": "{{_local?.form?._meta?.limits?.max_title_length_min ?? 1}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.max_title_length_max ?? 1000}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
}
|
||||
@@ -190,6 +194,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "min_content_length",
|
||||
"min": "{{_local?.form?._meta?.limits?.min_content_length_min ?? 0}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.min_content_length_max ?? 10000}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
}
|
||||
@@ -244,6 +250,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "max_content_length",
|
||||
"min": "{{_local?.form?._meta?.limits?.max_content_length_min ?? 1}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.max_content_length_max ?? 100000}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
}
|
||||
@@ -354,6 +362,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "new_display_hours",
|
||||
"min": "{{_local?.form?._meta?.limits?.new_display_hours_min ?? 0}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.new_display_hours_max ?? 720}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
}
|
||||
@@ -453,7 +463,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "max_reply_depth",
|
||||
"min": 1,
|
||||
"min": "{{_local?.form?._meta?.limits?.max_reply_depth_min ?? 1}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.max_reply_depth_max ?? 10}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
}
|
||||
@@ -561,6 +572,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "min_comment_length",
|
||||
"min": "{{_local?.form?._meta?.limits?.min_comment_length_min ?? 0}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.min_comment_length_max ?? 1000}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
}
|
||||
@@ -615,6 +628,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "max_comment_length",
|
||||
"min": "{{_local?.form?._meta?.limits?.max_comment_length_min ?? 1}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.max_comment_length_max ?? 10000}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
}
|
||||
@@ -672,6 +687,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "max_comment_depth",
|
||||
"min": "{{_local?.form?._meta?.limits?.max_comment_depth_min ?? 0}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.max_comment_depth_max ?? 10}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
}
|
||||
@@ -825,6 +842,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "max_file_count",
|
||||
"min": "{{_local?.form?._meta?.limits?.max_file_count_min ?? 1}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.max_file_count_max ?? 20}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
}
|
||||
@@ -879,6 +898,8 @@
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "max_file_size",
|
||||
"min": "{{_local?.form?._meta?.limits?.max_file_size_min ?? 1}}",
|
||||
"max": "{{_local?.form?._meta?.limits?.max_file_size_max ?? 200}}",
|
||||
"placeholder": "$t:sirsoft-board.admin.form.fields.max_file_size.placeholder",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": ""
|
||||
|
||||
+2
-2
@@ -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}}"
|
||||
}
|
||||
|
||||
+39
-6
@@ -43,8 +43,7 @@
|
||||
"id": "date_display_format_field",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
},
|
||||
"props": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -101,10 +100,27 @@
|
||||
"type": "radio",
|
||||
"name": "display.date_display_format",
|
||||
"value": "standard",
|
||||
"className": "radio mt-0.5 pointer-events-none",
|
||||
"className": "radio mt-0.5",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"checked": "{{(_local.form?.display?.date_display_format ?? 'standard') === 'standard'}}"
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form": {
|
||||
"...": "{{_local.form}}",
|
||||
"display": {
|
||||
"...": "{{_local.form?.display}}",
|
||||
"date_display_format": "standard"
|
||||
}
|
||||
},
|
||||
"hasChanges": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -173,10 +189,27 @@
|
||||
"type": "radio",
|
||||
"name": "display.date_display_format",
|
||||
"value": "relative",
|
||||
"className": "radio mt-0.5 pointer-events-none",
|
||||
"className": "radio mt-0.5",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"checked": "{{(_local.form?.display?.date_display_format ?? 'standard') === 'relative'}}"
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form": {
|
||||
"...": "{{_local.form}}",
|
||||
"display": {
|
||||
"...": "{{_local.form?.display}}",
|
||||
"date_display_format": "relative"
|
||||
}
|
||||
},
|
||||
"hasChanges": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
|
||||
+202
-58
@@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+10
-14
@@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user