feat(core,board,ecommerce,page,ckeditor5,kginicis): 업로드 이미지 축소·로그인 2단계 인증 도입 + 파생 결함 수정

인터뷰에서 방향만 정하고 남겨 뒀던 후속 5건을 구현하고, 커밋 전 종합 점검에서
발견한 결함 2건을 함께 고쳤다.

기능 도입:
- 업로드 이미지 축소 — `upload.image_max_width` / `image_max_height` / `image_quality`
 는 저장·검증만 되고 리사이즈 코드가 0건이었다. 비율 유지, 상한 이내면 재인코딩하지
 않음(불필요한 화질 손실 방지), 한계 미설정이면 무동작(기존 사이트 이미지 보호).
 PNG·GIF·WebP 투명도 보존. 실패해도 업로드는 계속되고 원본이 저장된다.
- 로그인 2단계 인증 — `security.two_factor_auth` 는 설정 항목만 있고 구현도 화면
 컨트롤도 없었다. 코어 IDV 인프라를 재사용해 purpose `login` 을 추가하고, 비밀번호
 확인 통과 후 challenge 만 발행한다(토큰 미발급 + logout). `POST /api/auth/login/two-factor`
 가 코드를 확인해야 토큰이 나가며, 로그인과 같은 요청 제한을 적용한다. challenge 의
 purpose 를 대조해 다른 흐름의 challenge 로 로그인하는 것을 막는다. 코드 발송이
 실패하면 실패로 알린다 — 2단계를 건너뛰고 로그인시키면 통제가 조용히 열린다.

결함 수정:
- 게시글 수정 시 사용자 경로가 `attachment_ids` 를 Service 로 넘기지 않아, 검증과
 권한은 통과하고 200 을 받는데 첨부만 조용히 연결되지 않았다. 관리자 경로는 넘기고
 있어 같은 요청이 화면에 따라 다르게 동작했다.
- 설정 화면 라디오가 `pointer-events-none` + Label click 으로만 상태를 바꿔, 키보드
 방향키(change) 조작 시 화면 표시와 저장될 값이 어긋났다. 동일 패턴 4개 레이아웃
 12개 라디오에 change 액션을 부여하고 params 를 click 과 동일하게 고정한다.
- 주문완료 화면이 결제수단 확인보다 영수증 조회를 앞세워, PG 를 거치지 않는 주문에서도
 매번 404 요청이 나갔다.
- 개별 게시판 폼의 숫자 항목에 경계값 바인딩이 없어 환경설정 화면과 안내가 달랐다.

커밋 전 점검에서 발견:
- 2단계 인증이 반쪽으로 들어가 있었다. enum 에 case 만 추가하고 코어 purpose
 레지스트리와 ko/en 라벨을 빠뜨려, 목적 목록 API 에서 통째로 빠지고 `hasPurpose`
 가 false 였으며 라벨 대신 i18n 키 원문이 반환됐다. 관리자가 이 목적의 메시지
 템플릿·정책을 만들 수 없는 상태였다. 세 곳을 enum 기준으로 전수 대조하는 회귀
 테스트를 함께 둔다.
- 업로드 이미지 축소가 적용되지 않은 8번째 서비스(레이아웃 편집기 첨부)가 있었다.
 열거형 커버리지 테스트는 내 조사 결과를 green 으로 고정할 뿐 전수를 증명하지
 못한다 — 같은 규약을 조건으로 표현한 audit 룰이 첫 실행에서 검출했다.

재발 방지:
- audit 룰 `upload-service-image-resize` (신규 업로드 서비스의 축소 누락 검출)
- 트러블슈팅 사례 20 (코어 설정이 일부 경로에만 적용되는 증상의 진단 절차)
- `sirsoft-ckeditor5` 의 코어 최소 요구 버전을 7.0.6 으로 상향 (신설 클래스 의존)

공개 이슈: gnuboard/g7
This commit is contained in:
HeuJung committed 2026-07-30 07:57:06 +09:00
1 parent d17ea28da5
commit 7b56d5f23a
72 files changed
+2024 -99

No files matched your search

+2
View File
@@ -16,6 +16,8 @@
- 허용 파일 형식을 저장해도 업로드 제한에 반영되지 않던 문제를 수정했습니다. 입력 칸은 `jpg,png,pdf` 처럼 쉼표로 구분한 한 줄이지만 적용 단계가 그 형태를 받아들이지 못해, 업로드 탭을 한 번이라도 저장하면 형식 제한이 저장 이전 목록으로 남았습니다. 저장은 성공하고 화면에도 값이 보여 어긋난 것을 알 방법이 없었습니다. 대문자나 앞의 점(`.JPG`, `.png`)은 자동으로 정리되며, 이미 저장되어 있던 값도 그대로 적용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 업로드된 파일의 저장 확장자를 파일명이 아니라 파일 내용에서 판별하도록 바꿨습니다. 확장자만 바꿔 올린 파일이 원래 형식과 다르게 저장되던 문제가 해소됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 본인인증의 '가입 후 본인확인' 정책을 켜면 회원가입 자체가 막혀 아무도 가입할 수 없던 문제를 수정했습니다. 이 정책은 가입은 받아들이고 계정을 '인증 대기' 상태로 둔 뒤 본인확인 안내를 보내는 설정인데, 안내를 보낸 직후 가입이 거절되어 '가입 전 본인확인' 정책과 구분이 없었습니다. 이제 설정한 대로 가입이 완료되고, 본인확인을 마치면 계정이 활성화됩니다. (안내 발송이 실패해도 가입은 유지되며 실패 사실은 기록에 남습니다.)
- 보안 환경설정의 '2단계 인증'이 실제로 동작합니다. 이전에는 항목만 있고 켜도 아무 일이 일어나지 않아, 관리자는 2단계 인증이 걸린 줄 알지만 비밀번호 하나로 로그인됐습니다. 이제 켜 두면 비밀번호 확인 뒤 인증번호를 메일로 보내고, 그 번호를 확인해야 로그인이 완료됩니다. 번호를 확인하기 전에는 로그인 상태가 만들어지지 않습니다. 안내 메일 문구는 환경설정 > 본인인증에서 '로그인 2단계 인증' 목적을 골라 따로 지정할 수 있습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 업로드의 이미지 최대 가로/세로 크기와 품질이 실제 업로드에 적용됩니다. 이전에는 값을 저장해도 이미지를 줄이는 동작이 없어 원본이 그대로 저장됐습니다. 크기를 지정하지 않으면 종전처럼 줄이지 않으며, 지정한 크기 이내인 이미지는 다시 변환하지 않아 불필요한 화질 손실이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 회원 상세 화면에 계정 잠금 상태 표시와 잠금 해제 버튼을 추가했습니다. 무기한 잠긴 계정은 로그인 성공으로 풀 수 없으므로 관리자가 직접 해제할 수 있어야 합니다. 해제는 활동 로그에 남습니다. (사용자 수정 권한이 필요합니다.) (#81 @jiwonpapa 님께서 제보해주셨습니다.)
### Added
+16 -2
View File
@@ -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 값 배열.
*
@@ -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
{
@@ -9,6 +9,7 @@ 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;
@@ -26,6 +27,8 @@ class AuthController extends AuthBaseController
// 공개 인증 엔드포인트를 제외한 나머지에만 인증 미들웨어 적용
$this->middleware('auth:sanctum')->except([
'login',
// 2단계 인증 확인은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
'verifyTwoFactor',
'register',
'forgotPassword',
'resetPassword',
@@ -47,6 +50,11 @@ 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']);
@@ -68,6 +76,33 @@ class AuthController extends AuthBaseController
}
}
/**
* 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');
}
}
/**
* 새로운 사용자를 등록시킵니다.
*
@@ -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
{
@@ -277,6 +277,7 @@ class SaveSettingsRequest extends FormRequest
'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'],
@@ -876,6 +877,7 @@ class SaveSettingsRequest extends FormRequest
'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'),
@@ -667,5 +667,13 @@ class SettingsServiceProvider extends ServiceProvider
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]);
}
}
}
}
+8 -1
View File
@@ -8,6 +8,7 @@ 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;
@@ -28,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
) {}
/**
@@ -59,6 +62,10 @@ class AttachmentService
// 필터 훅 - 파일 데이터 변형 (압축, 리사이즈 등 확장 포인트)
$file = HookManager::applyFilters('core.attachment.filter_upload_file', $file);
// 환경설정 > 업로드의 최대 가로/세로·품질을 실제로 적용한다.
// 임시 파일을 제자리에서 줄이므로 아래의 저장·크기·메타 계산이 모두 축소본을 본다.
$this->imageResizer->resizeInPlace($file->getRealPath(), $file->getMimeType());
// 저장 경로 생성 (날짜별 디렉토리).
// 확장자는 클라이언트가 보낸 파일명이 아니라 MIME 추론값을 우선 사용한다 —
// 파일명 확장자는 사용자가 임의로 바꿀 수 있어 저장 확장자의 근거가 될 수 없다.
+139
View File
@@ -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;
@@ -142,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 대시보드 인증용)
@@ -214,6 +214,35 @@ class IdentityVerificationService
];
}
/**
* 지정 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 콜백을 처리합니다.
*
@@ -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()));
+187
View File
@@ -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));
}
}
+19
View File
@@ -58,4 +58,23 @@ return [
'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),
];
+58
View File
@@ -1434,6 +1434,64 @@ Content-Type: application/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
<!-- @generated:start:api.auth.logout -->
+18 -6
View File
@@ -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 가 목적별 선택지와 문구를 구성할 때 사용합니다.
+3 -1
View File
@@ -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` 이 등록되어 있으므로 선택할 수 있습니다.
---
## 클래스 계층
+1 -1
View File
@@ -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 |
+2 -2
View File
@@ -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. 관련 코드 진입점
+1 -1
View File
@@ -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
@@ -15,6 +15,7 @@
- 플러그인을 GitHub 주소 또는 ZIP 파일로 설치할 때의 입력 검증 안내 문구 일본어 번역 추가 — 주소 형식이 잘못됐거나 ZIP 이 아닌 파일을 올렸을 때, 파일 용량을 초과했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 회원가입 검증 메시지의 휴대폰·전화번호 항목(`validation.auth.mobile.*`, `validation.auth.phone.*`) 일본어 번역 추가 — 회원가입 시 연락처 형식 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 환경설정 저장 시 오류 안내에 나오는 항목 이름의 일본어 번역 추가 — 보안·업로드·SEO·고급·드라이버 등 각 설정 항목이 내부 식별자 대신 일본어 항목명으로 표시됩니다.
- 로그인 2단계 인증 안내(인증번호 발송·불일치·요청 만료)와 인증번호 입력 검증 문구의 일본어 번역 추가.
## [1.0.6] - 2026-07-22
@@ -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' => 'ログアウトが成功しました。',
@@ -38,4 +41,5 @@ return [
'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' => '正しいメールアドレス形式ではありません。',
@@ -1108,6 +1113,7 @@ return [
'login_lockout_time' => 'ログインブロック時間',
'password_min_length' => 'パスワード最小長',
'require_password_special_char' => 'パスワード特殊文字必須',
'two_factor_auth' => '2段階認証',
'allow_internal_outbound_urls' => '内部ネットワークアドレス呼び出しの許可',
'advanced_cache_enabled' => 'キャッシュの使用',
'layout_cache_enabled' => 'レイアウトキャッシュの使用',
@@ -8,6 +8,7 @@
### 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*`) — 항목 이름과 설명, 선택지, 자동 감지 결과(확장자를 가로채는 서버 규칙 안내 포함)가 일본어 로케일에서 자연스럽게 표시됩니다.
@@ -1492,6 +1492,9 @@
"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": {
+4
View 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.',
+4
View File
@@ -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' => [
+6
View File
@@ -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.',
@@ -1237,6 +1242,7 @@ return [
'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',
+4
View File
@@ -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' => '로그아웃이 성공했습니다.',
+4
View File
@@ -65,6 +65,10 @@ return [
'label' => '민감 작업',
'description' => '계정 탈퇴·관리자 작업 등 재인증이 필요한 시점.',
],
'login' => [
'label' => '로그인 2단계 인증',
'description' => '2단계 인증을 켰을 때 비밀번호 확인 뒤 한 단계를 더 요구.',
],
],
'channels' => [
+6
View File
@@ -763,6 +763,11 @@ return [
// 인증 관련 검증 메시지
'auth' => [
'two_factor' => [
'challenge_required' => '인증 요청 정보가 없습니다. 처음부터 다시 로그인해주세요.',
'challenge_invalid' => '인증 요청 정보가 올바르지 않습니다.',
'code_required' => '인증번호를 입력해주세요.',
],
'email' => [
'required' => '이메일은 필수입니다.',
'email' => '올바른 이메일 형식이 아닙니다.',
@@ -1230,6 +1235,7 @@ return [
'login_lockout_time' => '로그인 차단 시간',
'password_min_length' => '비밀번호 최소 길이',
'require_password_special_char' => '비밀번호 특수문자 필수',
'two_factor_auth' => '2단계 인증',
'allow_internal_outbound_urls' => '내부 네트워크 주소 호출 허용',
// 고급 설정 (추가)
'advanced_cache_enabled' => '캐시 사용',
@@ -26,6 +26,9 @@
- 글을 쓰면서 파일을 함께 첨부하면 글은 저장되는데 첨부파일만 사라지던 문제를 수정했습니다. 첨부 개수·용량·형식 검사와 권한 확인은 모두 통과한 뒤 저장 단계에서만 빠져, 등록된 글에 첨부가 하나도 남지 않았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시판 신고 정책의 자동 숨김 기준 횟수를 0(자동 숨김 사용 안 함)으로 입력할 수 없던 문제를 수정했습니다. 서버는 0을 "사용 안 함"으로 처리하는데 화면에서만 1 이상을 요구해, 0을 입력하면 저장은 되면서도 입력칸이 계속 오류 상태로 남았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 글을 수정하면서 미리 올려 둔 파일을 첨부하면 저장은 되는데 첨부만 연결되지 않던 문제를 수정했습니다. 관리자 화면에서는 정상 연결되어 같은 요청이 화면에 따라 다르게 동작했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 개별 게시판을 수정할 때 목록 표시 개수·제목/내용 길이·댓글 깊이·첨부 개수 등 숫자 항목의 허용 범위가 화면에 표시되지 않던 문제를 수정했습니다. 게시판 환경설정(기본값) 화면에는 범위가 표시되는데 개별 게시판 화면에는 없어, 범위를 벗어난 값을 넣어도 저장을 눌러 봐야 알 수 있었습니다. 이제 두 화면이 같은 범위를 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 게시글 목록에서 글을 열어 이전글·다음글로 넘어가거나 수정 후 돌아올 때 보고 있던 페이지·검색어·분류가 사라지던 문제를 수정했습니다. 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 작성·수정 폼 어디로 오가든 목록 상태가 유지되며, 신고 관리와 게시판 목록도 동일하게 개선됐습니다. 새로고침 버튼도 걸어 둔 검색·필터를 지운 채 새로 부르지 않고 보고 있던 목록을 그대로 다시 부릅니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 게시글 목록에서 뒤쪽 페이지를 보던 중 검색하거나 게시 상태를 바꾸면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색과 상태 변경은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. 검색창에서 엔터를 치는 경우와 검색 버튼을 누르는 경우가 서로 다르게 동작하던 것도 함께 맞췄습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -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
@@ -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,
};
@@ -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": ""
@@ -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": ""
@@ -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",
@@ -382,7 +382,14 @@ class PostController extends PublicBaseController
// 게시글 수정
$data = $request->validated();
$post = $this->postService->updatePost($slug, $id, $data);
// `attachment_ids` 를 Service 로 넘기지 않으면 검증(형식·개수 상한 합산)은 통과하고
// 첨부만 조용히 연결되지 않는다 (200 + 첨부 0 건). 관리자 경로는 넘기고 있으므로
// 같은 요청이 화면에 따라 다르게 동작했다.
$attachmentIds = $data['attachment_ids'] ?? [];
unset($data['attachment_ids']);
$post = $this->postService->updatePost($slug, $id, $data, $attachmentIds);
// board 관계 수동 설정
$post->setRelation('board', $board);
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Services;
use App\Contracts\Extension\StorageInterface;
use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
use App\Support\ImageResizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile;
@@ -127,6 +128,10 @@ class AttachmentService
$path = "{$slug}/temp/{$tempKey}/{$storedFilename}";
}
// 환경설정 > 업로드의 최대 가로/세로·품질을 적용한다 (코어 설정이 모든 업로드 경로에 동일 적용).
// 임시 파일을 제자리에서 줄이므로 아래의 저장·크기 계산이 모두 축소본을 본다.
app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType());
// 스토리지에 파일 저장 (category: 'attachments')
$this->storage->put('attachments', $path, file_get_contents($file->getRealPath()));
@@ -442,6 +442,64 @@ class AttachmentLimitTest extends BoardTestCase
->assertSuccessful();
}
/**
* 수정 시 `attachment_ids` 로 보낸 첨부가 사용자 경로에서도 실제로 연결된다.
*
* 결함: 관리자 컨트롤러는 `attachment_ids` 를 뽑아 Service 로 넘기는데 사용자 컨트롤러는
* 넘기지 않았다. 그래서 같은 요청이 검증(형식·개수 상한 합산)은 통과하고 200 을 받는데
* 첨부만 조용히 연결되지 않았다 — 화면에는 성공으로 보이고 첨부만 사라진다.
* 작성 경로의 `files[]` 유실과 같은 계열이며, 두 경로가 같은 강도여야 한다.
*/
public function test_update_links_attachment_ids_on_user_path(): void
{
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
$tempKey = 'tk_'.str_repeat('u', 20);
$this->seedTempAttachments($tempKey, 2);
$attachmentIds = Attachment::where('temp_key', $tempKey)->pluck('id')->all();
$response = $this->actingAs($this->memberUser, 'sanctum')->putJson(
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}",
[
'title' => '첨부 연결 수정',
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
'attachment_ids' => $attachmentIds,
]
);
$response->assertStatus(200);
$this->assertSame(
2,
Attachment::where('post_id', $postId)->count(),
'attachment_ids 로 보낸 첨부가 게시글에 연결되지 않았습니다 — 응답은 성공인데 첨부만 사라집니다.'
);
}
/**
* 수정 경로의 `attachment_ids` 도 개수 상한을 넘기면 차단된다 (위 경계의 대조군).
*/
public function test_update_attachment_ids_over_limit_is_blocked(): void
{
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
$tempKey = 'tk_'.str_repeat('o', 20);
$this->seedTempAttachments($tempKey, 6);
$attachmentIds = Attachment::where('temp_key', $tempKey)->pluck('id')->all();
$response = $this->actingAs($this->memberUser, 'sanctum')->putJson(
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}",
[
'title' => '상한 초과 첨부 수정',
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
'attachment_ids' => $attachmentIds,
]
);
$response->assertStatus(422);
$this->assertSame(0, Attachment::where('post_id', $postId)->count());
}
/**
* 상한 미설정(0) 게시판은 제한하지 않는다 (기존 동작 유지).
*/
@@ -0,0 +1,78 @@
/**
* 개별 게시판 수정 화면의 숫자 입력 경계값이 서버 한계값과 일치하는지 검증 (공개 이슈 #81).
*
* 결함: 같은 설정 항목이 두 화면에 있는데 경계 안내가 한쪽에만 있었다.
* - 게시판 환경설정(모듈 기본값): 숫자 입력에 허용 범위가 붙어 있음
* - 개별 게시판 수정: min/max 가 아예 없어 범위를 알 수 없음
* 저장은 서버가 동일하게 막으므로 잘못된 값이 들어가지는 않았지만, 사용자는 저장을
* 눌러 422 를 받아야만 범위를 알 수 있었다.
*
* 단위/정적:
* - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-form-range-contract.test.ts
* 가 레이아웃 JSON 의 표현식(_local.form._meta.limits.*)과 폴백 값을 전수 고정
* 이 spec 은 브라우저 수준 — 서버가 실제로 내려준 한계값이 DOM 속성으로 해석되는지를 담당한다.
* (표현식이 잘못된 경로를 가리켜도 폴백 값이 서버 기본치와 같으면 화면만 봐서는 구분되지
* 않으므로, 응답의 `_meta.limits` 를 읽어 그 값과 대조한다.)
*
* 시드 의존: 샘플 시드의 자유게시판(`free`).
*
* @scenario board-form-range-parity
* @axes screen=per_board screen=module_defaults
* @effects form_boundary_matches_server_limits
*/
import { test, expect, authenticatePage } from '../../fixtures/board-auth';
const SLUG = 'free';
/** 개별 게시판 화면의 입력 name → 서버 limits 키 prefix (per_page_mobile 은 per_page 범위 공유) */
const FIELD_TO_LIMIT: Record<string, string> = {
per_page: 'per_page',
per_page_mobile: 'per_page',
min_title_length: 'min_title_length',
max_title_length: 'max_title_length',
min_content_length: 'min_content_length',
max_content_length: 'max_content_length',
new_display_hours: 'new_display_hours',
min_comment_length: 'min_comment_length',
max_comment_length: 'max_comment_length',
max_comment_depth: 'max_comment_depth',
max_file_size: 'max_file_size',
max_file_count: 'max_file_count',
};
test('#81 - 개별 게시판 수정 화면의 숫자 경계값이 서버 한계값과 일치한다', async ({ page, boardManageToken }) => {
await authenticatePage(page, boardManageToken);
await page.goto(`/admin/boards/${SLUG}/edit`);
await expect(page.locator('input[name="per_page"]')).toBeAttached({ timeout: 20_000 });
// 서버가 실제로 내려주는 한계값을 그대로 읽는다 (화면 폴백과 구분하기 위함)
const limits = await page.evaluate(async ({ slug, bearer }) => {
const response = await fetch(
`/api/modules/sirsoft-board/admin/boards/form-data?board_slug=${slug}`,
{ headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' } }
);
const body = await response.json();
return (body?.data?._meta?.limits ?? {}) as Record<string, number>;
}, { slug: SLUG, bearer: boardManageToken });
expect(
Object.keys(limits).length,
'게시판 한계값이 폼 데이터 응답에 실려 있지 않습니다 — 화면 바인딩이 영구히 폴백으로 떨어집니다.'
).toBeGreaterThan(0);
for (const [field, limitKey] of Object.entries(FIELD_TO_LIMIT)) {
const input = page.locator(`input[name="${field}"]`).first();
// 토글 뒤에 숨은 입력(예: 답글 사용)은 이 화면 기본 상태에서 렌더되지 않을 수 있다
if ((await input.count()) === 0) {
continue;
}
await expect(input, `${field}: min 이 서버 한계값과 다릅니다`)
.toHaveAttribute('min', String(limits[`${limitKey}_min`]));
await expect(input, `${field}: max 가 서버 한계값과 다릅니다`)
.toHaveAttribute('max', String(limits[`${limitKey}_max`]));
}
});
@@ -30,6 +30,7 @@
### Fixed
- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 로그인하지 않은 상태로 장바구니에 담아 둔 상품이 로그인 후 사라지던 문제를 수정했습니다. 로그인 시 장바구니를 회원 계정으로 옮기는 처리가 실행되지 않아, 담아 둔 상품이 아무 안내 없이 없어졌습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 설정 화면이 허용하는 값과 저장 규칙이 서로 달라 저장이 거부되거나 그 반대가 되던 항목들을 맞췄습니다. 리뷰 이미지 최대 개수(최대 20개), 리뷰 이미지 최대 크기(정수 MB, 최대 50MB), 마일리지 기본 적립률(최대 100%), 미입금 자동취소 기한(최소 1일 — 0일은 주문 즉시 만료라 사용할 수 없는 값)이 대상입니다. 정수만 받는 항목은 입력칸의 증감 단위도 1로 맞춰, 화살표로 값을 올리다 소수가 되어 저장이 거부되는 일이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 상품 고시정보 템플릿 목록에서 한 번에 가져올 개수에 상한(100개)을 두었습니다. 이전에는 임의의 큰 값을 넘겨 한 요청으로 전체 데이터를 가져갈 수 있었습니다. '전체' 조회는 종전대로 동작합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -0,0 +1,118 @@
/**
* 라디오 선택이 키보드로도 조작 가능한지 계약 테스트 (#493 후속)
*
* 배경: 라디오를 커스텀 스타일로 그리기 위해 `<Label>` 로 감싸고, 라디오 자체는
* `pointer-events-none` 으로 클릭을 통과시킨 뒤 Label 의 click 액션이 상태를 바꾼다.
*
* 결함: 이 구조에서 상태를 바꾸는 유일한 경로가 **click** 이다. 키보드 사용자는 라디오에
* 포커스를 두고 방향키로 항목을 옮기는데, 그때 발생하는 것은 click 이 아니라 change 다.
* 즉 화면상 선택 표시는 움직이는데 저장될 값은 따라오지 않는다 — 눈에 보이는 것과
* 저장되는 것이 어긋나므로 잘못된 값을 저장하고도 알 수 없다.
*
* 실측(2026-07-29, 마일리지 한도 설정): 마우스 클릭은 정상. 라디오 2개가 DOM 에
* 같은 `value` 로 렌더되는 것도 이 구조의 부산물이다(상태는 `checked` 바인딩이 결정).
*
* 회귀 차단 포인트:
* 1. 라디오는 클릭을 받을 수 있어야 한다 (`pointer-events-none` 금지).
* 2. 라디오 자신이 change 액션을 가져야 한다 — 키보드 조작의 유일한 신호다.
* 3. change 액션이 설정하는 값은 감싼 Label 의 click 액션과 같아야 한다
* (둘이 갈라지면 마우스와 키보드가 다른 값을 저장한다).
*/
import { describe, it, expect } from 'vitest';
import currencyCards from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json';
import currencyTable from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json';
type Node = Record<string, unknown>;
/**
* 레이아웃 트리에서 radio 입력 노드와 그것을 감싼 액션 보유 조상을 모두 수집합니다.
*
* @param node 탐색 대상
* @param ancestor 현재까지의 최근접 액션 보유 조상
* @param out 수집 결과
* @returns 수집된 (radio, ancestor) 쌍 목록
*/
function collectRadios(
node: unknown,
ancestor: Node | null = null,
out: Array<{ node: Node; props: Node; ancestor: Node | null }> = []
): Array<{ node: Node; props: Node; ancestor: Node | null }> {
if (Array.isArray(node)) {
node.forEach((child) => collectRadios(child, ancestor, out));
return out;
}
if (node && typeof node === 'object') {
const current = node as Node;
const props = (current.props ?? {}) as Node;
if (props.type === 'radio') {
out.push({ node: current, props, ancestor });
}
const nextAncestor = Array.isArray(current.actions) ? current : ancestor;
Object.values(current).forEach((value) => collectRadios(value, nextAncestor, out));
}
return out;
}
/**
* 액션 목록에서 지정 이벤트 타입의 액션을 찾습니다.
*
* @param owner 액션 보유 노드
* @param type 이벤트 타입
* @returns 찾은 액션 (없으면 undefined)
*/
function actionOfType(owner: Node | null, type: string): Node | undefined {
const actions = (owner?.actions ?? []) as Node[];
return Array.isArray(actions) ? actions.find((a) => a.type === type) : undefined;
}
const layouts: Array<[string, unknown]> = [
['마일리지 통화 카드(모바일)', currencyCards],
['마일리지 통화 표(데스크탑)', currencyTable],
];
describe('라디오 키보드 조작 계약 (#493 후속)', () => {
it.each(layouts)('%s — 라디오가 클릭을 받을 수 있다', (_label, layout) => {
const radios = collectRadios(layout);
expect(radios.length, '라디오를 찾지 못했습니다 — 검사가 무력화됐습니다').toBeGreaterThan(0);
radios.forEach(({ props }) => {
expect(
String(props.className ?? ''),
`${props.name}: pointer-events-none 이면 키보드 포커스로 바꾼 값이 저장되지 않습니다`
).not.toContain('pointer-events-none');
});
});
it.each(layouts)('%s — 라디오가 change 액션을 가지고, 값이 Label 클릭과 같다', (_label, layout) => {
const radios = collectRadios(layout);
radios.forEach(({ node, props, ancestor }) => {
const change = actionOfType(node, 'change');
expect(
change,
`${props.name}: 라디오 자신에 change 액션이 없습니다 — 키보드 조작 신호를 받을 곳이 없습니다`
).toBeDefined();
const click = actionOfType(ancestor, 'click');
expect(click, `${props.name}: 감싼 요소의 click 액션을 찾지 못했습니다`).toBeDefined();
// 마우스와 키보드가 다른 값을 저장하면 조작 방법에 따라 결과가 갈린다
expect(
JSON.stringify(change?.params),
`${props.name}: change 와 click 이 서로 다른 값을 저장합니다`
).toBe(JSON.stringify(click?.params));
});
});
});
@@ -223,9 +223,20 @@
"name": "{{'mileage.currency_rules.' + rule._idx + '.max_use_type'}}",
"value": "percent",
"checked": "{{rule.max_use_type === 'percent'}}",
"disabled": "{{_computed.isReadOnly}}",
"className": "pointer-events-none"
}
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"if": "{{!_computed.isReadOnly}}",
"params": {
"target": "local",
"form.mileage.currency_rules": "{{(_local.form?.mileage?.currency_rules ?? []).map((c, i) => i === rule._idx ? { ...c, max_use_type: 'percent' } : c)}}",
"hasChanges": true
}
}
]
},
{
"type": "basic",
@@ -285,9 +296,20 @@
"name": "{{'mileage.currency_rules.' + rule._idx + '.max_use_type'}}",
"value": "fixed",
"checked": "{{rule.max_use_type === 'fixed'}}",
"disabled": "{{_computed.isReadOnly}}",
"className": "pointer-events-none"
}
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"if": "{{!_computed.isReadOnly}}",
"params": {
"target": "local",
"form.mileage.currency_rules": "{{(_local.form?.mileage?.currency_rules ?? []).map((c, i) => i === rule._idx ? { ...c, max_use_type: 'fixed' } : c)}}",
"hasChanges": true
}
}
]
},
{
"type": "basic",
@@ -485,9 +507,19 @@
"name": "newMileageCurrency.max_use_type",
"value": "percent",
"checked": "{{(_local.form?.newMileageCurrency?.max_use_type ?? 'percent') === 'percent'}}",
"disabled": "{{_computed.isReadOnly}}",
"className": "pointer-events-none"
}
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"if": "{{!_computed.isReadOnly}}",
"params": {
"target": "local",
"form.newMileageCurrency.max_use_type": "percent"
}
}
]
},
{
"type": "basic",
@@ -546,9 +578,19 @@
"name": "newMileageCurrency.max_use_type",
"value": "fixed",
"checked": "{{(_local.form?.newMileageCurrency?.max_use_type ?? 'percent') === 'fixed'}}",
"disabled": "{{_computed.isReadOnly}}",
"className": "pointer-events-none"
}
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"if": "{{!_computed.isReadOnly}}",
"params": {
"target": "local",
"form.newMileageCurrency.max_use_type": "fixed"
}
}
]
},
{
"type": "basic",
@@ -263,9 +263,20 @@
"name": "{{'mileage.currency_rules.' + rule._idx + '.max_use_type'}}",
"value": "percent",
"checked": "{{rule.max_use_type === 'percent'}}",
"disabled": "{{_computed.isReadOnly}}",
"className": "pointer-events-none"
}
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"if": "{{!_computed.isReadOnly}}",
"params": {
"target": "local",
"form.mileage.currency_rules": "{{(_local.form?.mileage?.currency_rules ?? []).map((c, i) => i === rule._idx ? { ...c, max_use_type: 'percent' } : c)}}",
"hasChanges": true
}
}
]
},
{
"type": "basic",
@@ -325,9 +336,20 @@
"name": "{{'mileage.currency_rules.' + rule._idx + '.max_use_type'}}",
"value": "fixed",
"checked": "{{rule.max_use_type === 'fixed'}}",
"disabled": "{{_computed.isReadOnly}}",
"className": "pointer-events-none"
}
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"if": "{{!_computed.isReadOnly}}",
"params": {
"target": "local",
"form.mileage.currency_rules": "{{(_local.form?.mileage?.currency_rules ?? []).map((c, i) => i === rule._idx ? { ...c, max_use_type: 'fixed' } : c)}}",
"hasChanges": true
}
}
]
},
{
"type": "basic",
@@ -574,9 +596,19 @@
"name": "newMileageCurrency.max_use_type",
"value": "percent",
"checked": "{{(_local.form?.newMileageCurrency?.max_use_type ?? 'percent') === 'percent'}}",
"disabled": "{{_computed.isReadOnly}}",
"className": "pointer-events-none"
}
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"if": "{{!_computed.isReadOnly}}",
"params": {
"target": "local",
"form.newMileageCurrency.max_use_type": "percent"
}
}
]
},
{
"type": "basic",
@@ -635,9 +667,19 @@
"name": "newMileageCurrency.max_use_type",
"value": "fixed",
"checked": "{{(_local.form?.newMileageCurrency?.max_use_type ?? 'percent') === 'fixed'}}",
"disabled": "{{_computed.isReadOnly}}",
"className": "pointer-events-none"
}
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"if": "{{!_computed.isReadOnly}}",
"params": {
"target": "local",
"form.newMileageCurrency.max_use_type": "fixed"
}
}
]
},
{
"type": "basic",
@@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Ecommerce\Services;
use App\Contracts\Extension\StorageInterface;
use App\Extension\HookManager;
use App\Support\ImageResizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
@@ -22,8 +24,8 @@ class CategoryImageService
/**
* CategoryImageService 생성자
*
* @param CategoryImageRepositoryInterface $repository 카테고리 이미지 리포지토리
* @param StorageInterface $storage 모듈 스토리지 드라이버
* @param CategoryImageRepositoryInterface $repository 카테고리 이미지 리포지토리
* @param StorageInterface $storage 모듈 스토리지 드라이버
*/
public function __construct(
private CategoryImageRepositoryInterface $repository,
@@ -36,11 +38,11 @@ class CategoryImageService
* category_id가 없는 경우 임시 업로드로 처리합니다.
* 임시 업로드된 이미지는 temp_key로 식별되며, 카테고리 저장 시 연결됩니다.
*
* @param UploadedFile $file 업로드된 파일
* @param int|null $categoryId 카테고리 ID (새 카테고리 생성 시 null)
* @param string $collection 컬렉션명
* @param string|null $tempKey 임시 업로드 키 (새 카테고리 생성 시 사용)
* @param array|null $altText 대체 텍스트 (다국어 배열)
* @param UploadedFile $file 업로드된 파일
* @param int|null $categoryId 카테고리 ID (새 카테고리 생성 시 null)
* @param string $collection 컬렉션명
* @param string|null $tempKey 임시 업로드 키 (새 카테고리 생성 시 사용)
* @param array|null $altText 대체 텍스트 (다국어 배열)
* @return CategoryImage 생성된 이미지
*/
public function upload(
@@ -51,7 +53,7 @@ class CategoryImageService
?array $altText = null
): CategoryImage {
// categoryId와 tempKey 모두 없으면 tempKey 자동 생성
if (!$categoryId && !$tempKey) {
if (! $categoryId && ! $tempKey) {
$tempKey = Str::uuid()->toString();
}
@@ -67,6 +69,9 @@ class CategoryImageService
$path = "category/{$datePath}/{$storedFilename}";
// 스토리지에 파일 저장 (category: 'images')
// 환경설정 > 업로드의 최대 가로/세로·품질 적용 (코어 설정이 모든 업로드 경로에 동일 적용)
app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType());
$this->storage->put('images', $path, file_get_contents($file->getRealPath()));
// Disk 정보는 스토리지 드라이버에서 가져옴
@@ -123,8 +128,8 @@ class CategoryImageService
/**
* 임시 이미지를 카테고리에 연결합니다.
*
* @param string $tempKey 임시 업로드 키
* @param int $categoryId 카테고리 ID
* @param string $tempKey 임시 업로드 키
* @param int $categoryId 카테고리 ID
* @return int 연결된 이미지 수
*/
public function linkTempImages(string $tempKey, int $categoryId): int
@@ -135,9 +140,9 @@ class CategoryImageService
/**
* 임시 이미지 목록을 조회합니다.
*
* @param string $tempKey 임시 업로드 키
* @param string|null $collection 컬렉션 필터
* @return \Illuminate\Database\Eloquent\Collection
* @param string $tempKey 임시 업로드 키
* @param string|null $collection 컬렉션 필터
* @return Collection
*/
public function getTempImages(string $tempKey, ?string $collection = null)
{
@@ -147,7 +152,7 @@ class CategoryImageService
/**
* 해시로 이미지 조회
*
* @param string $hash 이미지 해시
* @param string $hash 이미지 해시
* @return CategoryImage|null 이미지 또는 null
*/
public function getByHash(string $hash): ?CategoryImage
@@ -158,7 +163,7 @@ class CategoryImageService
/**
* 이미지 다운로드 응답 생성
*
* @param string $hash 이미지 해시 (12자)
* @param string $hash 이미지 해시 (12자)
* @return StreamedResponse|null 이미지 스트림 또는 없을 경우 null
*/
public function download(string $hash): ?StreamedResponse
@@ -195,7 +200,7 @@ class CategoryImageService
/**
* 이미지 삭제
*
* @param int $id 이미지 ID
* @param int $id 이미지 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool
@@ -242,7 +247,7 @@ class CategoryImageService
/**
* 순서 변경
*
* @param array<int, int> $orders 이미지 ID => sort_order 매핑
* @param array<int, int> $orders 이미지 ID => sort_order 매핑
* @return bool 성공 여부
*/
public function reorder(array $orders): bool
@@ -261,8 +266,8 @@ class CategoryImageService
/**
* 삭제 후 남은 이미지들의 순서를 재정렬합니다.
*
* @param int $categoryId 카테고리 ID
* @param string $collection 컬렉션명
* @param int $categoryId 카테고리 ID
* @param string $collection 컬렉션명
*/
protected function reorderAfterDelete(int $categoryId, string $collection): void
{
@@ -281,9 +286,9 @@ class CategoryImageService
/**
* 카테고리의 이미지 목록을 조회합니다.
*
* @param int $categoryId 카테고리 ID
* @param string|null $collection 컬렉션 필터
* @return \Illuminate\Database\Eloquent\Collection
* @param int $categoryId 카테고리 ID
* @param string|null $collection 컬렉션 필터
* @return Collection
*/
public function getImages(int $categoryId, ?string $collection = null)
{
@@ -295,8 +300,7 @@ class CategoryImageService
*
* 카테고리 저장 실패 시 업로드된 이미지들을 정리하기 위해 사용됩니다.
*
* @param array<int> $imageIds 이미지 ID 배열
* @return void
* @param array<int> $imageIds 이미지 ID 배열
*/
public function rollbackUploadedImages(array $imageIds): void
{
@@ -308,8 +312,8 @@ class CategoryImageService
/**
* 이미지 정보 업데이트 (대체 텍스트 등)
*
* @param int $id 이미지 ID
* @param array $data 업데이트할 데이터
* @param int $id 이미지 ID
* @param array $data 업데이트할 데이터
* @return CategoryImage 업데이트된 이미지
*/
public function update(int $id, array $data): CategoryImage
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Services;
use App\Contracts\Extension\StorageInterface;
use App\Extension\HookManager;
use App\Support\ImageResizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
@@ -84,6 +85,9 @@ class ProductImageService
}
// 스토리지에 파일 저장 (category: 'images')
// 환경설정 > 업로드의 최대 가로/세로·품질 적용 (코어 설정이 모든 업로드 경로에 동일 적용)
app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType());
$this->storage->put('images', $path, file_get_contents($file->getRealPath()));
// Disk 정보는 스토리지 드라이버에서 가져옴
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Services;
use App\Contracts\Extension\StorageInterface;
use App\Extension\HookManager;
use App\Support\ImageResizer;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
@@ -60,6 +61,9 @@ class ProductReviewImageService
$storedFilename = Str::uuid().'.'.$file->getClientOriginalExtension();
$path = "reviews/{$review->id}/{$storedFilename}";
// 환경설정 > 업로드의 최대 가로/세로·품질 적용 (코어 설정이 모든 업로드 경로에 동일 적용)
app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType());
$this->storage->put('images', $path, file_get_contents($file->getRealPath()));
$disk = $this->storage->getDisk();
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Page\Services;
use App\Contracts\Extension\StorageInterface;
use App\Extension\HookManager;
use App\Support\ImageResizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile;
@@ -95,6 +96,10 @@ class PageAttachmentService
$path = 'temp/'.$tempKey.'/'.$storedFilename;
}
// 환경설정 > 업로드의 최대 가로/세로·품질을 적용한다 (코어 설정이 모든 업로드 경로에 동일 적용).
// 임시 파일을 제자리에서 줄이므로 아래의 저장·크기 계산이 모두 축소본을 본다.
app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType());
// StorageInterface를 통한 파일 저장
$this->storage->put('attachments', $path, file_get_contents($file->getRealPath()));
@@ -15,7 +15,7 @@
- 관리자 화면 좌우 정렬 컨테이너(섹션 헤더 · 액션바 · 카드 상단부 등) 외형을 표준 시맨틱(.flex-between)으로 정리 — 시각 변경 없음, 향후 톤 조정이 한 곳에서 가능.
- 플러그인 환경설정 화면의 하단 저장 버튼이 스크롤 중에도 화면에 고정되도록 개선.
- 플러그인 표준 베이스를 따르도록 내부 구조 정비. 이미지 업로드·서빙 동작은 동일합니다.
- 코어 최소 요구 버전을 7.0.0-beta.8 로 상향.
- 코어 최소 요구 버전을 7.0.6 으로 상향 — 에디터에 올린 이미지에도 환경설정 > 업로드의 크기·품질 제한이 적용되며, 이 기능은 7.0.6 코어에서 제공됩니다.
- 플러그인 환경설정 화면의 폼 라벨 / 보조 설명 / 에러 메시지 시각 시맨틱을 sirsoft-admin_basic 표준 시맨틱과 정합 — 다른 관리자 화면과 같은 결로 통일.
- 플러그인 환경설정 화면 곳곳의 텍스트 톤 (보조 설명 · 라벨 · 본문 · 강조 · 작은 보조) 시각 시맨틱을 관리자 표준 시맨틱과 정합 — 같은 결의 글자 톤이 한 곳에서 일괄 조정 가능.
- 플러그인 환경설정 화면의 세로 정렬 컨테이너 / 입력 박스를 sirsoft-admin_basic 표준 시맨틱 (.stack / .input) 과 정합 — 표준 간격과 외형이 일관 표시되도록 정리.
@@ -11,7 +11,7 @@
"ko": "CKEditor 5를 이용한 WYSIWYG 에디터 플러그인입니다. 플러그인 설치만으로 기존 HtmlEditor가 교체됩니다.",
"en": "WYSIWYG editor plugin using CKEditor 5. Installing this plugin replaces the default HtmlEditor."
},
"g7_version": ">=7.0.0",
"g7_version": ">=7.0.6",
"dependencies": {
"modules": {},
"plugins": {}
@@ -3,6 +3,8 @@
namespace Plugins\Sirsoft\Ckeditor5\Services;
use App\Contracts\Extension\StorageInterface;
use App\Extension\HookManager;
use App\Support\ImageResizer;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
@@ -34,24 +36,27 @@ class ImageUploadService
public function upload(UploadedFile $file, ?int $uploadedBy): Ckeditor5ImageUpload
{
// 훅: 이미지 업로드 전 (본인인증 등 확장 지점)
\App\Extension\HookManager::doAction('sirsoft-ckeditor5.image.before_upload', $file, $uploadedBy);
HookManager::doAction('sirsoft-ckeditor5.image.before_upload', $file, $uploadedBy);
$extension = $file->guessExtension() ?? $file->getClientOriginalExtension();
$storedFilename = Str::uuid().'.'.$extension;
$path = date('Y/m/d').'/'.$storedFilename;
// 환경설정 > 업로드의 최대 가로/세로·품질 적용 (코어 설정이 모든 업로드 경로에 동일 적용)
app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType());
$this->storage->put('images', $path, file_get_contents($file->getRealPath()));
$record = $this->repository->create([
'original_name' => $file->getClientOriginalName(),
'file_path' => "images/{$path}",
'storage_disk' => $this->storage->getDisk(),
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'uploaded_by' => $uploadedBy,
'file_path' => "images/{$path}",
'storage_disk' => $this->storage->getDisk(),
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'uploaded_by' => $uploadedBy,
]);
\App\Extension\HookManager::doAction('sirsoft-ckeditor5.image.after_upload', $record);
HookManager::doAction('sirsoft-ckeditor5.image.after_upload', $record);
return $record;
}
@@ -8,6 +8,7 @@
### Fixed
- 카드결제를 거치지 않은 주문(무통장입금 등)의 완료 화면에서도 영수증 조회를 시도해 불필요한 요청이 나가던 문제를 수정했습니다. 화면에는 영향이 없었으나 이제 카드결제 주문에서만 조회합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- PC 결제창 요청의 최소 결제금액이 100원으로 제한되어, 모바일에서는 성립하는 소액 결제가 PC 에서만 거부되던 문제를 수정했습니다. 이제 PC·모바일이 동일한 기준을 사용합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 주문 목록에서 주문번호를 눌러 상세로 들어갈 때 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-pay_kginicis",
"version": "1.0.1",
"version": "1.0.2",
"components": {
"basic": [],
"composite": [],
@@ -87,4 +87,5 @@
<div class="actions"><button type="button" onclick="window.print()">인쇄</button></div>
</main>
</body>
</html>`),t.document.close()}function E(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")}const D="sirsoft-pay_kginicis",we="__kginicisOrderShowInjectorInstalled",K="kginicis-mp-receipt-row",be=/^(?:\/mypage\/orders\/([^/]+)|\/shop\/guest\/orders\/([^/]+))$/,G=new Map;function Tt(e){try{const n=window.G7Core?.getState,i=n?.()?.currentDataContext?.order?.data;return i&&(i.order_number===e||i.id!==void 0&&String(i.id)===e)?i:null}catch{return null}}function xt(){const e=document.getElementById("order_payment_info_panel");if(e)return Array.from(e.children).find(r=>r.className?.includes("space-y"))??e;const t=Array.from(document.querySelectorAll("h3")).find(r=>{const o=r.textContent?.trim()??"";return o.includes("결제 정보")||/^Payment Information$/i.test(o)});if(!t)return null;const n=t.parentElement?.parentElement;return n?Array.from(n.children).find(r=>r.className?.includes("space-y"))??n:null}function Rt(e,t){if(!t)return!1;const n=Array.from(e.querySelectorAll("div"));for(const r of n){const o=Array.from(r.children).filter(a=>a instanceof HTMLElement&&a.tagName==="SPAN");if(o.length<2)continue;const i=o[0].textContent?.trim();if(i!=="결제 방법"&&i!=="결제수단"&&i!=="Payment Method")continue;const s=o[o.length-1];return s.textContent?.trim()===t?(r.dataset.kginicisPaymentMethodRow="true",!0):(s.textContent=t,s.dataset.kginicisPaymentMethodPatched="true",r.dataset.kginicisPaymentMethodRow="true",!0)}return!1}function Mt(e,t){const n=document.createElement("div");n.id=K,n.className="flex items-center justify-between",n.dataset.kginicisReceiptRow="mypage-order";const r=document.createElement("span");r.className="text-gray-500 dark:text-gray-400 text-sm",r.textContent=vt(t);const o=document.createElement("button");return o.type="button",o.dataset.kginicisReceiptButton="mypage-order",o.className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",o.textContent=A(t),o.addEventListener("click",async()=>{o.disabled=!0,o.textContent="로딩 중...";const i=await v(e);o.disabled=!1,o.textContent=A(i??t),C(i)&&ge(i)}),n.appendChild(r),n.appendChild(o),n}async function Nt(e,t){const n=Tt(e),r=n?.payment;if(r&&(r.pg_provider!=="kginicis"||!r.transaction_id))return!0;const o=n?.order_number??(t?e:null);if(!o)return!1;if(!t&&n&&!r)return!0;const i=xt();if(!i)return!1;const{status:s,info:a}=await he(o);if(!t&&s===404)return!0;const c=r?.payment_status==="paid",u=a?.receipt_type==="cbt_confirmation";if(r&&!c&&!u)return!0;if(!r&&!C(a))return!1;const d=Rt(i,a?.payment_method_display_label);return!document.getElementById(K)&&C(a)&&(i.appendChild(Mt(o,a)),console.info(`[${D}] receipt button injected on mypage order show`)),d&&console.info(`[${D}] payment method display patched on mypage order show`),!!(document.getElementById(K)||d)}function Lt(e,t){if(G.has(e))return;let n=0;const r=window.setInterval(()=>{n++,Nt(e,t).then(o=>{(o||n>=30)&&(window.clearInterval(r),G.delete(e))})},400);G.set(e,r)}function Ot(){const e=location.pathname.match(be);if(e){const t=e[1],n=e[2],r=t??n;r&&Lt(r,n!==void 0)}}function $t(){if(typeof window>"u")return;const e=window;if(e[we])return;e[we]=!0,console.info(`[${D}] mypage order show injector installed`);let t=null;const n=(a=1500)=>{t!==null&&window.clearTimeout(t),t=window.setTimeout(()=>{t=null,Ot()},a)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>n()):n();const r=history.pushState.bind(history);history.pushState=(...a)=>{r(...a),n(600)};const o=history.replaceState.bind(history);history.replaceState=(...a)=>{o(...a),n(600)},window.addEventListener("popstate",()=>n(500));const i=document.getElementById("app")??document.body;new MutationObserver(()=>{be.test(location.pathname)&&n(250)}).observe(i,{childList:!0,subtree:!0})}const j="sirsoft-pay_kginicis",ke="__kginicisAdminPaymentDisplayInjectorInstalled",Dt=/^\/admin\/ecommerce\/orders\/([^/]+)$/;function Kt(){return localStorage.getItem("auth_token")}async function Gt(e){const t=Kt();if(!t)return null;try{const n=await fetch(`/api/plugins/${j}/admin/orders/${e}/transaction-status`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});if(!n.ok)return null;const r=await n.json();return("data"in r?r.data:r)??null}catch{return null}}function jt(e){return Array.from(e.children).filter(t=>t instanceof HTMLElement)}function Yt(e,t){let n=!1;const r=Array.from(e.querySelectorAll("div"));for(const o of r){const i=jt(o),s=i.findIndex(l=>l.textContent?.trim()==="결제수단");if(s<0)continue;const a=i.slice(s+1).find(l=>l.textContent?.trim()&&l.textContent.trim()!=="결제수단");if(!a)continue;const c=i.slice(s+2).find(l=>/^\(.+\)$/.test(l.textContent?.trim()??"")),u=c?.textContent?.trim().replace(/^\(|\)$/g,"")??"",d=u&&t.endsWith(")")?t.replace(/\)$/,`, ${u})`):t;a.textContent?.trim()!==d&&(a.textContent=d,a.dataset.kginicisPaymentMethodPatched="true"),o.dataLine truncated
</html>`),t.document.close()}function E(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")}const D="sirsoft-pay_kginicis",we="__kginicisOrderShowInjectorInstalled",K="kginicis-mp-receipt-row",be=/^(?:\/mypage\/orders\/([^/]+)|\/shop\/guest\/orders\/([^/]+))$/,G=new Map;function Tt(e){try{const n=window.G7Core?.getState,i=n?.()?.currentDataContext?.order?.data;return i&&(i.order_number===e||i.id!==void 0&&String(i.id)===e)?i:null}catch{return null}}function xt(){const e=document.getElementById("order_payment_info_panel");if(e)return Array.from(e.children).find(r=>r.className?.includes("space-y"))??e;const t=Array.from(document.querySelectorAll("h3")).find(r=>{const o=r.textContent?.trim()??"";return o.includes("결제 정보")||/^Payment Information$/i.test(o)});if(!t)return null;const n=t.parentElement?.parentElement;return n?Array.from(n.children).find(r=>r.className?.includes("space-y"))??n:null}function Rt(e,t){if(!t)return!1;const n=Array.from(e.querySelectorAll("div"));for(const r of n){const o=Array.from(r.children).filter(a=>a instanceof HTMLElement&&a.tagName==="SPAN");if(o.length<2)continue;const i=o[0].textContent?.trim();if(i!=="결제 방법"&&i!=="결제수단"&&i!=="Payment Method")continue;const s=o[o.length-1];return s.textContent?.trim()===t?(r.dataset.kginicisPaymentMethodRow="true",!0):(s.textContent=t,s.dataset.kginicisPaymentMethodPatched="true",r.dataset.kginicisPaymentMethodRow="true",!0)}return!1}function Mt(e,t){const n=document.createElement("div");n.id=K,n.className="flex items-center justify-between",n.dataset.kginicisReceiptRow="mypage-order";const r=document.createElement("span");r.className="text-gray-500 dark:text-gray-400 text-sm",r.textContent=vt(t);const o=document.createElement("button");return o.type="button",o.dataset.kginicisReceiptButton="mypage-order",o.className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",o.textContent=A(t),o.addEventListener("click",async()=>{o.disabled=!0,o.textContent="로딩 중...";const i=await v(e);o.disabled=!1,o.textContent=A(i??t),C(i)&&ge(i)}),n.appendChild(r),n.appendChild(o),n}async function Nt(e,t){const n=Tt(e),r=n?.payment;if(r&&(r.pg_provider!=="kginicis"||!r.transaction_id))return!0;const o=n?.order_number??(t?e:null);if(!o)return!1;if(!t&&n&&!r)return!0;const i=xt();if(!i)return!1;const{status:s,info:a}=await he(o);if(!t&&s===404)return!0;const c=r?.payment_status==="paid",u=a?.receipt_type==="cbt_confirmation";if(r&&!c&&!u)return!0;if(!r&&!C(a))return!1;const d=Rt(i,a?.payment_method_display_label);return!document.getElementById(K)&&C(a)&&(i.appendChild(Mt(o,a)),console.info(`[${D}] receipt button injected on mypage order show`)),d&&console.info(`[${D}] payment method display patched on mypage order show`),!!(document.getElementById(K)||d)}function Lt(e,t){if(G.has(e))return;let n=0;const r=window.setInterval(()=>{n++,Nt(e,t).then(o=>{(o||n>=30)&&(window.clearInterval(r),G.delete(e))})},400);G.set(e,r)}function Ot(){const e=location.pathname.match(be);if(e){const t=e[1],n=e[2],r=t??n;r&&Lt(r,n!==void 0)}}function $t(){if(typeof window>"u")return;const e=window;if(e[we])return;e[we]=!0,console.info(`[${D}] mypage order show injector installed`);let t=null;const n=(a=1500)=>{t!==null&&window.clearTimeout(t),t=window.setTimeout(()=>{t=null,Ot()},a)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>n()):n();const r=history.pushState.bind(history);history.pushState=(...a)=>{r(...a),n(600)};const o=history.replaceState.bind(history);history.replaceState=(...a)=>{o(...a),n(600)},window.addEventListener("popstate",()=>n(500));const i=document.getElementById("app")??document.body;new MutationObserver(()=>{be.test(location.pathname)&&n(250)}).observe(i,{childList:!0,subtree:!0})}const j="sirsoft-pay_kginicis",ke="__kginicisAdminPaymentDisplayInjectorInstalled",Dt=/^\/admin\/ecommerce\/orders\/([^/]+)$/;function Kt(){return localStorage.getItem("auth_token")}async function Gt(e){const t=Kt();if(!t)return null;try{const n=await fetch(`/api/plugins/${j}/admin/orders/${e}/transaction-status`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});if(!n.ok)return null;const r=await n.json();return("data"in r?r.data:r)??null}catch{return null}}function jt(e){return Array.from(e.children).filter(t=>t instanceof HTMLElement)}function Yt(e,t){let n=!1;const r=Array.from(e.querySelectorAll("div"));for(const o of r){const i=jt(o),s=i.findIndex(l=>l.textContent?.trim()==="결제수단");if(s<0)continue;const a=i.slice(s+1).find(l=>l.textContent?.trim()&&l.textContent.trim()!=="결제수단");if(!a)continue;const c=i.slice(s+2).find(l=>/^\(.+\)$/.test(l.textContent?.trim()??"")),u=c?.textContent?.trim().replace(/^\(|\)$/g,"")??"",d=u&&t.endsWith(")")?t.replace(/\)$/,`, ${u})`):t;a.textContent?.trim()!==d&&(a.textContent=d,a.dataset.kginicisPaymentMethodPatched="true"),o.dataLine truncated
//# sourceMappingURL=plugin.iife.js.map
@@ -110,6 +110,42 @@ describe('order payment display injectors', () => {
expect(fetchMock.mock.calls[0][1].headers['X-Guest-Order-Token']).toBeUndefined();
});
it('KG이니시스 결제가 아닌 주문에서는 영수증 API 를 아예 호출하지 않는다', async () => {
// 결함: 영수증 조회를 먼저 호출하고 그 다음에 결제수단을 확인해, 무통장입금처럼
// PG 를 거치지 않는 주문에서도 매번 404 요청이 나갔다. 화면에는 영향이 없지만
// 불필요한 왕복이고 콘솔에 오류로 남는다.
vi.useFakeTimers();
localStorage.setItem('auth_token', 'member-token');
history.pushState(null, '', '/shop/orders/ORD-DBANK-1/complete');
document.body.innerHTML = `
<div id="actions">
<button type="button" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white">주문상세</button>
</div>
`;
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
if (String(url).includes('/user/orders/ORD-DBANK-1')) {
return {
ok: true,
status: 200,
// 무통장입금 — PG 미경유이므로 pg_provider 가 없다
json: async () => ({ data: { payment: { pg_provider: null, payment_status: 'pending', transaction_id: null } } }),
} as unknown as Response;
}
return { ok: false, status: 404, json: async () => ({}) } as unknown as Response;
});
globalThis.fetch = fetchMock;
installOrderCompleteReceiptInjector();
await vi.advanceTimersByTimeAsync(1300);
const receiptCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/receipt'));
expect(receiptCalls, 'PG 결제가 아닌데 영수증 API 를 호출했습니다').toHaveLength(0);
expect(document.getElementById('kginicis-oc-receipt-btn')).toBeNull();
});
it('비회원 주문 상세에서 order state 를 못 잡아도 receipt API 응답만으로 영수증 행을 붙인다', async () => {
vi.useFakeTimers();
history.pushState(null, '', '/shop/guest/orders/ORD-GUEST-RECEIPT');
@@ -91,14 +91,19 @@ function patchPaymentMethodDisplay(displayLabel: string | null | undefined): boo
async function injectOnOrderComplete(orderNumber: string): Promise<void> {
if (document.getElementById(BTN_ID)) return;
const receiptInfo = await fetchKginicisReceiptInfo(orderNumber);
if (!canOpenKginicisReceipt(receiptInfo)) return;
// 결제수단을 먼저 확인한다. 영수증 조회를 앞세우면 무통장입금처럼 PG 를 거치지 않는 주문에서도
// 매번 404 요청이 나간다 — 화면에는 영향이 없지만 불필요한 왕복이고 콘솔에 오류로 남는다.
// 결제 정보를 아예 얻지 못하는 경우(비회원 토큰 부재 등)는 종전대로 영수증 응답만으로 판정한다.
const payment = await fetchPayment(orderNumber);
if (payment) {
if (payment.pg_provider !== 'kginicis') return;
if (!payment.transaction_id) return;
}
const receiptInfo = await fetchKginicisReceiptInfo(orderNumber);
if (!canOpenKginicisReceipt(receiptInfo)) return;
if (payment) {
const isPaid = payment.payment_status === 'paid';
const isCbtConfirmation = receiptInfo.receipt_type === 'cbt_confirmation';
if (!isPaid && !isCbtConfirmation) return;
@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed
- 라이브 자격증명 입력칸(라이브 MID·라이브 API 키)에 브라우저 자동완성으로 계정 이메일 등 의도치 않은 값이 자동으로 채워지던 문제를 수정했습니다. 잘못 채워진 값이 저장되어 라이브 본인인증이 실패하는 상황을 방지합니다.
- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
## [1.0.0] - 2026-07-01
@@ -463,8 +463,22 @@
"name": "duplicate_field",
"value": "di",
"checked": "{{(_local.form?.duplicate_field ?? 'di') === 'di'}}",
"className": "radio pointer-events-none"
}
"className": "radio"
},
"actions": [
{
"comment": "키보드 방향키 조작은 click 이 아니라 change 로 온다 — 감싼 Label 의 click 과 같은 값을 저장한다",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form": {
"duplicate_field": "di"
},
"hasChanges": true
}
}
]
},
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-verification_kginicis.settings.duplicate_field.di" }
]
@@ -495,8 +509,22 @@
"name": "duplicate_field",
"value": "ci",
"checked": "{{_local.form?.duplicate_field === 'ci'}}",
"className": "radio pointer-events-none"
}
"className": "radio"
},
"actions": [
{
"comment": "키보드 방향키 조작은 click 이 아니라 change 로 온다 — 감싼 Label 의 click 과 같은 값을 저장한다",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form": {
"duplicate_field": "ci"
},
"hasChanges": true
}
}
]
},
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-verification_kginicis.settings.duplicate_field.ci" }
]
+4
View File
@@ -251,6 +251,10 @@ Route::prefix('auth')->group(function () {
// 로그인 라우트 (세션 생성 필요)
Route::middleware(['throttle:auth-login', 'start.api.session'])->group(function () {
Route::post('login', [UserAuthController::class, 'login'])->name('api.auth.login');
// 2단계 인증 확인 — 비밀번호 단계가 돌려준 challenge 로만 로그인이 완료된다.
// 로그인과 같은 제한을 적용해 코드 대입 시도를 함께 억제한다.
Route::post('login/two-factor', [UserAuthController::class, 'verifyTwoFactor'])
->name('api.auth.login.two-factor');
});
// 공개 인증 라우트 (세션 불필요)
@@ -1488,6 +1488,9 @@
"require_password_special_char": "Require special character",
"require_password_special_char_label": "Require at least one special character",
"require_password_special_char_hint": "When enabled, newly set passwords must contain at least one special character.",
"two_factor_auth": "Two-factor authentication",
"two_factor_auth_label": "Require a verification code at sign-in",
"two_factor_auth_hint": "When enabled, a verification code is emailed after the password check and must be entered to finish signing in.",
"ip_throttle_floor_hint": "The per-IP request limit is six times this value, with a guaranteed floor of 30 requests per minute."
},
"identity": {
@@ -1492,6 +1492,9 @@
"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배로 적용되며, 분당 최소 30회는 항상 보장됩니다."
},
"identity": {
@@ -466,6 +466,41 @@
"text": "$t:admin.settings.security.require_password_special_char_hint"
}
]
},
{
"id": "field_two_factor_auth",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.security.two_factor_auth"
},
{
"type": "basic",
"name": "Checkbox",
"props": {
"name": "security.two_factor_auth",
"disabled": "{{_computed.isReadOnly}}",
"label": "$t:admin.settings.security.two_factor_auth_label"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-hint mt-1"
},
"text": "$t:admin.settings.security.two_factor_auth_hint"
}
]
}
]
}
@@ -28,6 +28,7 @@
- 상품 상세에서 옵션을 선택해 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다.
- 저장이나 삭제가 실패했을 때 아무 안내 없이 조용히 넘어가던 화면들을 수정했습니다. 이제 실패하면 그 이유가 화면에 표시되므로, 반영되지 않은 변경을 성공한 것으로 오해하지 않습니다.
- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 서버가 알려 준 실패 사유가 그대로 표시됩니다.
- 주문서의 무통장입금·가상계좌 입금 기한 안내가 관리자가 설정한 미입금 자동취소 기한과 다르게 표시되던 문제를 수정했습니다. 설정과 무관하게 각각 7일·3일로 고정 안내되어, 자동취소 기한을 3일로 둔 사이트에서 무통장입금 구매자는 "7일 이내"라는 안내를 받고도 3일 뒤 주문이 취소됐습니다. 이제 두 결제수단 모두 설정한 기한을 그대로 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
#### 화면 표시
@@ -0,0 +1,52 @@
/**
* 주문서 입금 기한 안내 — 서버 SSoT 바인딩 계약 테스트 (#493 E1)
*
* 배경: 입금 기한은 결제수단과 무관하게 관리자 환경설정의 미입금 자동취소 기한
* (`order_settings.auto_cancel_days`) 하나만 사용한다. 서버는 가상계좌·무통장입금
* 양쪽 모두 이 값으로 `vbank_due_at` / `deposit_due_at` 을 계산한다
* (`OrderProcessingService`). 클라이언트가 보낸 기한 값은 무시된다.
*
* 결함: 주문서 화면은 서버에 존재하지 않는 `vbank_due_days` / `dbank_due_days` 를
* 참조하고 있었다. 두 키는 설정 응답에 없으므로 표현식이 항상 `undefined` 가 되고
* 하드코딩된 폴백(가상계좌 3, 무통장입금 7)이 그대로 노출됐다.
*
* 실측(2026-07-29): `auto_cancel_days = 3` 인 사이트에서 무통장입금 주문서가
* "입금 기한: 7일 이내" 로 안내했다. 실제로는 3일 뒤 자동취소된다 —
* 안내를 믿은 구매자는 주문이 취소된 뒤에야 알게 된다.
*
* 회귀 차단 포인트:
* 1. 두 안내 모두 `order_settings.auto_cancel_days` 를 참조한다.
* 2. 서버에 없는 `*_due_days` 키를 참조하지 않는다 (되살아나면 폴백으로 조용히 회귀).
* 3. 폴백 값은 서버 기본치(3)와 같다 — 설정 응답이 늦거나 실패해도 안내가 어긋나지 않도록.
*/
import { describe, it, expect } from 'vitest';
import checkoutPayment from '../../layouts/partials/shop/_checkout_payment.json';
const raw = JSON.stringify(checkoutPayment);
/** 서버 SSoT 표현식 — 결제수단 무관 단일 기준 */
const SSOT_EXPRESSION = 'paymentSettings.data?.order_settings?.auto_cancel_days ?? 3';
describe('주문서 입금 기한 안내 — 서버 SSoT 바인딩 (#493 E1)', () => {
it.each([
['가상계좌', 'vbank_due_notice'],
['무통장입금', 'dbank_due_notice'],
])('%s 안내가 auto_cancel_days 를 참조한다', (_label, translationKey) => {
const entry = raw.match(new RegExp(`\\$t:shop\\.checkout\\.${translationKey}\\|days=[^"]*`))?.[0];
expect(entry, `${translationKey} 안내 문구를 찾지 못했습니다`).toBeDefined();
expect(entry).toContain(SSOT_EXPRESSION);
});
it.each([
['vbank_due_days'],
['dbank_due_days'],
])('서버에 없는 %s 키를 참조하지 않는다', (orphanKey) => {
expect(
raw.includes(orphanKey),
`${orphanKey} 는 설정 응답에 존재하지 않는 키입니다 — 표현식이 항상 undefined 가 되어 하드코딩 폴백이 노출됩니다.`
).toBe(false);
});
});
@@ -200,7 +200,7 @@
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-blue-600 dark:text-blue-400" },
"text": "$t:shop.checkout.vbank_due_notice|days={{paymentSettings.data?.order_settings?.vbank_due_days ?? 3}}"
"text": "$t:shop.checkout.vbank_due_notice|days={{paymentSettings.data?.order_settings?.auto_cancel_days ?? 3}}"
},
{
"comment": "입금자명 입력",
@@ -426,7 +426,7 @@
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-amber-600 dark:text-amber-400 mt-3" },
"text": "$t:shop.checkout.dbank_due_notice|days={{paymentSettings.data?.order_settings?.dbank_due_days ?? 7}}"
"text": "$t:shop.checkout.dbank_due_notice|days={{paymentSettings.data?.order_settings?.auto_cancel_days ?? 3}}"
}
]
}
@@ -0,0 +1,52 @@
/**
* 주문서 입금 기한 안내가 관리자 설정(미입금 자동취소 기한)과 일치하는지 검증 (공개 이슈 #81).
*
* 결함: 입금 기한은 결제수단과 무관하게 `order_settings.auto_cancel_days` 하나만 쓰도록
* 서버를 통일했는데, 주문서 화면은 서버에 존재하지 않는 `vbank_due_days`/`dbank_due_days`
* 를 참조하고 있었다. 두 키는 설정 응답에 없으므로 표현식이 항상 undefined 가 되고
* 하드코딩 폴백(가상계좌 3일, 무통장입금 7일)이 그대로 노출됐다.
*
* 실측(2026-07-29): `auto_cancel_days = 3` 인 사이트에서 무통장입금 주문서가
* "입금 기한: 7일 이내" 로 안내했다. 안내를 믿은 구매자는 3일 뒤 주문이 자동취소된 뒤에야
* 알게 된다 — 화면만 봐서는 어긋난 것을 알 방법이 없다.
*
* 단위/정적:
* - templates/_bundled/sirsoft-basic/__tests__/layouts/checkout-deposit-due-days.test.ts
* 가 레이아웃 JSON 의 표현식을 고정하고 고아 키 재등장을 차단
* 이 spec 은 브라우저 수준 — 렌더된 안내 숫자가 설정 응답의 값과 같은지를 담당한다.
*
* @scenario checkout-deposit-due
* @axes method=dbank
* @effects due_notice_matches_auto_cancel_days
*
* e2e:allow 주문서 진입은 장바구니에 담긴 항목이 있어야 해 로그인 계정·시드 상품에 의존한다.
* 현재는 설정 응답값과 레이아웃 표현식의 일치까지를 자동 검증하고, 렌더 확인은
* 장바구니 시드 픽스처 도입 후 활성화한다. 브라우저 실렌더는 2026-07-29 수동
* 실측으로 확인됨("입금 기한: 3일 이내", auto_cancel_days=3).
*/
import { test, expect } from '@playwright/test';
test('#81 - 설정 응답이 입금 기한 SSoT(auto_cancel_days)를 노출한다', async ({ page }) => {
await page.goto('/shop/products');
const orderSettings = await page.evaluate(async () => {
const response = await fetch('/api/modules/sirsoft-ecommerce/settings/payment', {
headers: { Accept: 'application/json' },
});
const body = await response.json();
return (body?.data?.order_settings ?? {}) as Record<string, unknown>;
});
// 화면이 참조하는 키가 응답에 있어야 한다. 없으면 폴백으로 떨어져 설정과 어긋난다.
expect(
Object.keys(orderSettings),
'입금 기한 SSoT 가 결제 설정 응답에 없습니다 — 주문서 안내가 하드코딩 폴백으로 떨어집니다.'
).toContain('auto_cancel_days');
expect(Number(orderSettings.auto_cancel_days)).toBeGreaterThan(0);
// 폐기된 키가 되살아나면 화면이 다시 그 값을 따라가므로 부재를 함께 고정한다.
expect(Object.keys(orderSettings)).not.toContain('dbank_due_days');
expect(Object.keys(orderSettings)).not.toContain('vbank_due_days');
});
+202
View File
@@ -0,0 +1,202 @@
<?php
namespace Tests\Feature\Auth;
use App\Enums\IdentityVerificationPurpose;
use App\Enums\UserStatus;
use App\Models\IdentityVerificationLog;
use App\Models\User;
use App\Services\IdentityVerificationService;
use Database\Seeders\IdentityMessageDefinitionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 로그인 2단계 인증 테스트
*
* `security.two_factor_auth` 는 설정 화면에만 있고 구현이 없어, 켜도 아무 일도 일어나지
* 않았습니다. 관리자는 2단계 인증이 걸린 줄 알지만 실제로는 비밀번호 하나로 로그인됩니다.
*
* 코어 본인인증(IDV) 인프라를 재사용하며, 이 흐름의 불변조건은 하나입니다 —
* **코드 확인을 마치기 전에는 토큰이 발급되지 않는다.**
*/
class TwoFactorAuthTest extends TestCase
{
use RefreshDatabase;
private User $user;
protected function setUp(): void
{
parent::setUp();
Mail::fake();
// 인증번호 메일을 구성하려면 본인인증 메시지 정의가 있어야 한다. 실제 설치에는 있고
// RefreshDatabase 직후에는 비어 있으므로, 없는 상태를 그대로 두면 "코드 발송 실패" 가 되어
// 게이트 동작이 아니라 시드 부재를 측정하게 된다.
$this->seed(IdentityMessageDefinitionSeeder::class);
$this->user = User::factory()->create([
'email' => 'two-factor@test.com',
'password' => Hash::make('Passw0rd!2fa'),
'status' => UserStatus::Active->value,
]);
}
/**
* 2단계 인증 설정을 켜거나 끕니다.
*
* @param bool $enabled 활성화 여부
*/
private function setTwoFactor(bool $enabled): void
{
config(['g7_settings.core.security.two_factor_auth' => $enabled]);
}
/**
* 로그인을 시도합니다.
*
* @return TestResponse 로그인 응답
*/
private function login()
{
return $this->postJson('/api/auth/login', [
'email' => 'two-factor@test.com',
'password' => 'Passw0rd!2fa',
]);
}
#[Test]
public function login_issues_a_token_directly_when_two_factor_is_off(): void
{
$this->setTwoFactor(false);
$response = $this->login();
$response->assertStatus(200);
$this->assertNotEmpty($response->json('data.token'), '2단계 인증이 꺼져 있으면 종전대로 바로 로그인되어야 합니다.');
}
#[Test]
public function login_withholds_the_token_when_two_factor_is_on(): void
{
$this->setTwoFactor(true);
$response = $this->login();
$response->assertStatus(200);
$this->assertTrue(
(bool) $response->json('data.two_factor_required'),
'2단계 인증이 켜져 있는데 추가 확인 없이 응답했습니다.'
);
$this->assertNull(
$response->json('data.token'),
'코드 확인 전에 토큰이 발급되면 2단계 인증이 없는 것과 같습니다.'
);
$this->assertNotEmpty($response->json('data.challenge_id'));
}
#[Test]
public function verifying_the_challenge_completes_the_login(): void
{
$this->setTwoFactor(true);
$challengeId = $this->login()->json('data.challenge_id');
$response = $this->postJson('/api/auth/login/two-factor', [
'challenge_id' => $challengeId,
'code' => $this->issuedCode($challengeId),
]);
$response->assertStatus(200);
$this->assertNotEmpty($response->json('data.token'), '코드 확인에 성공했는데 토큰이 발급되지 않았습니다.');
$this->assertSame('two-factor@test.com', $response->json('data.user.email'));
}
#[Test]
public function a_wrong_code_does_not_issue_a_token(): void
{
$this->setTwoFactor(true);
$challengeId = $this->login()->json('data.challenge_id');
$response = $this->postJson('/api/auth/login/two-factor', [
'challenge_id' => $challengeId,
'code' => '000000',
]);
$response->assertStatus(401);
$this->assertNull($response->json('data.token'));
}
#[Test]
public function a_challenge_issued_for_another_purpose_cannot_be_used_to_log_in(): void
{
$this->setTwoFactor(true);
// 가입·비밀번호 재설정 등 다른 용도로 발급된 challenge 를 들고 와 로그인할 수 있으면,
// 그 흐름 하나만 통과해도 남의 계정에 들어갈 수 있다.
$foreign = app(IdentityVerificationService::class)->start(
IdentityVerificationPurpose::PasswordReset->value,
$this->user,
['origin_type' => 'route', 'origin_identifier' => 'test']
);
$response = $this->postJson('/api/auth/login/two-factor', [
'challenge_id' => $foreign->id,
'code' => $this->issuedCode($foreign->id),
]);
$response->assertStatus(401);
$this->assertNull($response->json('data.token'));
}
#[Test]
public function login_fails_clearly_when_the_code_cannot_be_delivered(): void
{
$this->setTwoFactor(true);
// 메일 발송이 실패하면 프로바이더가 challenge 를 failed 로 표시한다. 그 상태를 무시하면
// 사용자는 "인증번호를 보냈습니다" 안내를 받고 완료할 수 없는 challenge 를 들고 막힌다.
Mail::shouldReceive('send')->andThrow(new \RuntimeException('SMTP unavailable'));
Mail::shouldReceive('to')->andThrow(new \RuntimeException('SMTP unavailable'));
$response = $this->login();
$response->assertStatus(401);
$this->assertNull(
$response->json('data.token'),
'발송 실패 시 2단계 인증을 건너뛰고 로그인시키면 보안 통제가 조용히 열립니다.'
);
}
/**
* challenge 에 알려진 인증 코드를 심고 그 값을 돌려줍니다.
*
* 발급된 코드는 해시로만 저장되므로 되읽을 수 없습니다. 기존 IDV 테스트와 같은 방식으로
* 알려진 값의 해시를 심어 "코드가 맞을 때" 를 재현합니다 — 검증 대상은 코드 생성이 아니라
* 로그인 게이트(코드 확인 전 토큰 미발급 / 확인 후 발급)입니다.
*
* @param string $challengeId challenge UUID
* @return string 심어 둔 인증 코드
*/
private function issuedCode(string $challengeId): string
{
$code = '135790';
$log = IdentityVerificationLog::find($challengeId);
$metadata = $log->metadata ?? [];
$metadata['code_hash'] = Hash::make($code);
$log->metadata = $metadata;
$log->save();
return $code;
}
}
@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature\Identity;
use App\Enums\IdentityVerificationPurpose;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 코어 purpose 계약 정합성 테스트
*
* 코어 purpose 는 세 곳에 동시에 존재해야 온전히 동작합니다.
*
* 1. `IdentityVerificationPurpose` enum — 코드에서 참조하는 값
* 2. `IdentityVerificationManager::$corePurposes` — 관리자 화면·API 가 읽는 목록
* 3. `lang/{ko,en}/identity.php` 의 `purposes.{value}` — 사람이 읽는 라벨
*
* 실측(2026-07-29): 2단계 인증을 도입하며 enum 에 `login` 만 추가하고 2·3 을 빠뜨렸다.
* 그 결과 `hasPurpose('login')` 이 false 였고, `getAllPurposes()` 에서 빠져 관리자가
* 이 목적의 메시지 템플릿·정책을 만들 수 없었으며, `Login->label()` 은 라벨 대신
* `identity.purposes.login.label` 이라는 i18n 키 원문을 그대로 돌려줬다.
*
* 셋 중 하나만 빠져도 조용히 반쪽 동작하므로 값 목록을 하드코딩하지 않고
* enum 을 기준으로 나머지 둘을 전수 대조한다 — 새 purpose 를 추가하면 자동으로 검사 대상이 된다.
*/
class CorePurposeRegistryParityTest extends TestCase
{
#[Test]
public function every_core_purpose_is_registered_in_the_manager(): void
{
$registered = array_keys(app(IdentityVerificationManager::class)->getAllPurposes());
foreach (IdentityVerificationPurpose::values() as $purpose) {
$this->assertContains(
$purpose,
$registered,
"purpose '{$purpose}' 가 corePurposes 레지스트리에 없습니다 — 관리자가 이 목적의 메시지 템플릿·정책을 만들 수 없습니다."
);
}
}
#[Test]
public function every_core_purpose_answers_true_to_has_purpose(): void
{
$manager = app(IdentityVerificationManager::class);
foreach (IdentityVerificationPurpose::values() as $purpose) {
$this->assertTrue(
$manager->hasPurpose($purpose),
"hasPurpose('{$purpose}') 가 false 입니다 — 코어 purpose 인데 등록되지 않은 것으로 취급됩니다."
);
}
}
#[Test]
public function every_core_purpose_resolves_a_label_and_description_in_each_base_locale(): void
{
foreach (['ko', 'en'] as $locale) {
$this->app->setLocale($locale);
foreach (IdentityVerificationPurpose::cases() as $case) {
foreach (['label', 'description'] as $field) {
$key = "identity.purposes.{$case->value}.{$field}";
$translated = __($key);
$this->assertNotSame(
$key,
$translated,
"[{$locale}] {$key} 번역이 없습니다 — 화면에 i18n 키 원문이 그대로 노출됩니다."
);
}
}
}
}
}
@@ -0,0 +1,74 @@
<?php
namespace Tests\Unit\Support;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use Tests\TestCase;
/**
* 업로드 이미지 축소 적용 범위 테스트
*
* 최대 가로/세로·품질은 **코어 환경설정**이므로 특정 화면이 아니라 업로드 전 경로에
* 적용되어야 합니다. 코어 첨부만 고치면 게시판·페이지·상품 이미지는 원본 그대로 저장되어,
* 관리자 입장에서는 "설정을 켰는데 어떤 업로드는 줄고 어떤 업로드는 안 준다" 가 됩니다.
*
* 실측(2026-07-29): 코어 서비스만 적용한 상태에서 게시판 글쓰기로 2400x1800 을 올렸더니
* 2400x1800 그대로 저장됐다 — 게시판 첨부는 코어 AttachmentService 를 거치지 않는다.
*
* 파일을 저장하는 업로드 서비스가 새로 생기면 이 목록에 추가해야 합니다.
*/
class ImageResizeCoverageTest extends TestCase
{
/**
* 업로드 파일을 디스크에 저장하는 서비스 전수.
*
* @param string $relativePath 검사 대상 서비스 경로
*/
#[Test]
#[TestWith(['app/Services/AttachmentService.php'])]
#[TestWith(['app/Services/TemplateLayoutAttachmentService.php'])]
#[TestWith(['modules/_bundled/sirsoft-board/src/Services/AttachmentService.php'])]
#[TestWith(['modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php'])]
#[TestWith(['modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php'])]
#[TestWith(['modules/_bundled/sirsoft-ecommerce/src/Services/CategoryImageService.php'])]
#[TestWith(['modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewImageService.php'])]
#[TestWith(['plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageUploadService.php'])]
public function every_upload_service_applies_the_configured_image_bounds(string $relativePath): void
{
$path = base_path($relativePath);
$this->assertFileExists($path);
$source = $this->stripComments(file_get_contents($path));
$this->assertStringContainsString(
'resizeInPlace',
$source,
"{$relativePath}: 업로드 이미지 축소가 적용되지 않았습니다 — 이 경로로 올린 이미지는 환경설정 상한을 무시합니다."
);
}
/**
* 주석을 제거한 소스를 반환합니다.
*
* 설명 주석에 등장하는 호출 표기가 검사를 통과시키는 것을 막습니다.
*
* @param string $source 원본 소스
* @return string 주석이 제거된 소스
*/
private function stripComments(string $source): string
{
$stripped = '';
foreach (token_get_all($source) as $token) {
if (is_array($token) && in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) {
continue;
}
$stripped .= is_array($token) ? $token[1] : $token;
}
return $stripped;
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace Tests\Unit\Support;
use App\Services\AttachmentService;
use App\Support\ImageResizer;
use Illuminate\Http\UploadedFile;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 업로드 이미지 리사이즈 테스트
*
* 환경설정 > 업로드의 최대 가로/세로 크기와 품질은 저장·검증만 되고 실제로 이미지를 줄이는
* 코드가 없었습니다. 관리자는 값을 바꾸면 적용된다고 인지하지만 원본이 그대로 저장됐습니다.
*/
class ImageResizerTest extends TestCase
{
/**
* 지정 크기의 임시 PNG 를 만듭니다.
*
* @param int $width 가로 픽셀
* @param int $height 세로 픽셀
* @return string 생성된 파일 경로
*/
private function makeImage(int $width, int $height): string
{
$path = tempnam(sys_get_temp_dir(), 'g7img').'.png';
$image = imagecreatetruecolor($width, $height);
imagefill($image, 0, 0, imagecolorallocate($image, 10, 120, 200));
imagepng($image, $path);
imagedestroy($image);
return $path;
}
/**
* 파일의 가로·세로를 반환합니다.
*
* @param string $path 이미지 경로
* @return array{0:int,1:int} [가로, 세로]
*/
private function dimensions(string $path): array
{
$size = getimagesize($path);
return [(int) $size[0], (int) $size[1]];
}
#[Test]
public function it_shrinks_an_image_that_exceeds_the_configured_bounds(): void
{
config(['attachment.image_max_width' => 800, 'attachment.image_max_height' => 600, 'attachment.image_quality' => 80]);
$path = $this->makeImage(1600, 1200);
$this->assertTrue(app(ImageResizer::class)->resizeInPlace($path, 'image/png'));
[$width, $height] = $this->dimensions($path);
$this->assertSame(800, $width);
// 비율 유지 — 1600x1200 을 800 폭에 맞추면 600
$this->assertSame(600, $height);
@unlink($path);
}
#[Test]
public function it_keeps_aspect_ratio_when_only_one_side_exceeds(): void
{
config(['attachment.image_max_width' => 1000, 'attachment.image_max_height' => 1000, 'attachment.image_quality' => 80]);
$path = $this->makeImage(2000, 500);
$this->assertTrue(app(ImageResizer::class)->resizeInPlace($path, 'image/png'));
[$width, $height] = $this->dimensions($path);
$this->assertSame(1000, $width);
$this->assertSame(250, $height);
@unlink($path);
}
#[Test]
public function it_leaves_images_within_bounds_untouched(): void
{
config(['attachment.image_max_width' => 1000, 'attachment.image_max_height' => 1000]);
$path = $this->makeImage(400, 300);
$before = md5_file($path);
$this->assertFalse(
app(ImageResizer::class)->resizeInPlace($path, 'image/png'),
'한계 이내 이미지는 다시 인코딩하지 않아야 합니다 — 불필요한 화질 손실을 만듭니다.'
);
$this->assertSame($before, md5_file($path));
@unlink($path);
}
#[Test]
public function it_does_nothing_when_no_limit_is_configured(): void
{
config(['attachment.image_max_width' => null, 'attachment.image_max_height' => null]);
$path = $this->makeImage(4000, 3000);
$before = md5_file($path);
$this->assertFalse(app(ImageResizer::class)->resizeInPlace($path, 'image/png'));
$this->assertSame($before, md5_file($path));
@unlink($path);
}
#[Test]
public function it_ignores_non_image_files(): void
{
config(['attachment.image_max_width' => 100, 'attachment.image_max_height' => 100]);
$path = tempnam(sys_get_temp_dir(), 'g7doc').'.txt';
file_put_contents($path, str_repeat('a', 500));
$before = md5_file($path);
$this->assertFalse(app(ImageResizer::class)->resizeInPlace($path, 'text/plain'));
$this->assertSame($before, md5_file($path));
@unlink($path);
}
#[Test]
public function upload_applies_the_configured_bounds(): void
{
config(['attachment.image_max_width' => 640, 'attachment.image_max_height' => 640, 'attachment.image_quality' => 75]);
$path = $this->makeImage(1280, 960);
$upload = new UploadedFile($path, 'large.png', 'image/png', null, true);
$attachment = app(AttachmentService::class)->upload($upload);
$this->assertSame(640, $attachment->meta['width'] ?? null, '저장된 첨부의 가로가 설정 상한을 넘습니다 — 리사이즈가 업로드 경로에 적용되지 않았습니다.');
$this->assertSame(480, $attachment->meta['height'] ?? null);
@unlink($path);
}
}