group(function () { // 아래 dual* 매크로는 확장자 형태와 확장자 없는 형태를 동시에 등록한다. // 정적 최적화 블록(location ~* \.(js|css|json)$)이 있는 서버에서 동적 응답이 // nginx 에 가로채이지 않도록 하기 위함. 상세: App\Support\Routing\DualExtensionRoute Route::dualSuffix('{identifier}/routes', 'json', [PublicTemplateController::class, 'getRoutes']) ->name('api.public.templates.routes'); // 템플릿 설정 파일 서빙 (error_config 등) Route::dualSuffix('{identifier}/config', 'json', [PublicTemplateController::class, 'serveConfig']) ->name('api.public.templates.config'); // 레이아웃 편집기 스펙 조회 Route::get('{identifier}/editor-spec', [PublicTemplateController::class, 'serveEditorSpec'])->name('api.public.templates.editor_spec'); // 템플릿 정적 파일 서빙 (확장자 없는 형태는 ?file= 로 경로 수신) Route::dualAsset('assets/{identifier}', [PublicTemplateController::class, 'serveAsset']) ->name('api.public.templates.assets'); // 컴포넌트 정의 파일 서빙 Route::dualSuffix('{identifier}/components', 'json', [PublicTemplateController::class, 'serveComponents']) ->name('api.public.templates.components'); // 다국어 파일 서빙 Route::dualSuffix('{identifier}/lang/{locale}', 'json', [PublicTemplateController::class, 'serveLanguage']) ->where('locale', '[a-z]{2}(-[A-Z]{2})?') ->name('api.public.templates.language'); // 레이아웃 첨부 이미지 파일 서빙 — 발행된 배경 이미지는 // 일반 방문자에게도 로드되어야 하므로 인증 불필요. 첨부는 비공개 디스크에 있어 // 직접 URL 이 없으므로 본 라우트가 캐싱 헤더와 함께 스트림한다. serveFile 이 // 첨부가 경로의 템플릿 소속인지 검증한다. Route::get('{identifier}/layout-attachments/{attachment}/file', [PublicTemplateController::class, 'serveFile']) ->where('attachment', '[0-9]+') ->name('api.public.templates.layout-attachment-file'); }); // 레이아웃 서빙 API (Optional Sanctum 인증 - 토큰 있으면 인증, 없으면 guest) Route::prefix('layouts') ->middleware('optional.sanctum') ->group(function () { // 레이아웃 미리보기 서빙 (토큰 기반, 인증 불필요) // 주의: 일반 레이아웃 서빙보다 먼저 정의 (preview가 templateIdentifier로 매칭되는 것 방지) Route::dualSuffix('preview/{token}', 'json', [LayoutPreviewController::class, 'serve']) ->where('token', '[a-f0-9\-]{36}') ->name('api.public.layouts.preview.serve'); // 주의: dualSuffix 는 확장자 형태를 먼저 등록한다. layoutName 정규식이 `.` 를 // 포함해 greedy 하므로, 확장자 없는 형태가 먼저 등록되면 `.json` 요청까지 삼킨다. Route::dualSuffix('{templateIdentifier}/{layoutName}', 'json', [PublicLayoutController::class, 'serve']) ->where('layoutName', '[a-zA-Z0-9_/\.-]+') ->name('api.public.layouts.serve'); }); // 모듈 에셋 서빙 API Route::prefix('modules')->group(function () { // 활성 모듈 프론트엔드 IIFE/CSS 병합 번들 (개별 assets 라우트보다 위에 명시 등록) // 접미사가 js/css 를 구분하므로 제거 불가 — 세그먼트로 내린다 (bundle/js, bundle/css). Route::dualSuffixSegment('bundle', 'js', [PublicModuleController::class, 'serveBundleJs']) ->name('api.public.modules.bundle.js'); Route::dualSuffixSegment('bundle', 'css', [PublicModuleController::class, 'serveBundleCss']) ->name('api.public.modules.bundle.css'); Route::dualAsset('assets/{identifier}', [PublicModuleController::class, 'serveAsset']) ->name('api.public.modules.assets'); // 레이아웃 편집기 스펙 조회 Route::get('{identifier}/editor-spec', [PublicModuleController::class, 'serveEditorSpec']) ->name('api.public.modules.editor_spec'); // 컴포넌트 정의 파일 서빙 (module:build 산출물) Route::dualSuffix('{identifier}/components', 'json', [PublicModuleController::class, 'serveComponents']) ->name('api.public.modules.components'); }); // 플러그인 에셋 서빙 API Route::prefix('plugins')->group(function () { // 활성 플러그인 프론트엔드 IIFE/CSS 병합 번들 (개별 assets 라우트보다 위에 명시 등록) Route::dualSuffixSegment('bundle', 'js', [PublicPluginController::class, 'serveBundleJs']) ->name('api.public.plugins.bundle.js'); Route::dualSuffixSegment('bundle', 'css', [PublicPluginController::class, 'serveBundleCss']) ->name('api.public.plugins.bundle.css'); Route::dualAsset('assets/{identifier}', [PublicPluginController::class, 'serveAsset']) ->name('api.public.plugins.assets'); // 레이아웃 편집기 스펙 조회 Route::get('{identifier}/editor-spec', [PublicPluginController::class, 'serveEditorSpec']) ->name('api.public.plugins.editor_spec'); // 컴포넌트 정의 파일 서빙 (plugin:build 산출물) Route::dualSuffix('{identifier}/components', 'json', [PublicPluginController::class, 'serveComponents']) ->name('api.public.plugins.components'); }); // 사용자 공개 프로필 API // 게시글 관련 API는 게시판 모듈(sirsoft-board)에서 제공: // - GET /api/modules/sirsoft-board/users/{userId}/posts // - GET /api/modules/sirsoft-board/users/{userId}/posts/stats Route::prefix('users')->group(function () { Route::get('{user}/profile', [PublicProfileController::class, 'show']) ->name('api.public.users.profile'); }); // 활성 로케일 목록 — 언어팩 설치/활성화 직후 셀렉터 즉시 갱신용 Route::get('locales/active', [PublicLocaleController::class, 'active']) ->name('api.public.locales.active'); // 자산 URL 모드 감지 프로브 — 정적 최적화 블록이 확장자 붙은 동적 응답을 // 가로채는지 쌍으로 판정한다. 매크로 자기적용(확장자 형태 + 대조군). // DB 미접근·무인증·no-store. 상세: App\Http\Controllers\Api\Public\AssetProbeController Route::dualSuffix('system/asset-probe', 'js', [AssetProbeController::class, 'probe']) ->name('api.public.system.asset-probe'); }); // 브로드캐스팅 인증 (Sanctum 토큰 사용) Route::middleware(['auth:sanctum'])->post('broadcasting/auth', function (Request $request) { // 웹소켓 사용 OFF 시 채널 인증 거부 (전 계층 SSoT — 공개#50). // 변경 1(reverb.key 무력화)을 우회한 직접 연결 시도까지 차단한다. // broadcasting.default 는 applyWebsocketConfig 와 동일 SSoT. if (config('broadcasting.default') === 'null') { abort(403); } return Broadcast::auth($request); })->name('api.broadcasting.auth'); // 본인인증 (IdentityVerification) 공개 엔드포인트 // challenge 라우트 파라미터를 IdentityVerificationLog 모델로 자동 resolve — PermissionMiddleware 의 owner_key='user_id' scope 매칭 표준 메커니즘 활용 Route::model('challenge', IdentityVerificationLog::class); Route::prefix('identity')->group(function () { Route::get('providers', [IdentityVerificationController::class, 'providers']) ->name('api.identity.providers.index'); Route::get('purposes', [IdentityVerificationController::class, 'purposes']) ->name('api.identity.purposes.index'); Route::get('policies/resolve', [IdentityVerificationController::class, 'resolvePolicy']) ->middleware('optional.sanctum') ->name('api.identity.policies.resolve'); // Challenge 요청 / 검증 / 취소는 로그인 상태와 비로그인 상태(Mode B 가입) 모두 허용 — guest 역할에 IDV 권한 부여 // verify/cancel 은 로그인 사용자에 한해 PermissionMiddleware 의 scope=self 가드가 challenge.user_id 일치 여부 자동 검증 // request 한도는 정상 회원가입 흐름(모달 만료 후 재전송 / 같은 NAT IP 게스트 다회 가입 시도)을 차단하지 않도록 // verify(10) 보다 넉넉히 두고 cancel/show(30) 와 동일 수준 적용 — 게스트 IP 단일 키 공유 환경 대응 Route::middleware(['throttle:30,1', 'optional.sanctum', 'permission:user,core.identity.request']) ->post('challenges', [IdentityVerificationController::class, 'request']) ->name('api.identity.challenges.request'); Route::middleware(['throttle:10,1', 'optional.sanctum', 'permission:user,core.identity.verify']) ->post('challenges/{challenge}/verify', [IdentityVerificationController::class, 'verify']) ->name('api.identity.challenges.verify'); Route::middleware(['throttle:30,1', 'optional.sanctum', 'permission:user,core.identity.cancel']) ->post('challenges/{challenge}/cancel', [IdentityVerificationController::class, 'cancel']) ->name('api.identity.challenges.cancel'); // 비동기 검증 인프라 (engine-v1.46.0+) — Stripe Identity / 토스인증 push / 외부 redirect 콜백 지원 // 폴링 엔드포인트는 권한 가드 없음 (공개 안전 필드만 노출 — Service::getStatus 참조) Route::middleware(['throttle:30,1', 'optional.sanctum']) ->get('challenges/{challenge}', [IdentityVerificationController::class, 'show']) ->name('api.identity.challenges.show'); Route::middleware(['throttle:30,1', 'optional.sanctum']) ->post('callback/{providerId}', [IdentityVerificationController::class, 'callback']) ->name('api.identity.callback'); }); // 인증 관련 라우트 (인증 불필요, 속도 제한 없음 - 공개 API) // start.api.session: 로그인 시 세션 생성 (/dev 대시보드 인증용) // throttle:auth-login: per-IP brute-force 백업 방어 (보안 환경설정의 per-account 잠금과 2중 방어) 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'); // 인증번호 재발송 — 기존 challenge 를 취소하고 새로 발행한다. Route::post('login/two-factor/resend', [UserAuthController::class, 'resendTwoFactor']) ->name('api.auth.login.two-factor.resend'); }); // 공개 인증 라우트 (세션 불필요) // IDV 정책은 bootstrap/app.php 의 자동 매핑 미들웨어가 라우트 이름 기반으로 enforce. // 정책 DB 토글만으로 즉시 효과 — 라우트 코드 수정 불필요. 외부 모듈/플러그인이 자기 정책 키를 // 강제하고 싶을 때만 ->middleware('identity.policy:KEY') 를 명시 사용. Route::post('register', [UserAuthController::class, 'register']) ->name('api.auth.register'); Route::post('forgot-password', [UserAuthController::class, 'forgotPassword'])->name('api.auth.forgot-password'); Route::post('reset-password', [UserAuthController::class, 'resetPassword']) ->name('api.auth.reset-password'); Route::post('validate-reset-token', [UserAuthController::class, 'validateResetToken'])->name('api.auth.validate-reset-token'); // 일반 사용자 인증 (인증 필요 - 공용 경로) Route::middleware(['auth:sanctum', 'check.user_status'])->group(function () { Route::get('user', [UserAuthController::class, 'user'])->name('api.auth.user'); Route::post('logout', [UserAuthController::class, 'logout'])->middleware('start.api.session')->name('api.auth.logout'); }); // 관리자 인증 (로그인: 세션 생성 필요 + per-IP throttle) Route::prefix('admin')->group(function () { Route::post('login', [AdminAuthController::class, 'login']) ->middleware(['throttle:auth-login', 'start.api.session']) ->name('api.auth.admin.login'); // 관리자 2단계 인증 확인·재발송 — 사용자 경로와 같은 제한을 적용한다. Route::post('login/two-factor', [AdminAuthController::class, 'verifyTwoFactor']) ->middleware(['throttle:auth-login', 'start.api.session']) ->name('api.auth.admin.login.two-factor'); Route::post('login/two-factor/resend', [AdminAuthController::class, 'resendTwoFactor']) ->middleware(['throttle:auth-login', 'start.api.session']) ->name('api.auth.admin.login.two-factor.resend'); }); }); // 사용자 API (권한 기반 인증, 속도 제한 적용) // optional.sanctum: Bearer 토큰이 있으면 인증, 없으면 guest로 통과 Route::prefix('user')->middleware(['optional.sanctum', 'check.user_status', 'throttle:'.config('auth.throttle.user'), RefreshTokenExpiration::class])->group(function () { // 사용자 인증 Route::prefix('auth')->group(function () { Route::post('logout', [UserAuthController::class, 'logout']) ->middleware(['start.api.session', 'permission:user,core.auth.logout']) ->name('api.user.auth.logout'); Route::post('logout-all-devices', [UserAuthController::class, 'logoutFromAllDevices']) ->middleware('permission:user,core.auth.logout') ->name('api.user.auth.logout-all-devices'); Route::get('user', [UserAuthController::class, 'user']) ->middleware('permission:user,core.auth.user') ->name('api.user.auth.user'); Route::post('refresh', [UserAuthController::class, 'refresh']) ->middleware('permission:user,core.auth.refresh') ->name('api.user.auth.refresh'); }); // 사용자 알림 Route::prefix('notifications')->group(function () { Route::get('/', [UserNotificationController::class, 'index']) ->middleware('permission:user,core.user-notifications.read') ->name('api.user.notifications.index'); Route::get('unread-count', [UserNotificationController::class, 'unreadCount']) ->middleware('permission:user,core.user-notifications.read') ->name('api.user.notifications.unread-count'); Route::patch('{notification}/read', [UserNotificationController::class, 'markAsRead']) ->middleware('permission:user,core.user-notifications.update') ->name('api.user.notifications.read'); Route::post('read-batch', [UserNotificationController::class, 'markBatchAsRead']) ->middleware('permission:user,core.user-notifications.update') ->name('api.user.notifications.read-batch'); Route::post('read-all', [UserNotificationController::class, 'markAllAsRead']) ->middleware('permission:user,core.user-notifications.update') ->name('api.user.notifications.read-all'); Route::delete('all', [UserNotificationController::class, 'destroyAll']) ->middleware('permission:user,core.user-notifications.delete') ->name('api.user.notifications.destroy-all'); Route::delete('{notification}', [UserNotificationController::class, 'destroy']) ->middleware('permission:user,core.user-notifications.delete') ->name('api.user.notifications.destroy'); }); // 사용자 프로필 Route::prefix('profile')->group(function () { Route::get('/', [UserProfileController::class, 'show']) ->middleware('permission:user,core.profile.read') ->name('api.user.profile.show'); Route::put('/', [UserProfileController::class, 'update']) ->middleware('permission:user,core.profile.update') ->name('api.user.profile.update'); Route::post('update-language', [UserProfileController::class, 'updateLanguage']) ->name('api.user.profile.update-language'); Route::get('activity-log', [UserProfileController::class, 'activityLog']) ->middleware('permission:user,core.profile.read') ->name('api.user.profile.activity-log'); }); }); // 마이페이지 API (/api/me) - 프로필 관리용 단축 엔드포인트 Route::prefix('me')->middleware(['auth:sanctum', 'check.user_status', 'throttle:'.config('auth.throttle.user'), RefreshTokenExpiration::class])->group(function () { Route::get('/', [UserProfileController::class, 'show'])->name('api.me.show'); Route::put('/', [UserProfileController::class, 'update'])->name('api.me.update'); Route::delete('/', [UserProfileController::class, 'destroy'])->name('api.me.destroy'); Route::post('avatar', [UserProfileController::class, 'uploadAvatar'])->name('api.me.avatar.upload'); Route::delete('avatar', [UserProfileController::class, 'deleteAvatar'])->name('api.me.avatar.delete'); Route::post('verify-password', [UserProfileController::class, 'verifyPassword'])->name('api.me.verify-password'); Route::put('password', [UserProfileController::class, 'changePassword'])->name('api.me.password'); }); // 첨부파일 다운로드 (공개 - 권한 정책에 따라 접근 제어) Route::get('attachment/{hash}', [PublicAttachmentController::class, 'download']) ->where('hash', '[a-zA-Z0-9]{12}') ->name('api.attachment.download'); // 통합 검색 API (공개 — Bearer 토큰이 있으면 회원으로 해석해 게시판별 열람 권한을 // 검색 결과·available_boards 에 반영한다. 미들웨어가 없으면 $request->user() 가 // 항상 null 이라 인증 회원도 guest 수준으로 필터된다.) Route::get('search', [PublicSearchController::class, 'search'])->middleware('optional.sanctum')->name('api.search'); // 관리자 API (인증 + 관리자 권한 필요, 속도 제한 적용) Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin', 'throttle:'.config('auth.throttle.admin')])->group(function () { // 관리자 인증 Route::prefix('auth')->group(function () { Route::post('logout', [AdminAuthController::class, 'logout'])->middleware('start.api.session')->name('api.admin.auth.logout'); Route::get('user', [AdminAuthController::class, 'user'])->name('api.admin.auth.user'); Route::post('refresh', [AdminAuthController::class, 'refresh'])->name('api.admin.auth.refresh'); }); // IDV 관리자 라우트 (identity 정책/로그/프로바이더) Route::prefix('identity')->group(function () { Route::get('providers', [AdminIdentityProviderController::class, 'index']) ->middleware('permission:admin,core.admin.identity.providers.read') ->name('api.admin.identity.providers.index'); Route::get('logs', [AdminIdentityLogController::class, 'index']) ->middleware('permission:admin,core.admin.identity.logs.read') ->name('api.admin.identity.logs.index'); Route::post('logs/purge', [AdminIdentityLogController::class, 'purge']) ->middleware('permission:admin,core.admin.identity.logs.purge') ->name('api.admin.identity.logs.purge'); Route::prefix('policies')->group(function () { Route::get('/', [AdminIdentityPolicyController::class, 'index']) ->middleware('permission:admin,core.admin.identity.policies.read') ->name('api.admin.identity.policies.index'); Route::post('/', [AdminIdentityPolicyController::class, 'store']) ->middleware('permission:admin,core.admin.identity.policies.update') ->name('api.admin.identity.policies.store'); Route::put('{id}', [AdminIdentityPolicyController::class, 'update']) ->middleware('permission:admin,core.admin.identity.policies.update') ->name('api.admin.identity.policies.update'); Route::delete('{id}', [AdminIdentityPolicyController::class, 'destroy']) ->middleware('permission:admin,core.admin.identity.policies.update') ->name('api.admin.identity.policies.destroy'); Route::post('{id}/reset-field', [AdminIdentityPolicyController::class, 'resetField']) ->middleware('permission:admin,core.admin.identity.policies.update') ->name('api.admin.identity.policies.reset-field'); }); // IDV 메시지 정의/템플릿 관리 Route::prefix('messages')->group(function () { Route::get('definitions', [AdminIdentityMessageDefinitionController::class, 'index']) ->middleware('permission:admin,core.admin.identity.messages.read') ->name('api.admin.identity.messages.definitions.index'); Route::post('definitions', [AdminIdentityMessageDefinitionController::class, 'store']) ->middleware('permission:admin,core.admin.identity.messages.update') ->name('api.admin.identity.messages.definitions.store'); Route::get('definitions/{definition}', [AdminIdentityMessageDefinitionController::class, 'show']) ->middleware('permission:admin,core.admin.identity.messages.read') ->name('api.admin.identity.messages.definitions.show'); Route::patch('definitions/{definition}', [AdminIdentityMessageDefinitionController::class, 'update']) ->middleware('permission:admin,core.admin.identity.messages.update') ->name('api.admin.identity.messages.definitions.update'); Route::delete('definitions/{definition}', [AdminIdentityMessageDefinitionController::class, 'destroy']) ->middleware('permission:admin,core.admin.identity.messages.update') ->name('api.admin.identity.messages.definitions.destroy'); Route::patch('definitions/{definition}/toggle-active', [AdminIdentityMessageDefinitionController::class, 'toggleActive']) ->middleware('permission:admin,core.admin.identity.messages.update') ->name('api.admin.identity.messages.definitions.toggle-active'); Route::post('definitions/{definition}/reset', [AdminIdentityMessageDefinitionController::class, 'reset']) ->middleware('permission:admin,core.admin.identity.messages.update') ->name('api.admin.identity.messages.definitions.reset'); Route::patch('templates/{template}', [AdminIdentityMessageTemplateController::class, 'update']) ->middleware('permission:admin,core.admin.identity.messages.update') ->name('api.admin.identity.messages.templates.update'); Route::patch('templates/{template}/toggle-active', [AdminIdentityMessageTemplateController::class, 'toggleActive']) ->middleware('permission:admin,core.admin.identity.messages.update') ->name('api.admin.identity.messages.templates.toggle-active'); Route::post('templates/{template}/reset', [AdminIdentityMessageTemplateController::class, 'reset']) ->middleware('permission:admin,core.admin.identity.messages.update') ->name('api.admin.identity.messages.templates.reset'); Route::post('templates/preview', [AdminIdentityMessageTemplateController::class, 'preview']) ->middleware('permission:admin,core.admin.identity.messages.read') ->name('api.admin.identity.messages.templates.preview'); }); }); // 관리자 알림 Route::prefix('notifications')->group(function () { Route::get('/', [AdminNotificationController::class, 'index']) ->middleware('permission:admin,core.notifications.read') ->name('api.admin.notifications.index'); Route::get('unread-count', [AdminNotificationController::class, 'unreadCount']) ->middleware('permission:admin,core.notifications.read') ->name('api.admin.notifications.unread-count'); Route::post('read-batch', [AdminNotificationController::class, 'markBatchAsRead']) ->middleware('permission:admin,core.notifications.update') ->name('api.admin.notifications.read-batch'); Route::post('read-all', [AdminNotificationController::class, 'markAllAsRead']) ->middleware('permission:admin,core.notifications.update') ->name('api.admin.notifications.read-all'); Route::patch('{notification}/read', [AdminNotificationController::class, 'markAsRead']) ->middleware('permission:admin,core.notifications.update') ->name('api.admin.notifications.read'); Route::delete('all', [AdminNotificationController::class, 'destroyAll']) ->middleware('permission:admin,core.notifications.delete') ->name('api.admin.notifications.destroy-all'); Route::delete('{notification}', [AdminNotificationController::class, 'destroy']) ->middleware('permission:admin,core.notifications.delete') ->name('api.admin.notifications.destroy'); }); // 대시보드 API Route::prefix('dashboard')->group(function () { Route::get('stats', [AdminDashboardController::class, 'stats']) ->middleware('permission:admin,core.dashboard.read') ->name('api.admin.dashboard.stats'); Route::get('resources', [AdminDashboardController::class, 'resources']) ->middleware('permission:admin,core.dashboard.read') ->name('api.admin.dashboard.resources'); Route::get('activities', [AdminDashboardController::class, 'activities']) ->middleware('permission:admin,core.dashboard.activities') ->name('api.admin.dashboard.activities'); Route::get('alerts', [AdminDashboardController::class, 'alerts']) ->middleware('permission:admin,core.dashboard.read') ->name('api.admin.dashboard.alerts'); Route::get('recent-notifications', [AdminDashboardController::class, 'recentNotifications']) ->middleware('permission:admin,core.notification-logs.read') ->name('api.admin.dashboard.recent-notifications'); }); // 코어 라이선스 Route::get('license', [AdminLicenseController::class, 'core'])->name('api.admin.license'); Route::get('changelog', [AdminLicenseController::class, 'changelog'])->name('api.admin.changelog'); // 현재 사용자 정보 업데이트 Route::patch('users/me/language', [AdminUserController::class, 'updateMyLanguage'])->name('api.admin.users.me.language'); // 언어팩 관리 Route::prefix('language-packs')->group(function () { Route::get('/', [AdminLanguagePackController::class, 'index']) ->middleware('permission:admin,core.language_packs.read') ->name('api.admin.language-packs.index'); Route::post('check-updates', [AdminLanguagePackController::class, 'checkUpdates']) ->middleware('permission:admin,core.language_packs.update') ->name('api.admin.language-packs.check-updates'); Route::post('refresh-cache', [AdminLanguagePackController::class, 'refreshCache']) ->middleware('permission:admin,core.language_packs.manage') ->name('api.admin.language-packs.refresh-cache'); Route::post('manifest-preview', [AdminLanguagePackController::class, 'manifestPreview']) ->middleware('permission:admin,core.language_packs.install') ->name('api.admin.language-packs.manifest-preview'); Route::post('install-from-file', [AdminLanguagePackController::class, 'installFromFile']) ->middleware('permission:admin,core.language_packs.install') ->name('api.admin.language-packs.install-from-file'); Route::post('install-from-github', [AdminLanguagePackController::class, 'installFromGithub']) ->middleware('permission:admin,core.language_packs.install') ->name('api.admin.language-packs.install-from-github'); Route::post('install-from-url', [AdminLanguagePackController::class, 'installFromUrl']) ->middleware('permission:admin,core.language_packs.install') ->name('api.admin.language-packs.install-from-url'); Route::post('install-from-bundled', [AdminLanguagePackController::class, 'installFromBundled']) ->middleware('permission:admin,core.language_packs.install') ->name('api.admin.language-packs.install-from-bundled'); // 요구사항 #7: 호스트 확장 재활성화 시 cascade 비활성화됐던 언어팩 일괄 활성화 Route::post('bulk-activate', [AdminLanguagePackController::class, 'bulkActivate']) ->middleware('permission:admin,core.language_packs.manage') ->name('api.admin.language-packs.bulk-activate'); // 미설치 번들 행은 DB id 가 없으므로 정수/문자열(번들 식별자) 모두 수용 Route::get('{id}', [AdminLanguagePackController::class, 'show']) ->where('id', '[A-Za-z0-9_\-]+') ->middleware('permission:admin,core.language_packs.read') ->name('api.admin.language-packs.show'); Route::get('{id}/changelog', [AdminLanguagePackController::class, 'changelog']) ->where('id', '[A-Za-z0-9_\-]+') ->middleware('permission:admin,core.language_packs.read') ->name('api.admin.language-packs.changelog'); Route::post('{id}/activate', [AdminLanguagePackController::class, 'activate']) ->whereNumber('id') ->middleware('permission:admin,core.language_packs.manage') ->name('api.admin.language-packs.activate'); Route::post('{id}/deactivate', [AdminLanguagePackController::class, 'deactivate']) ->whereNumber('id') ->middleware('permission:admin,core.language_packs.manage') ->name('api.admin.language-packs.deactivate'); Route::post('{id}/update', [AdminLanguagePackController::class, 'performUpdate']) ->whereNumber('id') ->middleware('permission:admin,core.language_packs.update') ->name('api.admin.language-packs.update'); Route::delete('{id}', [AdminLanguagePackController::class, 'uninstall']) ->whereNumber('id') ->middleware('permission:admin,core.language_packs.manage') ->name('api.admin.language-packs.uninstall'); }); // 모듈 관리 Route::prefix('modules')->group(function () { Route::get('/', [AdminModuleController::class, 'index'])->middleware('permission:admin,core.modules.read|core.menus.read,false')->name('api.admin.modules.index'); Route::get('installed', [AdminModuleController::class, 'installed'])->name('api.admin.modules.installed'); Route::get('uninstalled', [AdminModuleController::class, 'uninstalled'])->middleware('permission:admin,core.modules.read')->name('api.admin.modules.uninstalled'); Route::get('{moduleName}', [AdminModuleController::class, 'show'])->middleware('permission:admin,core.modules.read')->name('api.admin.modules.show'); Route::get('{identifier}/dependent-templates', [AdminModuleController::class, 'dependentTemplates'])->middleware('permission:admin,core.modules.read')->name('api.admin.modules.dependent-templates'); Route::get('{identifier}/changelog', [AdminModuleController::class, 'changelog'])->middleware('permission:admin,core.modules.read')->name('api.admin.modules.changelog'); Route::get('{identifier}/license', [AdminModuleController::class, 'license'])->middleware('permission:admin,core.modules.read')->name('api.admin.modules.license'); Route::get('{moduleName}/uninstall-info', [AdminModuleController::class, 'uninstallInfo'])->middleware('permission:admin,core.modules.uninstall')->name('api.admin.modules.uninstall-info'); Route::get('{moduleName}/install-preview', [AdminModuleController::class, 'installPreview'])->middleware('permission:admin,core.modules.install')->name('api.admin.modules.install-preview'); Route::post('install', [AdminModuleController::class, 'install'])->middleware('permission:admin,core.modules.install')->name('api.admin.modules.install'); Route::post('install-from-file', [AdminModuleController::class, 'installFromFile'])->middleware('permission:admin,core.modules.install')->name('api.admin.modules.install-from-file'); Route::post('manifest-preview', [AdminModuleController::class, 'manifestPreview'])->middleware('permission:admin,core.modules.install')->name('api.admin.modules.manifest-preview'); Route::post('install-from-github', [AdminModuleController::class, 'installFromGithub'])->middleware('permission:admin,core.modules.install')->name('api.admin.modules.install-from-github'); Route::post('activate', [AdminModuleController::class, 'activate'])->middleware('permission:admin,core.modules.activate')->name('api.admin.modules.activate'); Route::post('deactivate', [AdminModuleController::class, 'deactivate'])->middleware('permission:admin,core.modules.activate')->name('api.admin.modules.deactivate'); Route::post('check-updates', [AdminModuleController::class, 'checkUpdates'])->middleware('permission:admin,core.modules.install')->name('api.admin.modules.check-updates'); Route::get('{moduleName}/check-modified-layouts', [AdminModuleController::class, 'checkModifiedLayouts'])->middleware('permission:admin,core.modules.read')->name('api.admin.modules.check-modified-layouts'); Route::post('{moduleName}/update', [AdminModuleController::class, 'performUpdate'])->middleware('permission:admin,core.modules.install')->name('api.admin.modules.update'); Route::post('refresh-layouts', [AdminModuleController::class, 'refreshLayouts'])->middleware('permission:admin,core.modules.activate')->name('api.admin.modules.refresh-layouts'); Route::delete('uninstall', [AdminModuleController::class, 'uninstall'])->middleware('permission:admin,core.modules.uninstall')->name('api.admin.modules.uninstall'); }); // 플러그인 관리 Route::prefix('plugins')->group(function () { Route::get('/', [AdminPluginController::class, 'index'])->middleware('permission:admin,core.plugins.read')->name('api.admin.plugins.index'); Route::get('installed', [AdminPluginController::class, 'installed'])->name('api.admin.plugins.installed'); Route::get('{pluginName}', [AdminPluginController::class, 'show'])->middleware('permission:admin,core.plugins.read')->name('api.admin.plugins.show'); Route::get('{identifier}/dependent-templates', [AdminPluginController::class, 'dependentTemplates'])->middleware('permission:admin,core.plugins.read')->name('api.admin.plugins.dependent-templates'); Route::get('{identifier}/changelog', [AdminPluginController::class, 'changelog'])->middleware('permission:admin,core.plugins.read')->name('api.admin.plugins.changelog'); Route::get('{identifier}/license', [AdminPluginController::class, 'license'])->middleware('permission:admin,core.plugins.read')->name('api.admin.plugins.license'); Route::get('{pluginName}/install-preview', [AdminPluginController::class, 'installPreview'])->middleware('permission:admin,core.plugins.install')->name('api.admin.plugins.install-preview'); Route::get('{pluginName}/uninstall-info', [AdminPluginController::class, 'uninstallInfo'])->middleware('permission:admin,core.plugins.uninstall')->name('api.admin.plugins.uninstall-info'); Route::post('install', [AdminPluginController::class, 'install'])->middleware('permission:admin,core.plugins.install')->name('api.admin.plugins.install'); Route::post('install-from-file', [AdminPluginController::class, 'installFromFile'])->middleware('permission:admin,core.plugins.install')->name('api.admin.plugins.install-from-file'); Route::post('manifest-preview', [AdminPluginController::class, 'manifestPreview'])->middleware('permission:admin,core.plugins.install')->name('api.admin.plugins.manifest-preview'); Route::post('install-from-github', [AdminPluginController::class, 'installFromGithub'])->middleware('permission:admin,core.plugins.install')->name('api.admin.plugins.install-from-github'); Route::post('activate', [AdminPluginController::class, 'activate'])->middleware('permission:admin,core.plugins.activate')->name('api.admin.plugins.activate'); Route::post('deactivate', [AdminPluginController::class, 'deactivate'])->middleware('permission:admin,core.plugins.activate')->name('api.admin.plugins.deactivate'); Route::post('check-updates', [AdminPluginController::class, 'checkUpdates'])->middleware('permission:admin,core.plugins.install')->name('api.admin.plugins.check-updates'); Route::get('{pluginName}/check-modified-layouts', [AdminPluginController::class, 'checkModifiedLayouts'])->middleware('permission:admin,core.plugins.read')->name('api.admin.plugins.check-modified-layouts'); Route::post('{pluginName}/update', [AdminPluginController::class, 'performUpdate'])->middleware('permission:admin,core.plugins.install')->name('api.admin.plugins.update'); Route::post('refresh-layouts', [AdminPluginController::class, 'refreshLayouts'])->middleware('permission:admin,core.plugins.activate')->name('api.admin.plugins.refresh-layouts'); Route::delete('uninstall', [AdminPluginController::class, 'uninstall'])->middleware('permission:admin,core.plugins.uninstall')->name('api.admin.plugins.uninstall'); // 플러그인 설정 Route::get('{identifier}/settings', [AdminPluginSettingsController::class, 'show'])->middleware('permission:admin,core.plugins.read')->name('api.admin.plugins.settings.show'); Route::put('{identifier}/settings', [AdminPluginSettingsController::class, 'update'])->middleware('permission:admin,core.plugins.update')->name('api.admin.plugins.settings.update'); Route::get('{identifier}/settings/layout', [AdminPluginSettingsController::class, 'layout'])->middleware('permission:admin,core.plugins.read')->name('api.admin.plugins.settings.layout'); }); // 확장 호환성 복구/조회/dismiss (코어 버전 비호환 자동 비활성화 → 원클릭 복구) Route::prefix('extensions')->group(function () { Route::get('auto-deactivated', [AdminExtensionRecoveryController::class, 'autoDeactivated']) ->middleware('permission:admin,core.plugins.activate') ->name('api.admin.extensions.auto-deactivated'); Route::post('{type}/{identifier}/recover', [AdminExtensionRecoveryController::class, 'recover']) ->where('type', 'plugin|module|template') ->middleware('permission:admin,core.plugins.activate') ->name('api.admin.extensions.recover'); Route::post('{type}/{identifier}/dismiss', [AdminExtensionRecoveryController::class, 'dismiss']) ->where('type', 'plugin|module|template') ->middleware('permission:admin,core.plugins.activate') ->name('api.admin.extensions.dismiss'); // 사용자 추가 에셋(`custom/`) 관리 — 모듈·플러그인·템플릿 공통. // // 타입별로 나누면 같은 검증·문서·테스트가 세 벌로 갈리고, 그중 하나만 약해지면 // 그 경로가 조용한 우회로가 된다. 권한도 하나(`core.extensions.custom_assets.manage`)다 — // 쪼개면 운영자가 셋을 다 부여해야 하고 "모듈 CSS 는 되는데 템플릿 CSS 는 안 되는" // 상태가 실질적 의미 없이 생긴다. // // 레이아웃 편집 권한과는 분리한다: 여기서 올린 스크립트는 그 레이아웃 한 장이 아니라 // 사이트 전 화면에서 실행되므로, 레이아웃을 고칠 수 있다는 것이 곧 그 권한이 될 수 없다. Route::get('{type}/{identifier}/custom-assets', [AdminExtensionCustomAssetController::class, 'index']) ->where('type', 'plugin|module|template') ->middleware('permission:admin,core.extensions.custom_assets.manage') ->name('api.admin.extensions.custom-assets.index'); Route::get('{type}/{identifier}/custom-assets/content', [AdminExtensionCustomAssetController::class, 'show']) ->where('type', 'plugin|module|template') ->middleware('permission:admin,core.extensions.custom_assets.manage') ->name('api.admin.extensions.custom-assets.show'); Route::put('{type}/{identifier}/custom-assets/content', [AdminExtensionCustomAssetController::class, 'store']) ->where('type', 'plugin|module|template') ->middleware('permission:admin,core.extensions.custom_assets.manage') ->name('api.admin.extensions.custom-assets.store'); Route::post('{type}/{identifier}/custom-assets/upload', [AdminExtensionCustomAssetController::class, 'upload']) ->where('type', 'plugin|module|template') ->middleware('permission:admin,core.extensions.custom_assets.manage') ->name('api.admin.extensions.custom-assets.upload'); Route::delete('{type}/{identifier}/custom-assets', [AdminExtensionCustomAssetController::class, 'destroy']) ->where('type', 'plugin|module|template') ->middleware('permission:admin,core.extensions.custom_assets.manage') ->name('api.admin.extensions.custom-assets.destroy'); }); // 환경설정 관리 Route::prefix('settings')->group(function () { Route::get('/', [AdminSettingsController::class, 'index'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.index'); Route::post('/', [AdminSettingsController::class, 'store'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.store'); Route::get('system-info', [AdminSettingsController::class, 'systemInfo'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.system-info'); // 읽기 전용 진단 (#124). 값 편집 엔드포인트는 두지 않는다 — 편집은 .env 전용. // `{key}` 와일드카드보다 먼저 등록해야 그 라우트에 흡수되지 않는다. Route::get('trusted-proxy', [AdminSettingsController::class, 'trustedProxy'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.trusted-proxy'); // 초기 화면 정적 파일(정적 게시) 상태 + 관리자 수동 복구 (#651). 상태는 `{key}` 와일드카드보다 먼저. Route::get('static-cache', [AdminSettingsController::class, 'staticCacheStatus'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.static-cache'); Route::post('static-cache/republish', [AdminSettingsController::class, 'republishStaticCache'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.static-cache.republish'); Route::get('app-key', [AdminSettingsController::class, 'getAppKey'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.app-key'); Route::post('regenerate-app-key', [AdminSettingsController::class, 'regenerateAppKey'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.regenerate-app-key'); Route::post('clear-cache', [AdminSettingsController::class, 'clearCache'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.clear-cache'); Route::post('optimize-system', [AdminSettingsController::class, 'optimizeSystem'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.optimize-system'); Route::post('backup-database', [AdminSettingsController::class, 'backupDatabase'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.backup-database'); Route::post('backup', [AdminSettingsController::class, 'backup'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.backup'); Route::post('restore', [AdminSettingsController::class, 'restore'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.restore'); Route::post('test-mail', [AdminSettingsController::class, 'testMail'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.test-mail'); Route::post('test-driver', [AdminSettingsController::class, 'testDriverConnection'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.test-driver'); Route::post('test-outbound-proxy', [AdminSettingsController::class, 'testOutboundProxy'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.test-outbound-proxy'); Route::post('geoip/update', [AdminGeoIpController::class, 'update'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.geoip.update'); Route::get('{key}', [AdminSettingsController::class, 'show'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.show'); Route::put('{key}', [AdminSettingsController::class, 'update'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.update'); }); // 코어 업데이트 Route::prefix('core-update')->group(function () { Route::post('check', [AdminCoreUpdateController::class, 'checkForUpdates']) ->middleware('permission:admin,core.settings.update') ->name('api.admin.core-update.check'); Route::get('changelog', [AdminCoreUpdateController::class, 'changelog']) ->middleware('permission:admin,core.settings.read') ->name('api.admin.core-update.changelog'); }); // 메뉴 관리 Route::prefix('menus')->group(function () { Route::get('/', [AdminMenuController::class, 'index'])->middleware('permission:admin,core.menus.read')->name('api.admin.menus.index'); Route::get('hierarchy', [AdminMenuController::class, 'hierarchy'])->middleware('permission:admin,core.menus.read')->name('api.admin.menus.hierarchy'); Route::get('active', [AdminMenuController::class, 'active'])->middleware('permission:admin,core.menus.read')->name('api.admin.menus.active'); Route::post('/', [AdminMenuController::class, 'store'])->middleware('permission:admin,core.menus.create')->name('api.admin.menus.store'); Route::put('order', [AdminMenuController::class, 'updateOrder'])->middleware('permission:admin,core.menus.update')->name('api.admin.menus.update-order'); Route::get('extension/{type}/{identifier}', [AdminMenuController::class, 'getByExtension'])->middleware('permission:admin,core.menus.read')->name('api.admin.menus.by-extension'); Route::get('{menu}', [AdminMenuController::class, 'show'])->middleware('permission:admin,core.menus.read')->name('api.admin.menus.show'); Route::put('{menu}', [AdminMenuController::class, 'update'])->middleware('permission:admin,core.menus.update')->name('api.admin.menus.update'); Route::delete('{menu}', [AdminMenuController::class, 'destroy'])->middleware('permission:admin,core.menus.delete')->name('api.admin.menus.destroy'); Route::patch('{menu}/toggle-status', [AdminMenuController::class, 'toggleStatus'])->middleware('permission:admin,core.menus.update')->name('api.admin.menus.toggle-status'); }); // 역할 관리 Route::prefix('roles')->group(function () { Route::get('/', [AdminRoleController::class, 'index'])->middleware('permission:admin,core.permissions.read')->name('api.admin.roles.index'); Route::get('active', [AdminRoleController::class, 'active'])->name('api.admin.roles.active'); Route::post('/', [AdminRoleController::class, 'store'])->middleware('permission:admin,core.permissions.create')->name('api.admin.roles.store'); Route::get('{role}', [AdminRoleController::class, 'show'])->middleware('permission:admin,core.permissions.read')->name('api.admin.roles.show'); Route::put('{role}', [AdminRoleController::class, 'update'])->middleware('permission:admin,core.permissions.update')->name('api.admin.roles.update'); Route::delete('{role}', [AdminRoleController::class, 'destroy'])->middleware('permission:admin,core.permissions.delete')->name('api.admin.roles.destroy'); Route::patch('{role}/toggle-status', [AdminRoleController::class, 'toggleStatus'])->middleware('permission:admin,core.permissions.update')->name('api.admin.roles.toggle-status'); }); // 권한 목록 (역할 폼에서 사용) Route::prefix('permissions')->group(function () { Route::get('/', [AdminPermissionController::class, 'index'])->middleware('permission:admin,core.permissions.read')->name('api.admin.permissions.index'); }); // 활동 로그 조회/삭제 Route::prefix('activity-logs')->group(function () { Route::get('/', [AdminActivityLogController::class, 'index'])->middleware('permission:admin,core.activities.read')->name('api.admin.activity-logs.index'); Route::delete('/{activityLog}', [AdminActivityLogController::class, 'destroy'])->middleware('permission:admin,core.activities.delete')->name('api.admin.activity-logs.destroy'); Route::post('/bulk-delete', [AdminActivityLogController::class, 'bulkDestroy'])->middleware('permission:admin,core.activities.delete')->name('api.admin.activity-logs.bulk-destroy'); }); // 첨부파일 관리 Route::prefix('attachments')->group(function () { Route::post('/', [AdminAttachmentController::class, 'upload']) ->middleware('permission:admin,core.attachments.create') ->name('api.admin.attachments.upload'); Route::post('batch', [AdminAttachmentController::class, 'uploadBatch']) ->middleware('permission:admin,core.attachments.create') ->name('api.admin.attachments.upload_batch'); Route::delete('{attachment}', [AdminAttachmentController::class, 'destroy']) ->where('attachment', '[0-9]+') ->middleware('permission:admin,core.attachments.delete') ->name('api.admin.attachments.destroy'); Route::patch('reorder', [AdminAttachmentController::class, 'reorder']) ->middleware('permission:admin,core.attachments.update') ->name('api.admin.attachments.reorder'); }); // 사용자 관리 Route::prefix('users')->group(function () { Route::get('/', [AdminUserController::class, 'index'])->middleware('permission:admin,core.users.read')->name('api.admin.users.index'); Route::post('/', [AdminUserController::class, 'store'])->middleware('permission:admin,core.users.create')->name('api.admin.users.store'); Route::get('statistics', [AdminUserController::class, 'statistics'])->middleware('permission:admin,core.users.read')->name('api.admin.users.statistics'); Route::get('recent', [AdminUserController::class, 'recent'])->middleware('permission:admin,core.users.read')->name('api.admin.users.recent'); Route::get('search', [AdminUserController::class, 'search'])->middleware('permission:admin,core.users.read')->name('api.admin.users.search'); Route::post('check-email', [AdminUserController::class, 'checkEmail'])->middleware('permission:admin,core.users.read')->name('api.admin.users.check-email'); Route::patch('bulk-status', [AdminUserController::class, 'bulkUpdateStatus'])->middleware('permission:admin,core.users.update')->name('api.admin.users.bulk-status'); Route::get('{user}', [AdminUserController::class, 'show'])->middleware('permission:admin,core.users.read')->name('api.admin.users.show'); Route::put('{user}', [AdminUserController::class, 'update'])->middleware('permission:admin,core.users.update')->name('api.admin.users.update'); Route::delete('{user}', [AdminUserController::class, 'destroy'])->middleware('permission:admin,core.users.delete')->name('api.admin.users.destroy'); // 계정 잠금 해제 — 영구 잠금(무한대) 계정의 유일한 복구 경로 Route::post('{user}/unlock', [AdminUserController::class, 'unlock'])->middleware('permission:admin,core.users.update')->name('api.admin.users.unlock'); }); // 스케줄 관리 Route::prefix('schedules')->group(function () { Route::get('/', [AdminScheduleController::class, 'index'])->middleware('permission:admin,core.schedules.read')->name('api.admin.schedules.index'); Route::post('/', [AdminScheduleController::class, 'store'])->middleware('permission:admin,core.schedules.create')->name('api.admin.schedules.store'); Route::get('statistics', [AdminScheduleController::class, 'statistics'])->middleware('permission:admin,core.schedules.read')->name('api.admin.schedules.statistics'); Route::patch('bulk-status', [AdminScheduleController::class, 'bulkUpdateStatus'])->middleware('permission:admin,core.schedules.update')->name('api.admin.schedules.bulk-status'); Route::delete('bulk', [AdminScheduleController::class, 'bulkDelete'])->middleware('permission:admin,core.schedules.delete')->name('api.admin.schedules.bulk-delete'); Route::get('{schedule}', [AdminScheduleController::class, 'show'])->middleware('permission:admin,core.schedules.read')->name('api.admin.schedules.show'); Route::put('{schedule}', [AdminScheduleController::class, 'update'])->middleware('permission:admin,core.schedules.update')->name('api.admin.schedules.update'); Route::delete('{schedule}', [AdminScheduleController::class, 'destroy'])->middleware('permission:admin,core.schedules.delete')->name('api.admin.schedules.destroy'); Route::post('{schedule}/run', [AdminScheduleController::class, 'run'])->middleware('permission:admin,core.schedules.run')->name('api.admin.schedules.run'); Route::post('{schedule}/duplicate', [AdminScheduleController::class, 'duplicate'])->middleware('permission:admin,core.schedules.create')->name('api.admin.schedules.duplicate'); Route::get('{schedule}/history', [AdminScheduleController::class, 'history'])->middleware('permission:admin,core.schedules.read')->name('api.admin.schedules.history'); Route::delete('history/{historyId}', [AdminScheduleController::class, 'deleteHistory'])->middleware('permission:admin,core.schedules.delete')->name('api.admin.schedules.delete-history'); }); // SEO 캐시 관리 Route::prefix('seo')->group(function () { Route::get('stats', [AdminSeoCacheController::class, 'stats'])->middleware('permission:admin,core.settings.read')->name('api.admin.seo.stats'); Route::post('clear-cache', [AdminSeoCacheController::class, 'clearCache'])->middleware('permission:admin,core.settings.update')->name('api.admin.seo.clear-cache'); Route::post('warmup', [AdminSeoCacheController::class, 'warmup'])->middleware('permission:admin,core.settings.update')->name('api.admin.seo.warmup'); Route::post('sitemap/regenerate', [AdminSeoCacheController::class, 'regenerateSitemap'])->middleware('permission:admin,core.settings.update')->name('api.admin.seo.sitemap.regenerate'); Route::get('sitemap/status', [AdminSeoCacheController::class, 'sitemapStatus'])->middleware('permission:admin,core.settings.read')->name('api.admin.seo.sitemap.status'); Route::get('cached-urls', [AdminSeoCacheController::class, 'cachedUrls'])->middleware('permission:admin,core.settings.read')->name('api.admin.seo.cached-urls'); }); // 알림 정의 관리 Route::prefix('notification-definitions')->group(function () { Route::get('/', [AdminNotificationDefinitionController::class, 'index'])->middleware('permission:admin,core.settings.read')->name('api.admin.notification-definitions.index'); Route::get('{definition}', [AdminNotificationDefinitionController::class, 'show'])->middleware('permission:admin,core.settings.read')->name('api.admin.notification-definitions.show'); Route::put('{definition}', [AdminNotificationDefinitionController::class, 'update'])->middleware('permission:admin,core.settings.update')->name('api.admin.notification-definitions.update'); Route::patch('{definition}/toggle-active', [AdminNotificationDefinitionController::class, 'toggleActive'])->middleware('permission:admin,core.settings.update')->name('api.admin.notification-definitions.toggle-active'); Route::post('{definition}/reset', [AdminNotificationDefinitionController::class, 'reset'])->middleware('permission:admin,core.settings.update')->name('api.admin.notification-definitions.reset'); }); // 알림 템플릿 관리 Route::prefix('notification-templates')->group(function () { Route::put('{template}', [AdminNotificationTemplateController::class, 'update'])->middleware('permission:admin,core.settings.update')->name('api.admin.notification-templates.update'); Route::patch('{template}/toggle-active', [AdminNotificationTemplateController::class, 'toggleActive'])->middleware('permission:admin,core.settings.update')->name('api.admin.notification-templates.toggle-active'); Route::post('preview', [AdminNotificationTemplateController::class, 'preview'])->middleware('permission:admin,core.settings.read')->name('api.admin.notification-templates.preview'); Route::post('{template}/reset', [AdminNotificationTemplateController::class, 'reset'])->middleware('permission:admin,core.settings.update')->name('api.admin.notification-templates.reset'); }); // 알림 채널 관리 Route::get('notification-channels', [AdminNotificationChannelController::class, 'index'])->middleware('permission:admin,core.settings.read')->name('api.admin.notification-channels.index'); // 알림 발송 이력 Route::prefix('notification-logs')->group(function () { Route::get('/', [AdminNotificationLogController::class, 'index'])->middleware('permission:admin,core.notification-logs.read')->name('api.admin.notification-logs.index'); Route::delete('{notificationLog}', [AdminNotificationLogController::class, 'destroy'])->middleware('permission:admin,core.notification-logs.delete')->name('api.admin.notification-logs.destroy'); Route::post('bulk-delete', [AdminNotificationLogController::class, 'bulkDestroy'])->middleware('permission:admin,core.notification-logs.delete')->name('api.admin.notification-logs.bulk-destroy'); }); Route::prefix('templates')->group(function () { // 레이아웃 편집기 자산 서빙 — 활성/비활성 무관 // 권한 `core.templates.layouts.edit` 가드. // {templateName} 동적 라우트보다 위에 두어 매칭 우선순위 확보. Route::get('{identifier}/editor-assets', [AdminTemplateAssetController::class, 'getEditorAssets']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.editor-assets'); Route::dualSuffix('{identifier}/editor/components', 'json', [AdminTemplateAssetController::class, 'serveComponents']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.editor-components'); Route::dualSuffix('{identifier}/editor/routes', 'json', [AdminTemplateAssetController::class, 'serveRoutes']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.editor-routes'); Route::dualSuffix('{identifier}/editor/editor-spec', 'json', [AdminTemplateAssetController::class, 'serveEditorSpec']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.editor-spec'); Route::dualSuffix('{identifier}/editor/lang/{locale}', 'json', [AdminTemplateAssetController::class, 'serveLanguage']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.editor-lang'); // 표시 권한 후보 — 코어 + 활성 확장 권한 (속성 모달 표시 권한 TagInput). // 편집 권한 가드 하에서만 노출(전역 G7Config 상시 노출 회피). Route::dualSuffix('{identifier}/editor/permission-candidates', 'json', [AdminTemplateAssetController::class, 'servePermissionCandidates']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.editor-permission-candidates'); // 편집기 프리뷰 전용 CSS — 다크 셀렉터를 프리뷰 마커(.g7le-preview-dark)로 치환해 서빙 // 관리자 admin 의 html.dark 조상과 독립적으로 프리뷰 라이트/다크 격리. // URI 가 `components` 가 아니라 `component-styles` 인 이유: 확장자를 떼면 // `editor/components.json` 의 확장자 없는 형태와 충돌한다. Route::dualSuffix('{identifier}/editor/component-styles', 'css', [AdminTemplateAssetController::class, 'serveEditorCss']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.editor-css'); // 페이지 설정 모달 — SEO 후보/미리보기 + 브로드캐스트 카탈로그. // 전부 편집 권한 가드 하 편집기 전용. {templateName} 동적 라우트보다 위에 둠. Route::dualSuffix('{identifier}/editor/seo-candidates', 'json', [SeoCandidateController::class, 'index']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.editor-seo-candidates'); Route::post('{identifier}/editor/seo-og-preview', [SeoOgPreviewController::class, 'show']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.editor-seo-og-preview'); Route::post('{identifier}/editor/seo-bot-preview', [SeoBotPreviewController::class, 'show']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.editor-seo-bot-preview'); Route::dualSuffix('{identifier}/editor/broadcast-catalog', 'json', [BroadcastCatalogController::class, 'index']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.editor-broadcast-catalog'); // 레이아웃 첨부 파일 (배경 이미지 등). 권한 core.templates.layouts.edit. Route::get('{identifier}/layout-attachments', [AdminTemplateLayoutAttachmentController::class, 'index']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.layout-attachments.index'); Route::post('{identifier}/layout-attachments', [AdminTemplateLayoutAttachmentController::class, 'store']) ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.layout-attachments.store'); Route::delete('layout-attachments/{attachment}', [AdminTemplateLayoutAttachmentController::class, 'destroy']) ->where('attachment', '[0-9]+') ->middleware('permission:admin,core.templates.layouts.edit') ->name('api.admin.templates.layout-attachments.destroy'); Route::get('/', [AdminTemplateController::class, 'index'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.index'); Route::get('{templateName}', [AdminTemplateController::class, 'show'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.show'); Route::get('{templateName}/install-preview', [AdminTemplateController::class, 'installPreview'])->middleware('permission:admin,core.templates.install')->name('api.admin.templates.install-preview'); Route::post('install', [AdminTemplateController::class, 'install'])->middleware('permission:admin,core.templates.install')->name('api.admin.templates.install'); Route::post('install-from-file', [AdminTemplateController::class, 'installFromFile'])->middleware('permission:admin,core.templates.install')->name('api.admin.templates.install-from-file'); Route::post('manifest-preview', [AdminTemplateController::class, 'manifestPreview'])->middleware('permission:admin,core.templates.install')->name('api.admin.templates.manifest-preview'); Route::post('install-from-github', [AdminTemplateController::class, 'installFromGithub'])->middleware('permission:admin,core.templates.install')->name('api.admin.templates.install-from-github'); Route::post('activate', [AdminTemplateController::class, 'activate'])->middleware('permission:admin,core.templates.activate')->name('api.admin.templates.activate'); Route::post('deactivate', [AdminTemplateController::class, 'deactivate'])->middleware('permission:admin,core.templates.activate')->name('api.admin.templates.deactivate'); Route::post('refresh-layouts', [AdminTemplateController::class, 'refreshLayouts'])->middleware('permission:admin,core.templates.activate')->name('api.admin.templates.refresh-layouts'); Route::post('check-updates', [AdminTemplateController::class, 'checkUpdates'])->middleware('permission:admin,core.templates.install')->name('api.admin.templates.check-updates'); Route::get('{identifier}/changelog', [AdminTemplateController::class, 'changelog'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.changelog'); Route::get('{identifier}/license', [AdminTemplateController::class, 'license'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.license'); Route::get('{templateName}/check-modified-layouts', [AdminTemplateController::class, 'checkModifiedLayouts'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.check-modified-layouts'); Route::post('{templateName}/update', [AdminTemplateController::class, 'performUpdate'])->middleware('permission:admin,core.templates.install')->name('api.admin.templates.update'); Route::get('{templateName}/uninstall-info', [AdminTemplateController::class, 'uninstallInfo'])->middleware('permission:admin,core.templates.uninstall')->name('api.admin.templates.uninstall-info'); Route::delete('uninstall', [AdminTemplateController::class, 'uninstall'])->middleware('permission:admin,core.templates.uninstall')->name('api.admin.templates.uninstall'); // 레이아웃 관리 Route::get('{templateName}/layouts', [AdminLayoutController::class, 'index'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.layouts.index'); // 레이아웃 버전 관리 (show보다 먼저 등록 — {name}의 / 허용으로 인한 라우트 충돌 방지) Route::get('{templateName}/layouts/{name}/versions', [AdminLayoutController::class, 'versions'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.layouts.versions.index')->where('name', '[a-zA-Z0-9_/\.\-]+'); Route::get('{templateName}/layouts/{name}/versions/{version}', [AdminLayoutController::class, 'showVersion'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.layouts.versions.show')->where('name', '[a-zA-Z0-9_/\.\-]+'); Route::post('{templateName}/layouts/{name}/versions/{versionId}/restore', [AdminLayoutController::class, 'restoreVersion'])->middleware('permission:admin,core.templates.layouts.edit')->name('api.admin.templates.layouts.versions.restore')->where('name', '[a-zA-Z0-9_/\.\-]+'); // 레이아웃 미리보기 Route::post('{templateName}/layouts/{name}/preview', [AdminLayoutController::class, 'storePreview'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.layouts.preview.store')->where('name', '[a-zA-Z0-9_/\.\-]+'); // 레이아웃 CRUD (범용 {name} 라우트 — 가장 마지막에 등록) Route::get('{templateName}/layouts/{name}', [AdminLayoutController::class, 'show'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.layouts.show')->where('name', '[a-zA-Z0-9_/\.\-]+'); Route::put('{templateName}/layouts/{name}', [AdminLayoutController::class, 'update'])->middleware('permission:admin,core.templates.layouts.edit')->name('api.admin.templates.layouts.update')->where('name', '[a-zA-Z0-9_/\.\-]+'); // 레이아웃 확장 관리 (별도 prefix — 일반 레이아웃 {name} 정규식 충돌 회피) Route::get('{templateName}/layout-extensions', [AdminLayoutExtensionController::class, 'index'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.layout-extensions.index'); // 레이아웃 확장 버전 관리 (show 보다 먼저 등록) Route::get('{templateName}/layout-extensions/{extensionId}/versions', [AdminLayoutExtensionController::class, 'versions'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.layout-extensions.versions.index')->whereNumber('extensionId'); Route::get('{templateName}/layout-extensions/{extensionId}/versions/{version}', [AdminLayoutExtensionController::class, 'showVersion'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.layout-extensions.versions.show')->whereNumber('extensionId')->whereNumber('version'); Route::post('{templateName}/layout-extensions/{extensionId}/versions/{versionId}/restore', [AdminLayoutExtensionController::class, 'restoreVersion'])->middleware('permission:admin,core.templates.layouts.edit')->name('api.admin.templates.layout-extensions.versions.restore')->whereNumber('extensionId')->whereNumber('versionId'); // 레이아웃 확장 미리보기 Route::post('{templateName}/layout-extensions/{extensionId}/preview', [AdminLayoutExtensionController::class, 'storePreview'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.layout-extensions.preview.store')->whereNumber('extensionId'); // 레이아웃 확장 CRUD (범용 {extensionId} 라우트 — 마지막에 등록) Route::get('{templateName}/layout-extensions/{extensionId}', [AdminLayoutExtensionController::class, 'show'])->middleware('permission:admin,core.templates.read')->name('api.admin.templates.layout-extensions.show')->whereNumber('extensionId'); Route::put('{templateName}/layout-extensions/{extensionId}', [AdminLayoutExtensionController::class, 'update'])->middleware('permission:admin,core.templates.layouts.edit')->name('api.admin.templates.layout-extensions.update')->whereNumber('extensionId'); // 커스텀 다국어 키 관리 Route::get('{templateName}/custom-translations', [AdminTemplateCustomTranslationController::class, 'index'])->middleware('permission:admin,core.templates.layouts.edit')->name('api.admin.templates.custom-translations.index'); Route::post('{templateName}/custom-translations', [AdminTemplateCustomTranslationController::class, 'store'])->middleware('permission:admin,core.templates.layouts.edit')->name('api.admin.templates.custom-translations.store'); // 일괄 삭제 (body: ids[]) — {id} 라우트보다 먼저 등록 (id 없는 경로) Route::delete('{templateName}/custom-translations', [AdminTemplateCustomTranslationController::class, 'bulkDestroy'])->middleware('permission:admin,core.templates.layouts.edit')->name('api.admin.templates.custom-translations.bulk-destroy'); Route::put('{templateName}/custom-translations/{id}', [AdminTemplateCustomTranslationController::class, 'update'])->middleware('permission:admin,core.templates.layouts.edit')->name('api.admin.templates.custom-translations.update')->whereNumber('id'); Route::delete('{templateName}/custom-translations/{id}', [AdminTemplateCustomTranslationController::class, 'destroy'])->middleware('permission:admin,core.templates.layouts.edit')->name('api.admin.templates.custom-translations.destroy')->whereNumber('id'); }); });