From 657f546efc61a5777f3fb283c8a3f06f8380c3c5 Mon Sep 17 00:00:00 2001 From: thisgun Date: Thu, 2 Jul 2026 20:03:22 +0900 Subject: [PATCH] =?UTF-8?q?chore(payment):=20NHN=20KCP=EC=99=80=20NICE=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정식 develop에서는 기본 번들 정책에 따라 제거된 결제 플러그인을 별도 개발 브랜치에서 계속 유지보수할 수 있도록 복원한다. --- .env.example | 6 +- .env.testing.example | 4 +- .github/ISSUE_TEMPLATE/bug_report.md | 71 - .github/ISSUE_TEMPLATE/config.yml | 8 - .github/ISSUE_TEMPLATE/feature_request.md | 26 - .github/ISSUE_TEMPLATE/upgrade_issue.md | 51 - .gitignore | 3 - AGENTS.md | 12 - CHANGELOG.md | 33 +- INSTALL.md | 2 +- README.md | 2 +- .../CleanupExtensionBundlesCommand.php | 43 - .../Commands/Core/CoreUpdateCommand.php | 295 +- .../Core/ExecuteUpgradeStepsCommand.php | 10 - .../Extension/UpdateAutoloadCommand.php | 21 +- app/Console/Commands/HooksCacheCommand.php | 57 - app/Console/Commands/HooksClearCommand.php | 45 - .../Module/ClearModuleCacheCommand.php | 7 +- .../Plugin/ClearPluginCacheCommand.php | 7 +- .../Template/ClearTemplateCacheCommand.php | 7 +- app/Extension/AbstractModule.php | 23 - app/Extension/AbstractPlugin.php | 24 - app/Extension/ExtensionManager.php | 207 +- app/Extension/Helpers/CoreBackupHelper.php | 231 +- .../Helpers/FilePermissionHelper.php | 138 +- app/Extension/HookCacheManager.php | 320 - app/Extension/HookListenerRegistrar.php | 51 +- app/Extension/ModuleManager.php | 50 +- app/Extension/PluginManager.php | 50 +- app/Helpers/ResponseHelper.php | 199 +- .../Admin/AdminTemplateAssetController.php | 2 +- .../Api/Admin/SettingsController.php | 16 +- .../Api/Base/BaseApiController.php | 93 +- .../Api/Public/PublicModuleController.php | 46 +- .../Api/Public/PublicPluginController.php | 45 +- app/Http/Middleware/GzipEncodeResponse.php | 78 +- .../Settings/RestoreSettingsRequest.php | 55 - app/Http/View/Composers/TemplateComposer.php | 30 - .../View/Composers/UserTemplateComposer.php | 30 - .../ExtensionConfigCacheListener.php | 86 - app/Listeners/NotificationHookListener.php | 4 +- app/Models/TemplateLayoutAttachment.php | 2 +- app/Providers/CoreServiceProvider.php | 55 +- app/Repositories/IdentityPolicyRepository.php | 8 +- app/Seo/Editor/BroadcastCatalogService.php | 2 +- app/Services/CoreUpdateService.php | 99 +- app/Services/ExtensionBundleService.php | 475 - app/Services/GeoIpDatabaseService.php | 6 +- app/Services/LayoutService.php | 36 - app/Services/SettingsService.php | 104 +- .../TemplateLayoutAttachmentService.php | 4 +- app/Support/ConfigCacheHelper.php | 89 - config/app.php | 4 +- config/database.php | 16 +- config/filesystems.php | 10 - docs/backend/core-update-system.md | 61 +- docs/backend/response-helper.md | 21 - docs/backend/service-provider.md | 56 +- docs/cheatsheet.md | 10 +- docs/extension/editor-spec.md | 2 +- docs/extension/extension-manager.md | 16 - docs/extension/hooks.md | 38 - docs/extension/module-assets.md | 66 - .../layout-json-components-rendering.md | 2 +- docs/frontend/layout-json.md | 9 +- docs/testing/e2e-testing.md | 4 +- lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 13 - .../_bundled/g7-core-ja/backend/ja/hooks.php | 7 - .../g7-core-ja/backend/ja/settings.php | 8 - .../_bundled/g7-core-ja/language-pack.json | 2 +- .../g7-module-sirsoft-board-ja/CHANGELOG.md | 6 - .../frontend/partial/admin/posts.json | 3 +- .../language-pack.json | 2 +- .../CHANGELOG.md | 6 - .../frontend/partial/admin/product.json | 3 +- .../language-pack.json | 2 +- .../g7-module-sirsoft-page-ja/CHANGELOG.md | 6 - .../backend/ja/validation.php | 3 - .../frontend/partial/admin.json | 8 +- .../language-pack.json | 2 +- .../CHANGELOG.md | 11 + .../backend/ja/activity_log.php | 10 + .../backend/ja/messages.php | 16 + .../backend/ja/payment_methods.php | 24 + .../backend/ja/provider.php | 6 + .../frontend/ja.json | 136 + .../language-pack.json | 30 + .../seed/manifest.json | 4 + .../CHANGELOG.md | 11 + .../backend/ja/activity_log.php | 10 + .../backend/ja/messages.php | 24 + .../backend/ja/payment_methods.php | 36 + .../backend/ja/provider.php | 6 + .../frontend/ja.json | 149 + .../language-pack.json | 30 + .../seed/manifest.json | 4 + .../CHANGELOG.md | 10 - .../frontend/partial/admin.json | 3 +- .../frontend/partial/attachment.json | 4 +- .../language-pack.json | 2 +- .../g7-template-sirsoft-basic-ja/CHANGELOG.md | 8 - .../frontend/partial/attachment.json | 3 +- .../frontend/partial/board.json | 4 - .../frontend/partial/shop.json | 3 +- .../frontend/partial/user.json | 3 +- .../language-pack.json | 2 +- lang/en/hooks.php | 8 - lang/en/settings.php | 10 - lang/ko/hooks.php | 8 - lang/ko/settings.php | 10 - modules/_bundled/sirsoft-board/CHANGELOG.md | 11 - modules/_bundled/sirsoft-board/composer.json | 2 +- .../_bundled/sirsoft-board/editor-spec.json | 4 +- modules/_bundled/sirsoft-board/module.json | 2 +- .../_bundled/sirsoft-board/package-lock.json | 4 +- modules/_bundled/sirsoft-board/package.json | 2 +- .../admin-ecommerce-inquiry-settings.json | 4 +- .../admin-board-cross-navigation.test.tsx | 80 - .../layouts/admin-board-settings.test.tsx | 4 +- .../admin-comment-cascade-visibility.test.tsx | 2 +- .../admin_dashboard_extension.test.tsx | 2 +- .../layouts/board-copy-modal-info.test.tsx | 2 +- .../lang/partial/en/admin/posts.json | 3 +- .../lang/partial/ko/admin/posts.json | 3 +- .../layouts/admin/admin_board_form.json | 20 +- .../layouts/admin/admin_board_index.json | 6 +- .../admin/admin_board_post_detail.json | 27 - .../admin/admin_board_posts_index.json | 27 - .../sirsoft-board/resources/routes/admin.json | 2 +- .../Controllers/Admin/BoardController.php | 37 +- .../src/Http/Resources/BoardResource.php | 5 +- .../src/Http/Resources/PostCollection.php | 3 - .../src/Http/Resources/PostResource.php | 11 +- .../sirsoft-board/src/Models/Board.php | 23 - .../src/Repositories/CommentRepository.php | 2 +- .../Feature/Admin/BoardManagementTest.php | 77 - .../Admin/DeletedPostDetailVisibilityTest.php | 2 +- .../Feature/Admin/PostDeleteCascadeTest.php | 2 +- .../Feature/PostResourcePermissionTest.php | 116 - .../Feature/PostSearchSecretPreviewTest.php | 4 +- .../tests/Unit/BoardDashboardServiceTest.php | 4 +- .../board-attachment-download-actor.yaml | 2 +- .../board-blinded-original-access.yaml | 2 +- .../tests/scenarios/board-clone.yaml | 2 +- ...ard-comment-deleted-parent-child-tree.yaml | 2 +- .../board-delete-children-force-delete.yaml | 2 +- .../board-search-secret-content-preview.yaml | 2 +- .../tests/scenarios/post-delete-cascade.yaml | 4 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 17 - .../_bundled/sirsoft-ecommerce/composer.json | 2 +- .../_bundled/sirsoft-ecommerce/module.json | 2 +- .../sirsoft-ecommerce/package-lock.json | 4 +- .../_bundled/sirsoft-ecommerce/package.json | 2 +- .../handlers/bulkUpdateHandlers.test.ts | 2 +- .../adminOrderPartialCancelBadge.test.ts | 2 +- ...ommerce_mileage_transaction_index.test.tsx | 2 +- ...in_ecommerce_settings_mileage_tab.test.tsx | 4 +- .../layouts/mileage_user_injection.test.tsx | 2 +- .../layouts/productFormLayouts.test.tsx | 4 +- .../layouts/shippingCountryLayouts.test.tsx | 4 +- .../js/handlers/shippingPolicyFormHandlers.ts | 2 +- .../lang/partial/en/admin/product.json | 3 +- .../lang/partial/ko/admin/product.json | 3 +- .../admin/admin_ecommerce_product_form.json | 30 +- .../admin_ecommerce_product_review_index.json | 2 +- .../_modal_cancel_order.json | 2 +- .../_partial_product_datagrid.json | 2 +- .../src/Enums/OrderStatusEnum.php | 2 +- .../Controllers/Public/ProductController.php | 12 +- .../Public/ProductInquiryController.php | 13 +- .../Public/ProductReviewController.php | 9 +- .../Public/PublicCouponController.php | 7 +- .../Resources/MileageTransactionResource.php | 2 +- .../Http/Resources/ProductReviewResource.php | 1 - .../Http/Resources/PublicProductResource.php | 9 - .../Listeners/MileageTransactionListener.php | 25 +- .../src/Listeners/SearchProductsListener.php | 3 +- .../Listeners/UserMileageCleanupListener.php | 4 +- .../sirsoft-ecommerce/src/Models/Order.php | 2 +- .../MileageTransactionRepositoryInterface.php | 34 - .../MileageTransactionRepository.php | 45 +- .../src/Repositories/ProductRepository.php | 75 +- .../src/Services/OrderAdjustmentService.php | 2 +- .../src/Services/OrderCalculationService.php | 4 +- .../src/Services/OrderCancellationService.php | 2 +- .../src/Services/OrderOptionService.php | 35 +- .../src/Services/ProductInquiryService.php | 3 +- .../src/Services/SignupCurrencyResolver.php | 2 +- .../src/Services/UserMileageService.php | 36 +- .../sirsoft-ecommerce/src/routes/api.php | 26 +- .../Admin/ProductControllerSearchTest.php | 34 - .../Public/PublicProductControllerTest.php | 85 - .../User/UserProductInquiryControllerTest.php | 40 - .../CheckoutVerificationGuardTest.php | 2 +- .../Search/ProductSearchIntegrationTest.php | 48 - .../tests/Playwright/README.md | 2 +- .../Unit/Enums/OrderStatusTransitionTest.php | 2 +- .../UserMileageCleanupListenerTest.php | 2 +- .../Unit/Repositories/OrderRepositoryTest.php | 2 +- .../Resources/OrderResourceFieldsTest.php | 2 +- .../Services/MileageBaseCurrencyGateTest.php | 2 +- .../Services/MileageEarnDeltaAndGateTest.php | 228 - .../Services/OrderAdjustmentServiceTest.php | 4 +- .../OrderOptionServiceConfirmTest.php | 270 - .../Services/OrderProcessingServiceTest.php | 2 +- .../OrderServiceStockRedeductTest.php | 2 +- .../scenarios/mp02-order-cancellation.yaml | 2 +- .../mp04-order-status-transition.yaml | 2 +- .../scenarios/mp08-base-currency-policy.yaml | 2 +- .../tests/scenarios/mp08-signup-currency.yaml | 2 +- .../tests/scenarios/mp08-user-currency.yaml | 2 +- .../order-option-status-transition.yaml | 15 - .../sirsoft-ecommerce/vendor-bundle.json | 10 +- .../sirsoft-ecommerce/vendor-bundle.zip | Bin 435549 -> 435550 bytes modules/_bundled/sirsoft-page/CHANGELOG.md | 25 - modules/_bundled/sirsoft-page/composer.json | 2 +- ...001_drop_soft_deletes_from_pages_table.php | 44 - ...ft_deletes_from_page_attachments_table.php | 47 - modules/_bundled/sirsoft-page/module.json | 2 +- .../_bundled/sirsoft-page/package-lock.json | 4 +- modules/_bundled/sirsoft-page/package.json | 2 +- ...n-page-detail-attachment-download.test.tsx | 89 - .../layouts/admin-page-layouts.test.ts | 474 - .../resources/lang/partial/en/admin.json | 8 +- .../resources/lang/partial/ko/admin.json | 8 +- .../layouts/admin/admin_page_detail.json | 163 +- .../layouts/admin/admin_page_form.json | 93 +- .../layouts/admin/admin_page_list.json | 30 +- .../Console/Commands/PlaywrightSeedPage.php | 139 - .../Admin/PageAttachmentController.php | 43 +- .../Http/Controllers/Admin/PageController.php | 11 +- .../User/PublicPageAttachmentController.php | 33 +- .../Controllers/User/PublicPageController.php | 19 +- .../src/Http/Requests/PageListRequest.php | 24 +- .../src/Http/Requests/StorePageRequest.php | 2 - .../src/Http/Requests/UpdatePageRequest.php | 2 - .../Http/Resources/PageAttachmentResource.php | 19 +- .../src/Http/Resources/PageResource.php | 5 +- .../src/Http/Resources/PublicPageResource.php | 27 +- .../src/Listeners/SeoPageCacheListener.php | 9 +- .../_bundled/sirsoft-page/src/Models/Page.php | 2 + .../src/Models/PageAttachment.php | 32 +- .../src/Providers/PageServiceProvider.php | 20 +- .../Contracts/PageRepositoryInterface.php | 11 +- .../src/Repositories/PageRepository.php | 78 +- .../src/Services/PageAttachmentService.php | 9 +- .../sirsoft-page/src/Services/PageService.php | 88 +- .../sirsoft-page/src/lang/en/validation.php | 5 - .../sirsoft-page/src/lang/ko/validation.php | 5 - .../_bundled/sirsoft-page/src/routes/api.php | 16 +- .../Admin/PageAttachmentControllerTest.php | 28 +- .../Feature/Admin/PageControllerTest.php | 492 +- .../Repositories/PageRepositorySearchTest.php | 2 +- .../Feature/User/PublicPageControllerTest.php | 362 +- .../tests/Playwright/fixtures/page-auth.ts | 54 - .../tests/Playwright/fixtures/page-seed.ts | 53 - .../tests/Playwright/playwright.config.ts | 74 - .../specs/admin/page-attachment-order.spec.ts | 63 - .../admin/page-form-cancel-navigation.spec.ts | 58 - .../page-form-reset-on-route-switch.spec.ts | 82 - .../page-version-changed-fields-i18n.spec.ts | 42 - .../user/unpublished-admin-preview.spec.ts | 94 - .../Listeners/SeoPageCacheListenerTest.php | 27 - .../Services/PageAttachmentServiceTest.php | 32 +- .../tests/Unit/Services/PageServiceTest.php | 135 +- .../page-attachment-order-and-seo-cache.yaml | 110 - .../scenarios/page-attachment-serving.yaml | 75 - .../page-unpublished-admin-preview.yaml | 67 - playwright.config.ts | 2 +- .../layouts/admin_plugin_settings.test.tsx | 8 +- .../__tests__/layouts/cookie_banner.test.tsx | 20 +- .../layouts/gdpr_consent_log.test.tsx | 2 +- .../layouts/mypage_privacy_tab.test.tsx | 8 +- .../Admin/GdprAdminSettingsControllerTest.php | 2 +- .../Listeners/GdprAuthConsentListenerTest.php | 4 +- .../Controllers/MobileCallbackRouteTest.php | 2 +- .../_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md | 103 + plugins/_bundled/sirsoft-pay_nhnkcp/LICENSE | 47 + plugins/_bundled/sirsoft-pay_nhnkcp/README.md | 249 + .../bin/KCPPaymentService.wsdl | 142 + .../_bundled/sirsoft-pay_nhnkcp/bin/pp_cli | Bin 0 -> 314981 bytes .../sirsoft-pay_nhnkcp/bin/pp_cli_exe.exe | Bin 0 -> 385024 bytes .../sirsoft-pay_nhnkcp/bin/pp_cli_x64 | Bin 0 -> 377122 bytes .../_bundled/sirsoft-pay_nhnkcp/bin/pub.key | 5 + .../bin/real_KCPPaymentService.wsdl | 145 + .../_bundled/sirsoft-pay_nhnkcp/composer.json | 20 + .../_bundled/sirsoft-pay_nhnkcp/composer.lock | 20 + .../config/settings/defaults.json | 24 + .../sirsoft-pay_nhnkcp/dist/js/plugin.iife.js | 18 + .../dist/js/plugin.iife.js.map | 1 + .../sirsoft-pay_nhnkcp/editor-spec.json | 119 + .../lang/en/activity_log.php | 10 + .../sirsoft-pay_nhnkcp/lang/en/messages.php | 18 + .../lang/en/payment_methods.php | 24 + .../sirsoft-pay_nhnkcp/lang/en/provider.php | 13 + .../lang/ko/activity_log.php | 10 + .../sirsoft-pay_nhnkcp/lang/ko/messages.php | 18 + .../lang/ko/payment_methods.php | 24 + .../sirsoft-pay_nhnkcp/lang/ko/provider.php | 12 + .../sirsoft-pay_nhnkcp/package-lock.json | 3179 +++++ .../_bundled/sirsoft-pay_nhnkcp/package.json | 19 + .../_bundled/sirsoft-pay_nhnkcp/plugin.json | 34 + .../_bundled/sirsoft-pay_nhnkcp/plugin.php | 204 + .../admin_order_list_test_badge.json | 285 + .../extensions/admin_order_payment_query.json | 459 + .../extensions/checkout_easy_pay.json | 97 + .../user_order_complete_receipt.json | 313 + .../resources/extensions/user_order_show.json | 374 + .../adminApplePayNoticeInjector.test.ts | 38 + .../adminPaymentMethodBrandInjector.test.ts | 87 + .../__tests__/checkoutEasyPayInjector.test.ts | 226 + .../handlers/copyToClipboard.test.ts | 42 + .../__tests__/handlers/requestPayment.test.ts | 215 + .../layouts/pluginSettingsSticky.test.tsx | 153 + .../orderResponseInterceptor.test.ts | 158 + .../js/__tests__/paymentCloseReport.test.ts | 92 + .../js/adminApplePayNoticeInjector.ts | 142 + .../js/adminPaymentMethodBrandInjector.ts | 278 + .../resources/js/checkoutEasyPayInjector.ts | 429 + .../resources/js/handlers/copyToClipboard.ts | 34 + .../resources/js/handlers/index.ts | 9 + .../resources/js/handlers/requestPayment.ts | 580 + .../resources/js/handlers/setPaymentMethod.ts | 76 + .../sirsoft-pay_nhnkcp/resources/js/index.ts | 109 + .../resources/js/mypageOrderShowInjector.ts | 365 + .../js/orderCompleteReceiptInjector.ts | 148 + .../resources/js/orderResponseInterceptor.ts | 295 + .../resources/js/paymentCloseReport.ts | 106 + .../resources/js/support/applePayDevice.ts | 38 + .../sirsoft-pay_nhnkcp/resources/lang/en.json | 145 + .../sirsoft-pay_nhnkcp/resources/lang/ko.json | 145 + .../layouts/admin/plugin_settings.json | 1944 +++ .../src/Concerns/PreventsReplayCallback.php | 57 + .../Concerns/RecordsPaymentWindowClosure.php | 217 + .../src/Concerns/SanitizesPgResponse.php | 33 + .../src/Concerns/SendsKcpNotifyResponse.php | 43 + .../AdminEscrowDeliveryController.php | 210 + .../Controllers/AdminOrderListController.php | 61 + .../AdminTransactionController.php | 74 + .../EscrowCommonNotifyController.php | 211 + .../src/Controllers/HealthCheckController.php | 444 + .../Controllers/MobileApprovalController.php | 258 + .../Controllers/PaymentCallbackController.php | 874 ++ .../PaymentCloseReportController.php | 102 + .../Controllers/PaymentRetryController.php | 82 + .../src/Controllers/UserReceiptController.php | 113 + .../UserVbankMockDepositController.php | 94 + .../src/Exceptions/NhnKcpApiException.php | 20 + .../src/Http/Middleware/RestrictKcpIp.php | 79 + .../src/Http/Requests/AuthCallbackRequest.php | 76 + .../Requests/EscrowCommonNotifyRequest.php | 50 + .../Requests/PaymentCloseReportRequest.php | 37 + .../src/Http/Requests/PaymentRetryRequest.php | 36 + .../src/Http/Requests/VbankNotifyRequest.php | 62 + ...tEcommercePaymentMethodsLayoutListener.php | 103 + .../Listeners/CancelActivityLogListener.php | 91 + ...nOrderDetailPaymentQueryLayoutListener.php | 338 + ...eAdminOrderListTestBadgeLayoutListener.php | 203 + .../src/Listeners/PaymentRefundListener.php | 166 + .../RegisterEasyPayMethodsListener.php | 155 + .../Listeners/RegisterPgProviderListener.php | 150 + ...oreLayoutExtensionsAfterUpdateListener.php | 154 + .../src/Services/KcpSoapService.php | 196 + .../src/Services/NhnKcpApiService.php | 618 + .../sirsoft-pay_nhnkcp/src/routes/api.php | 72 + .../sirsoft-pay_nhnkcp/src/routes/web.php | 40 + .../Concerns/PreventsReplayCallbackTest.php | 141 + .../AdminEscrowDeliveryControllerTest.php | 95 + .../EscrowCommonNotifyControllerTest.php | 153 + .../MobileApprovalControllerTest.php | 393 + .../PaymentCallbackControllerTest.php | 1056 ++ .../PaymentCloseReportControllerTest.php | 170 + .../PaymentRetryControllerTest.php | 168 + .../ShippingAndCouponCancellationTest.php | 704 + .../Feature/Middleware/RestrictKcpIpTest.php | 135 + .../tests/PluginTestCase.php | 181 + .../Controllers/HealthCheckControllerTest.php | 45 + ...mmercePaymentMethodsLayoutListenerTest.php | 76 + ...AdminOrderLayoutExtensionsListenerTest.php | 259 + .../RegisterEasyPayMethodsListenerTest.php | 105 + ...ayoutExtensionsAfterUpdateListenerTest.php | 111 + .../Unit/Services/CliInjectionDefenseTest.php | 165 + .../Unit/Services/NhnKcpApiServiceTest.php | 508 + .../scenarios/example_pg_payment.yaml.example | 93 + .../scenarios/security-callback-defense.yaml | 107 + .../_bundled/sirsoft-pay_nhnkcp/tsconfig.json | 18 + .../sirsoft-pay_nhnkcp/vite.config.ts | 27 + .../sirsoft-pay_nhnkcp/vitest.config.ts | 17 + .../sirsoft-pay_nicepayments/CHANGELOG.md | 96 + .../_bundled/sirsoft-pay_nicepayments/LICENSE | 47 + .../sirsoft-pay_nicepayments/README.md | 147 + .../sirsoft-pay_nicepayments/composer.json | 20 + .../sirsoft-pay_nicepayments/composer.lock | 20 + .../config/settings/defaults.json | 24 + .../dist/js/plugin.iife.js | 2 + .../dist/js/plugin.iife.js.map | 1 + .../sirsoft-pay_nicepayments/editor-spec.json | 62 + .../lang/en/activity_log.php | 10 + .../lang/en/messages.php | 26 + .../lang/en/payment_methods.php | 36 + .../lang/en/provider.php | 13 + .../lang/ko/activity_log.php | 10 + .../lang/ko/messages.php | 26 + .../lang/ko/payment_methods.php | 36 + .../lang/ko/provider.php | 12 + .../package-lock.json | 3179 +++++ .../sirsoft-pay_nicepayments/package.json | 19 + .../sirsoft-pay_nicepayments/plugin.json | 34 + .../sirsoft-pay_nicepayments/plugin.php | 232 + .../admin_order_list_test_badge.json | 266 + .../extensions/admin_order_payment_query.json | 828 ++ .../extensions/checkout_easy_pay.json | 223 + .../extensions/user_mypage_order_receipt.json | 462 + .../user_order_complete_receipt.json | 196 + .../adminPaymentMethodBrandInjector.test.ts | 87 + .../__tests__/checkoutEasyPayInjector.test.ts | 83 + .../__tests__/handlers/requestPayment.test.ts | 264 + .../handlers/setPaymentMethod.test.ts | 82 + .../layouts/pluginSettingsSticky.test.tsx | 83 + .../orderResponseInterceptor.test.ts | 107 + .../js/__tests__/paymentCloseReport.test.ts | 53 + .../js/adminPaymentMethodBrandInjector.ts | 287 + .../resources/js/checkoutEasyPayInjector.ts | 480 + .../resources/js/handlers/index.ts | 7 + .../resources/js/handlers/requestPayment.ts | 405 + .../resources/js/handlers/setPaymentMethod.ts | 83 + .../resources/js/index.ts | 99 + .../resources/js/mypageOrderShowInjector.ts | 246 + .../js/orderCompleteReceiptInjector.ts | 118 + .../resources/js/orderResponseInterceptor.ts | 330 + .../resources/js/paymentCloseReport.ts | 63 + .../resources/lang/en.json | 149 + .../resources/lang/ko.json | 149 + .../layouts/admin/plugin_settings.json | 1163 ++ .../src/Concerns/PreventsReplayCallback.php | 57 + .../Concerns/RecordsPaymentWindowClosure.php | 90 + .../src/Concerns/SanitizesPgResponse.php | 33 + .../src/Controllers/AdminEscrowController.php | 132 + .../Controllers/AdminOrderListController.php | 48 + .../AdminTransactionController.php | 96 + .../AdminVbankNotificationController.php | 77 + .../AdminVbankRefundController.php | 253 + .../Controllers/PaymentCallbackController.php | 1110 ++ .../PaymentCloseReportController.php | 138 + .../src/Controllers/UserReceiptController.php | 63 + .../src/Exceptions/NicePayApiException.php | 20 + .../Middleware/VbankNotifyIpWhitelist.php | 46 + .../src/Http/Requests/AuthCallbackRequest.php | 85 + .../Requests/PaymentCloseReportRequest.php | 37 + .../src/Http/Requests/VbankNotifyRequest.php | 147 + ...tEcommercePaymentMethodsLayoutListener.php | 106 + .../Listeners/CancelActivityLogListener.php | 92 + ...nOrderDetailPaymentQueryLayoutListener.php | 340 + ...eAdminOrderListTestBadgeLayoutListener.php | 203 + .../src/Listeners/PaymentRefundListener.php | 157 + .../RegisterEasyPayMethodsListener.php | 177 + .../Listeners/RegisterPgProviderListener.php | 130 + ...oreLayoutExtensionsAfterUpdateListener.php | 154 + .../src/Services/NicePaymentsApiService.php | 419 + .../src/Support/UrlHelper.php | 41 + .../src/routes/api.php | 79 + .../src/routes/web.php | 39 + .../Concerns/PreventsReplayCallbackTest.php | 149 + .../Controllers/AdminEscrowControllerTest.php | 103 + .../AdminVbankRefundControllerTest.php | 231 + .../PaymentCallbackControllerTest.php | 998 ++ .../PaymentCloseReportControllerTest.php | 277 + .../ShippingAndCouponCancellationTest.php | 788 + .../tests/PluginTestCase.php | 181 + .../Controllers/AuthTokenDedupeKeyTest.php | 137 + ...mmercePaymentMethodsLayoutListenerTest.php | 80 + ...AdminOrderLayoutExtensionsListenerTest.php | 261 + .../Listeners/PaymentRefundListenerTest.php | 164 + .../RegisterEasyPayMethodsListenerTest.php | 135 + ...ayoutExtensionsAfterUpdateListenerTest.php | 111 + .../Services/NicePaymentsApiServiceTest.php | 253 + .../tests/Unit/Support/UrlHelperTest.php | 39 + .../scenarios/example_pg_payment.yaml.example | 110 + .../scenarios/security-callback-defense.yaml | 95 + .../sirsoft-pay_nicepayments/tsconfig.json | 18 + .../sirsoft-pay_nicepayments/vite.config.ts | 27 + .../sirsoft-pay_nicepayments/vitest.config.ts | 17 + .../identityProviderInicisExtension.test.ts | 4 +- .../pluginSettingsErrorBinding.test.tsx | 2 +- .../tests/scenarios/duplicate-block.yaml | 2 +- .../tests/scenarios/extension-point.yaml | 6 +- .../install-default-purpose-provider.yaml | 2 +- .../scenarios/partial-data-integrity.yaml | 4 +- public/build/core/devtools.min.js.map | 2 +- public/build/core/layout-editor.min.js | 2 +- public/build/core/layout-editor.min.js.map | 2 +- public/build/core/template-engine.min.js | 44 +- public/build/core/template-engine.min.js.map | 2 +- public/index.php | 31 +- public/install/api/state-management.php | 23 +- public/install/includes/functions.php | 305 +- public/install/includes/installer-runtime.php | 40 +- public/install/includes/installer-state.php | 127 +- public/install/includes/progress-emitter.php | 30 +- public/install/includes/request-handler.php | 70 +- public/install/includes/task-runner.php | 352 +- public/install/lang/en.php | 3 - public/install/lang/ko.php | 3 - resources/js/core/TemplateApp.ts | 63 +- .../core/__tests__/loadDevToolsBundle.test.ts | 2 +- resources/js/core/devtools-entry.ts | 2 +- .../__runtime-shims/G7DevToolsCore.ts | 2 +- .../core/identity/IdentityGuardInterceptor.ts | 6 +- .../js/core/modules/ModuleAssetLoader.ts | 151 - .../__tests__/ModuleAssetLoader.test.ts | 108 +- resources/js/core/modules/index.ts | 3 +- .../js/core/template-engine/CHANGELOG.md | 25 +- .../core/template-engine/DynamicRenderer.tsx | 2 +- .../js/core/template-engine/G7CoreGlobals.ts | 10 +- .../DynamicRenderer.iterationId.test.tsx | 2 +- .../IdentityGuardInterceptor.test.ts | 2 +- .../components/PlaceholderChipInput.test.tsx | 2 +- .../__tests__/components/TableEditor.test.tsx | 2 +- .../layouts/condition-toggle-render.test.tsx | 2 +- .../__tests__/spec/actionRecipeEngine.test.ts | 12 +- .../spec/conditionRecipeEngine.test.ts | 4 +- .../__tests__/spec/editorGapCoverage.test.ts | 4 +- .../spec/expressionValueTree.test.ts | 6 +- .../components/PreviewCanvas.tsx | 2 +- .../page-settings/SegmentedValueEditor.tsx | 2 +- .../property-controls/I18nTextField.tsx | 4 +- .../InlineBindingScalarPicker.tsx | 2 +- .../layout-editor/spec/actionRecipeEngine.ts | 12 +- .../layout-editor/spec/bindingCandidates.ts | 4 +- .../spec/computedRecipeEngine.ts | 2 +- .../spec/conditionRecipeEngine.ts | 6 +- .../layout-editor/spec/inlineBindingUtils.ts | 2 +- .../layout-editor/spec/specTypes.ts | 6 +- resources/views/admin.blade.php | 1 - resources/views/app.blade.php | 1 - resources/views/dev-dashboard.blade.php | 12 - routes/api.php | 14 +- .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 14 - .../admin-base-header-lang-currency.test.tsx | 2 +- ...in-settings-identity-policy-modal.test.tsx | 2 +- .../dist/css/components.css | 2 +- .../dist/js/components.iife.js | 2 +- .../dist/js/components.iife.js.map | 2 +- .../lang/partial/en/admin.json | 3 +- .../lang/partial/en/attachment.json | 2 +- .../lang/partial/ko/admin.json | 3 +- .../lang/partial/ko/attachment.json | 2 +- .../layouts/_admin_base.json | 40 - .../sirsoft-admin_basic/package-lock.json | 11832 ++++++++-------- .../_bundled/sirsoft-admin_basic/package.json | 2 +- .../layouts/sidebar-view-site-link.test.tsx | 97 - .../src/styles/layout/_admin_base.css | 41 +- .../sirsoft-admin_basic/src/styles/main.css | 2 - .../sirsoft-admin_basic/template.json | 2 +- templates/_bundled/sirsoft-basic/CHANGELOG.md | 16 - .../board-deleted-comment-tree.test.tsx | 2 +- ...ard-search-secret-content-preview.test.tsx | 2 +- .../layouts/coupon-download-modal.test.tsx | 2 +- .../page-attachment-download-handler.test.tsx | 111 - .../layouts/page-show-preview-banner.test.tsx | 79 - .../layouts/partialCancelBadge.test.tsx | 2 +- .../shop-checkout-shipping-country.test.tsx | 2 +- .../layouts/shop-guest-order-flow.test.tsx | 4 +- .../sirsoft-basic/dist/js/components.iife.js | 22 +- .../dist/js/components.iife.js.map | 2 +- .../src/components/composite/ProductCard.d.ts | 2 - .../_bundled/sirsoft-basic/editor-spec.json | 2 +- .../lang/partial/en/attachment.json | 1 - .../sirsoft-basic/lang/partial/en/board.json | 4 - .../sirsoft-basic/lang/partial/en/shop.json | 1 - .../sirsoft-basic/lang/partial/en/user.json | 3 +- .../lang/partial/ko/attachment.json | 1 - .../sirsoft-basic/lang/partial/ko/board.json | 4 - .../sirsoft-basic/lang/partial/ko/shop.json | 1 - .../sirsoft-basic/lang/partial/ko/user.json | 3 +- .../sirsoft-basic/layouts/page/show.json | 64 +- .../partials/board/index/_admin_links.json | 73 - .../board/show/modals/_modal_report.json | 2 +- .../partials/board/types/basic/index.json | 18 +- .../partials/board/types/basic/show.json | 69 - .../partials/board/types/card/index.json | 18 +- .../partials/board/types/gallery/index.json | 18 +- .../partials/mypage/orders/_items.json | 4 +- .../partials/search/products/_item_list.json | 2 +- .../layouts/partials/shop/_cart_item.json | 6 +- .../partials/shop/_checkout_items.json | 4 +- .../shop/detail/_admin_edit_link.json | 35 - .../layouts/partials/shop/detail/_header.json | 14 - .../layouts/shop/guest_order_form.json | 4 +- .../sirsoft-basic/layouts/shop/show.json | 8 +- .../_bundled/sirsoft-basic/package-lock.json | 4 +- templates/_bundled/sirsoft-basic/package.json | 2 +- templates/_bundled/sirsoft-basic/routes.json | 2 +- .../layouts/admin-cross-navigation.test.tsx | 237 - .../board-report-modal-feedback.test.tsx | 4 +- .../composite/FileUploader/useFileUploader.ts | 10 +- .../src/components/composite/ProductCard.tsx | 6 +- .../__tests__/FileUploaderLimit.test.ts | 135 - .../_bundled/sirsoft-basic/template.json | 2 +- .../Api/Admin/SettingsControllerTest.php | 212 - .../Api/Public/ExtensionBundleServingTest.php | 119 - .../Feature/Api/Public/LayoutServingTest.php | 119 - .../Public/LayoutSourceMetaServingTest.php | 84 - .../CoreUpdateCommandSpawnFailureTest.php | 92 +- .../Console/CoreUpdateResumeGuidanceTest.php | 107 - .../Console/CoreUpdateWithBundleTest.php | 13 - .../Feature/Console/HooksCacheCommandTest.php | 132 - .../Middleware/GzipEncodeResponseTest.php | 127 - tests/Playwright/README.md | 8 +- ...cross-navigation-and-mobile-drawer.spec.ts | 116 - .../checkout-pg-dynamic-dispatch.spec.ts | 2 +- .../specs/extension-bundle-loading.spec.ts | 78 - .../specs/ime-composition-key-filter.spec.ts | 2 +- .../devtools-bundle-split.spec.ts | 2 +- .../layout-editor/iteration-id-unique.spec.ts | 2 +- .../responsive-branch-drag.spec.ts | 2 +- .../Playwright/specs/smoke/admin-base.spec.ts | 2 +- .../Config/DatabaseConfigDeprecationTest.php | 88 +- .../CoreBackupHelperApplyListTest.php | 350 - .../Extension/ExtensionSourceClassmapTest.php | 268 - .../FilePermissionHelperSymlinkTest.php | 76 +- tests/Unit/Extension/HookCacheManagerTest.php | 414 - .../Extension/InstallerCompletedGuardTest.php | 156 +- .../ModuleCleanupStaleEntriesTest.php | 95 +- tests/Unit/Installer/AddLogUtf8ScrubTest.php | 251 - tests/Unit/Installer/DeleteDirectoryTest.php | 75 +- .../DirectoryPermissionScanDepthTest.php | 134 - .../Installer/InstallerRuntimeHelperTest.php | 73 +- .../InstallerWindowsCommandsTest.php | 25 +- .../ExtensionConfigCacheListenerTest.php | 84 - .../InstallerRuntimeServiceProviderTest.php | 31 +- ...SettingsServiceProviderDebugConfigTest.php | 2 +- tests/Unit/Services/CoreUpdateServiceTest.php | 251 +- .../Services/ExtensionBundleServiceTest.php | 307 - .../NotificationDefinitionServiceTest.php | 50 - tests/Unit/Support/ConfigCacheHelperTest.php | 128 - .../admin-user-cross-navigation.yaml | 48 - .../admin-user-list-bulk-status-i18n.yaml | 4 +- .../core-update-active-dir-preservation.yaml | 31 +- .../core-update-spawn-failure-mode.yaml | 47 +- tests/scenarios/ecommerce-mileage.yaml | 2 +- tests/scenarios/extension-bundle-loading.yaml | 71 - .../scenarios/extension-source-classmap.yaml | 61 - tests/scenarios/guest-order.yaml | 4 +- tests/scenarios/hook-cache.yaml | 67 - ...r-tree-version-badge-and-modal-scroll.yaml | 2 +- ...mantic-asset-mapping-408-list-spacing.yaml | 2 +- .../layout-semantic-asset-mapping-408.yaml | 2 +- .../settings-system-info-probe-isolation.yaml | 41 - tests/scenarios/websocket-toggle-ssot.yaml | 4 +- vite.config.devtools.js | 2 +- 651 files changed, 49118 insertions(+), 20476 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .github/ISSUE_TEMPLATE/upgrade_issue.md delete mode 100644 app/Console/Commands/CleanupExtensionBundlesCommand.php delete mode 100644 app/Console/Commands/HooksCacheCommand.php delete mode 100644 app/Console/Commands/HooksClearCommand.php delete mode 100644 app/Extension/HookCacheManager.php delete mode 100644 app/Http/Requests/Settings/RestoreSettingsRequest.php delete mode 100644 app/Listeners/ExtensionConfigCacheListener.php delete mode 100644 app/Services/ExtensionBundleService.php delete mode 100644 app/Support/ConfigCacheHelper.php delete mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/activity_log.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/payment_methods.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/provider.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/seed/manifest.json create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/activity_log.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/payment_methods.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/provider.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/frontend/ja.json create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/seed/manifest.json delete mode 100644 lang/en/hooks.php delete mode 100644 lang/ko/hooks.php delete mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-cross-navigation.test.tsx delete mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnDeltaAndGateTest.php delete mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceConfirmTest.php delete mode 100644 modules/_bundled/sirsoft-page/database/migrations/2026_06_29_000001_drop_soft_deletes_from_pages_table.php delete mode 100644 modules/_bundled/sirsoft-page/database/migrations/2026_06_29_000002_drop_soft_deletes_from_page_attachments_table.php delete mode 100644 modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-detail-attachment-download.test.tsx delete mode 100644 modules/_bundled/sirsoft-page/src/Console/Commands/PlaywrightSeedPage.php delete mode 100644 modules/_bundled/sirsoft-page/tests/Playwright/fixtures/page-auth.ts delete mode 100644 modules/_bundled/sirsoft-page/tests/Playwright/fixtures/page-seed.ts delete mode 100644 modules/_bundled/sirsoft-page/tests/Playwright/playwright.config.ts delete mode 100644 modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-attachment-order.spec.ts delete mode 100644 modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-form-cancel-navigation.spec.ts delete mode 100644 modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-form-reset-on-route-switch.spec.ts delete mode 100644 modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-version-changed-fields-i18n.spec.ts delete mode 100644 modules/_bundled/sirsoft-page/tests/Playwright/specs/user/unpublished-admin-preview.spec.ts delete mode 100644 modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-order-and-seo-cache.yaml delete mode 100644 modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-serving.yaml delete mode 100644 modules/_bundled/sirsoft-page/tests/scenarios/page-unpublished-admin-preview.yaml create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/LICENSE create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/README.md create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/bin/KCPPaymentService.wsdl create mode 100755 plugins/_bundled/sirsoft-pay_nhnkcp/bin/pp_cli create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/bin/pp_cli_exe.exe create mode 100755 plugins/_bundled/sirsoft-pay_nhnkcp/bin/pp_cli_x64 create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/bin/pub.key create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/bin/real_KCPPaymentService.wsdl create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/composer.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/composer.lock create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/config/settings/defaults.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/dist/js/plugin.iife.js create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/dist/js/plugin.iife.js.map create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/editor-spec.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/activity_log.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/messages.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/payment_methods.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/provider.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/activity_log.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/messages.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/payment_methods.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/provider.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/package.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/plugin.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/extensions/admin_order_list_test_badge.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/extensions/admin_order_payment_query.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/extensions/checkout_easy_pay.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/extensions/user_order_complete_receipt.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/extensions/user_order_show.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/__tests__/adminApplePayNoticeInjector.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/__tests__/adminPaymentMethodBrandInjector.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/__tests__/checkoutEasyPayInjector.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/__tests__/handlers/copyToClipboard.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/__tests__/handlers/requestPayment.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/__tests__/orderResponseInterceptor.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/__tests__/paymentCloseReport.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/adminApplePayNoticeInjector.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/adminPaymentMethodBrandInjector.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/checkoutEasyPayInjector.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/handlers/copyToClipboard.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/handlers/index.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/handlers/requestPayment.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/handlers/setPaymentMethod.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/index.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/mypageOrderShowInjector.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/orderCompleteReceiptInjector.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/orderResponseInterceptor.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/paymentCloseReport.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/support/applePayDevice.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/lang/en.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/lang/ko.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/resources/layouts/admin/plugin_settings.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/PreventsReplayCallback.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/RecordsPaymentWindowClosure.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/SanitizesPgResponse.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/SendsKcpNotifyResponse.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminEscrowDeliveryController.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminOrderListController.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/AdminTransactionController.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/EscrowCommonNotifyController.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/HealthCheckController.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/MobileApprovalController.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentRetryController.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserVbankMockDepositController.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Exceptions/NhnKcpApiException.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Http/Middleware/RestrictKcpIp.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Http/Requests/AuthCallbackRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Http/Requests/EscrowCommonNotifyRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Http/Requests/PaymentCloseReportRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Http/Requests/PaymentRetryRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Http/Requests/VbankNotifyRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Listeners/CancelActivityLogListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Listeners/EnsureAdminOrderListTestBadgeLayoutListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Listeners/PaymentRefundListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Listeners/RegisterEasyPayMethodsListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Listeners/RegisterPgProviderListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Services/KcpSoapService.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/Services/NhnKcpApiService.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/api.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/src/routes/web.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Concerns/PreventsReplayCallbackTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/AdminEscrowDeliveryControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/EscrowCommonNotifyControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/MobileApprovalControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCallbackControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCloseReportControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentRetryControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/ShippingAndCouponCancellationTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Middleware/RestrictKcpIpTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/PluginTestCase.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Controllers/HealthCheckControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Listeners/EnsureAdminOrderLayoutExtensionsListenerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Listeners/RestoreLayoutExtensionsAfterUpdateListenerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Services/CliInjectionDefenseTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Services/NhnKcpApiServiceTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/scenarios/example_pg_payment.yaml.example create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tests/scenarios/security-callback-defense.yaml create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/tsconfig.json create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/vite.config.ts create mode 100644 plugins/_bundled/sirsoft-pay_nhnkcp/vitest.config.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/LICENSE create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/README.md create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/composer.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/composer.lock create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/config/settings/defaults.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/dist/js/plugin.iife.js create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/dist/js/plugin.iife.js.map create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/editor-spec.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/lang/en/activity_log.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/lang/en/messages.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/lang/en/payment_methods.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/lang/en/provider.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/lang/ko/activity_log.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/lang/ko/messages.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/lang/ko/payment_methods.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/lang/ko/provider.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/package-lock.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/package.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/plugin.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/plugin.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/extensions/admin_order_list_test_badge.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/extensions/admin_order_payment_query.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/extensions/checkout_easy_pay.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/extensions/user_mypage_order_receipt.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/extensions/user_order_complete_receipt.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/__tests__/adminPaymentMethodBrandInjector.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/__tests__/checkoutEasyPayInjector.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/__tests__/handlers/requestPayment.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/__tests__/handlers/setPaymentMethod.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/__tests__/orderResponseInterceptor.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/__tests__/paymentCloseReport.test.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/adminPaymentMethodBrandInjector.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/checkoutEasyPayInjector.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/handlers/index.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/handlers/requestPayment.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/handlers/setPaymentMethod.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/index.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/mypageOrderShowInjector.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/orderCompleteReceiptInjector.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/orderResponseInterceptor.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/js/paymentCloseReport.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/lang/en.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/lang/ko.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/resources/layouts/admin/plugin_settings.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Concerns/PreventsReplayCallback.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Concerns/RecordsPaymentWindowClosure.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Concerns/SanitizesPgResponse.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminOrderListController.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminTransactionController.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminVbankNotificationController.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminVbankRefundController.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCallbackController.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/UserReceiptController.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Exceptions/NicePayApiException.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Middleware/VbankNotifyIpWhitelist.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/AuthCallbackRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/PaymentCloseReportRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Http/Requests/VbankNotifyRequest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/CancelActivityLogListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/EnsureAdminOrderListTestBadgeLayoutListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/PaymentRefundListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/RegisterEasyPayMethodsListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/RegisterPgProviderListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Services/NicePaymentsApiService.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/Support/UrlHelper.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/routes/api.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/src/routes/web.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Concerns/PreventsReplayCallbackTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/AdminEscrowControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/AdminVbankRefundControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCloseReportControllerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/ShippingAndCouponCancellationTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Controllers/AuthTokenDedupeKeyTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Listeners/EnsureAdminOrderLayoutExtensionsListenerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Listeners/PaymentRefundListenerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Listeners/RestoreLayoutExtensionsAfterUpdateListenerTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Services/NicePaymentsApiServiceTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Support/UrlHelperTest.php create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/scenarios/example_pg_payment.yaml.example create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tests/scenarios/security-callback-defense.yaml create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/tsconfig.json create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/vite.config.ts create mode 100644 plugins/_bundled/sirsoft-pay_nicepayments/vitest.config.ts delete mode 100644 templates/_bundled/sirsoft-admin_basic/src/__tests__/layouts/sidebar-view-site-link.test.tsx delete mode 100644 templates/_bundled/sirsoft-basic/__tests__/layouts/page-attachment-download-handler.test.tsx delete mode 100644 templates/_bundled/sirsoft-basic/__tests__/layouts/page-show-preview-banner.test.tsx delete mode 100644 templates/_bundled/sirsoft-basic/layouts/partials/board/index/_admin_links.json delete mode 100644 templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_admin_edit_link.json delete mode 100644 templates/_bundled/sirsoft-basic/src/__tests__/layouts/admin-cross-navigation.test.tsx delete mode 100644 templates/_bundled/sirsoft-basic/src/components/composite/__tests__/FileUploaderLimit.test.ts delete mode 100644 tests/Feature/Api/Public/ExtensionBundleServingTest.php delete mode 100644 tests/Feature/Console/CoreUpdateResumeGuidanceTest.php delete mode 100644 tests/Feature/Console/HooksCacheCommandTest.php delete mode 100644 tests/Playwright/specs/admin/cross-navigation-and-mobile-drawer.spec.ts delete mode 100644 tests/Playwright/specs/extension-bundle-loading.spec.ts delete mode 100644 tests/Unit/Extension/CoreBackupHelperApplyListTest.php delete mode 100644 tests/Unit/Extension/ExtensionSourceClassmapTest.php delete mode 100644 tests/Unit/Extension/HookCacheManagerTest.php delete mode 100644 tests/Unit/Installer/AddLogUtf8ScrubTest.php delete mode 100644 tests/Unit/Installer/DirectoryPermissionScanDepthTest.php delete mode 100644 tests/Unit/Listeners/ExtensionConfigCacheListenerTest.php delete mode 100644 tests/Unit/Services/ExtensionBundleServiceTest.php delete mode 100644 tests/Unit/Support/ConfigCacheHelperTest.php delete mode 100644 tests/scenarios/admin-user-cross-navigation.yaml delete mode 100644 tests/scenarios/extension-bundle-loading.yaml delete mode 100644 tests/scenarios/extension-source-classmap.yaml delete mode 100644 tests/scenarios/hook-cache.yaml delete mode 100644 tests/scenarios/settings-system-info-probe-isolation.yaml diff --git a/.env.example b/.env.example index 8fcf8120..ad1164be 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.2 +APP_VERSION=7.0.1 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko @@ -36,9 +36,7 @@ DB_WRITE_PASSWORD= # Database Table Prefix (optional) DB_PREFIX=g7_ -# Read Database Connection (Optional) -# 비워두면 각 항목이 대응하는 DB_WRITE_* 값으로 대체됩니다 (config/database.php). -# Read 전용 DB(리플리카)를 쓸 때만 값을 채우세요. +# Read Database Connection (Optional - falls back to WRITE if not set) DB_READ_HOST= DB_READ_PORT= DB_READ_DATABASE= diff --git a/.env.testing.example b/.env.testing.example index 96277f5a..889ac45f 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.2 +APP_VERSION=7.0.1 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko @@ -34,7 +34,7 @@ DB_WRITE_PASSWORD= DB_PREFIX=g7_ # Read Database Connection (테스트에서는 WRITE와 동일하게 설정) -# 비워두면 config/database.php 가 WRITE 값으로 대체하나, 테스트 DB 명시를 위해 값을 채워 둔다. +# database.php의 read 기본값이 WRITE와 다르므로, 명시 설정 필수 DB_READ_HOST=127.0.0.1 DB_READ_PORT=3306 DB_READ_DATABASE=g7_testing diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 6371abda..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: 🐛 버그 제보 -about: 그누보드7(G7) 사용 중 발견한 버그를 제보합니다. -title: "[Bug] " -labels: bug -assignees: '' ---- - -## 증상 요약 - - - -## 재현 절차 - -1. -2. -3. - -## 기대한 동작 - - - -## 실제 동작 - - - -## 환경 정보 (필수) - -> 아래 정보가 없으면 원인 파악이 어렵습니다. 가능한 한 모두 채워 주세요. - -- **코어 버전**: -- **업그레이드 이슈인 경우 — 업그레이드 전 버전 → 후 버전**: -- **PHP 버전**: -- **웹서버·실행 방식**: -- **DB**: -- **OS**: - -## 활성 확장 목록 (해당 시) - - - -```text -(여기에 붙여넣기) -``` - -## 커스텀 자산 여부 (해당 시) - -> 업그레이드·파일 삭제·권한 문제라면 반드시 확인해 주세요. - -- [ ] `modules/_bundled/`·`plugins/_bundled/`·`templates/_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다. -- [ ] `public/.htaccess` 에 직접 추가한 규칙이 있습니다. -- [ ] 그 외 코어 배포본에 없는 커스텀 파일을 직접 추가/수정했습니다. - - - -## 스크린샷·로그 - - - -## 추가 정보 - - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 58b6d87a..00000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -blank_issues_enabled: true -contact_links: - - name: 질문·사용법 문의 (sir.kr Q&A) - url: https://sir.kr/questions/create?tag=%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7 - about: 버그가 아닌 사용법 질문·사용법 문의는 sir.kr Q&A 를 이용해 주세요. - - name: 보안 취약점 신고 - url: https://github.com/gnuboard/g7/security/advisories/new - about: 보안 취약점은 공개 이슈가 아닌 비공개 Security Advisory 로 신고해 주세요. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 0e9e294b..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: ✨ 기능 제안 -about: 새로운 기능이나 개선 아이디어를 제안합니다. -title: "[Feature] " -labels: enhancement -assignees: '' ---- - -## 해결하려는 문제 - - - -## 제안하는 기능 - - - -## 적용 범위 (해당 시) - -- [ ] 코어 -- [ ] 모듈 -- [ ] 플러그인 -- [ ] 템플릿 - -## 대안·참고 사항 - - diff --git a/.github/ISSUE_TEMPLATE/upgrade_issue.md b/.github/ISSUE_TEMPLATE/upgrade_issue.md deleted file mode 100644 index b09d50d0..00000000 --- a/.github/ISSUE_TEMPLATE/upgrade_issue.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: ⬆️ 업그레이드 문제 -about: core:update / 확장 업데이트 과정에서 발생한 문제를 제보합니다. -title: "[Upgrade] " -labels: "bug, upgrade" -assignees: '' ---- - -## 업그레이드 경로 (필수) - -- **업그레이드 전 버전**: -- **업그레이드 후(목표) 버전**: -- **실행한 명령**: -- **실행 계정/권한**: - -> 참고: `core:update` 는 **현재 설치된(업그레이드 전) 버전의 코드**로 실행됩니다. -> 따라서 최신 버전에서 수정된 버그라도, 그 수정 이전 버전에서 올라오는 경우에는 재발할 수 있습니다. -> 업그레이드 전 반드시 전체 백업을 권장합니다. - -## 증상 - - - -## 확장·커스텀 자산 상태 (필수) - -- [ ] `_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다. -- [ ] `public/.htaccess`·`public/storage` 등 커스텀 파일/링크가 있습니다. -- [ ] 업데이트 후 위 자산이 삭제/변경되었습니다. - - - -```text -(확장 목록 붙여넣기) -``` - -## 환경 정보 - -- **PHP 버전**: -- **웹서버·실행 방식**: -- **DB**: -- **OS**: - -## 로그·스크린샷 - - diff --git a/.gitignore b/.gitignore index b2a79076..ee76dad5 100644 --- a/.gitignore +++ b/.gitignore @@ -49,9 +49,6 @@ node_modules/ # Settings JSON files (환경별 설정) /storage/app/settings/ -# 확장 프론트엔드 병합 번들 캐시 (version-in-path, 런타임 생성) -/storage/app/ext-bundles/ - # Bootstrap cache (auto-generated) /bootstrap/cache/* !/bootstrap/cache/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index 4ffdec6a..98717a21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -788,18 +788,6 @@ php artisan plugin:update sirsoft-payment --force lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱글톤·React Context·DevTools 코어)을 재사용할 때는 재번들하지 않고 `window.G7Core.__runtime` 을 빌려 쓴다 — React/컨텍스트/싱글톤 인스턴스 동일성이 강제되기 때문(사본이 둘이면 "Invalid hook call"·컨텍스트 미매칭). 메인 번들이 `G7CoreGlobals` 에서 공유 대상을 `G7Core.__runtime` 에 노출하고, lazy 번들 vite config 는 React 4종(`react`/`react-dom`/`react-dom/client`/`react/jsx-runtime`)을 external→window 로, 코어 런타임 모듈을 `resolveId` 플러그인으로 `__runtime-shims/` 로 치환한다. -### 확장 번들 병합 (서버측 concat) - -활성 모듈/플러그인의 프론트엔드 IIFE JS·CSS 는 타입별로 서버에서 하나의 번들로 병합해 서빙한다(`/api/{modules,plugins}/bundle.{js,css}?v={version}`). `ExtensionBundleService` 가 정렬·필터·concat·캐시를 전담하고, 프론트는 `window.G7Config.bundleUrls` 를 읽어 모듈 번들 → 플러그인 번들 순으로 로드한다. 병합 규율: - -- priority 순서는 선언형 — 실행 순서는 오직 manifest `loading.priority` 오름차순(`uasort`). 특정 확장 이름을 지목하는 분기를 두지 않는다. -- IIFE 사이는 `\n;\n`(JS)/`\n`(CSS) 로 잇는다. 미사용 시 ASI 경계가 깨져 번들 전체 파싱 에러가 난다. -- 소스맵은 prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite. 개별 에셋 서빙 라우트(`*.map` 포함)는 존치한다. -- 번들 URL 은 반드시 same-origin(`/api/...`). 외부 origin/CDN·protocol-relative 는 gdpr preblocker 에 자기 차단된다. -- 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들. -- concat 루프는 확장별 try/catch — 실패 확장만 skip 하고 나머지 병합을 지속한다. -- 번들 파일명에 확장 캐시 버전을 포함(`{type}.{version}.{js,css}`). 조합 변경 시 version bump → 새 파일명 → 자동 재생성. 구파일 GC 는 `ext-bundles:cleanup` + `{module,plugin,template}:cache-clear` 가 담당한다. prod 은 version-in-path 디스크 캐시, 비프로덕션은 매 요청 concat. - ### 빌드 명령어 (Artisan) ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e6c423..0e73358d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,42 +4,11 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [7.0.2] - 2026-07-08 - -### Added - -- 설치된 모듈·플러그인이 늘어날수록 화면마다 개별로 내려받던 확장 스크립트·스타일 요청을 종류별로 하나로 묶어 내려받도록 개선해, 확장이 많은 사이트의 초기 로딩 요청 수를 크게 줄였습니다. 확장을 설치·활성화·비활성화하면 묶음이 자동으로 다시 만들어져 항상 현재 활성 상태와 일치합니다. - -### Changed - -- 코어 업데이트가 파일을 반영하는 기본 방식을 "새 버전이 실제로 추가·변경한 파일만 반영"으로 바꿔, 업데이트로 인한 사고 가능성을 크게 줄였습니다. 이제 코어가 건드리지 않은 파일은 그대로 두므로, `public/.htaccess` 에 직접 넣은 설정이나 사용자가 추가해 둔 파일이 업데이트 과정에서 사라지지 않습니다. 새 버전에 함께 담긴 기본 제공 확장(모듈·플러그인·템플릿·언어팩)의 갱신 파일은 이 과정에서도 정상적으로 최신 내용으로 반영되며, 사용자가 확장 폴더에 직접 추가해 둔 항목은 그대로 보존됩니다. 기존처럼 새 버전에서 제거된 파일 정리까지 포함한 전체 덮어쓰기를 원하면 `--prune` 옵션으로 업데이트하세요. (#64 @bigmsg 님께서 제보해주셨습니다.) -- 화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다. - - 모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다. - - 한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다. - - 화면을 그리는 데 쓰이지 않는 개발용 설명 주석을 화면 정보 응답에서 빼도록 해 응답 용량을 추가로 줄였습니다. - -### Fixed - -- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다. -- 설치 마법사에서 Vendor 설치 방식을 "Composer 실행"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.) -- 설치 마법사의 "설치 환경 확인" 단계에서, storage 폴더 하위에 파일이 많이 쌓인 환경일수록 요구사항 검사가 오래 걸리던 문제를 개선했습니다. 폴더 쓰기 권한 검사가 불필요하게 깊은 하위까지 훑던 것을 필요한 범위로 제한해 이 단계가 즉시 완료되도록 했습니다. -- 설치 시 "읽기 전용 데이터베이스(Read DB)를 사용하지 않음"으로 선택했는데도, 읽기 전용 DB 접속 정보를 확인하려다 설치 마지막 단계에서 실패하던 문제를 수정했습니다. 이제 읽기 전용 DB를 사용하지 않으면 쓰기 DB 정보만으로 설치가 정상 완료됩니다. 아울러 설치 이후에도 읽기 전용 DB 접속 정보를 비워 두면 조회 연결이 자동으로 쓰기 DB로 대체되도록 하여, 별도의 읽기 전용 DB 없이도 안정적으로 동작합니다. (#63 @kitrio 님께서 제보해주셨습니다.) - -## [7.0.1] - 2026-07-03 +## [7.0.1] - 2026-07-01 ### Changed - 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다. -- 설치가 끝난 사이트에서 매 요청마다 반복되던 불필요한 데이터베이스 조회를 줄여 페이지 응답 속도를 개선했습니다. 알림·본인인증 설정을 준비하는 과정에서 이미 존재가 보장된 테이블을 매번 다시 확인하던 동작을 설치 완료 상태에서는 건너뛰도록 했습니다. -- 화면을 그릴 때마다 서버가 매번 확장 기능의 연결 정보를 처음부터 다시 계산하던 부담을 줄여 페이지 응답 속도를 개선했습니다. 이 연결 정보는 확장을 설치·수정하거나 코어를 업데이트할 때만 바뀌므로 그 시점에 한 번 저장해 두고, 이후 요청에서는 저장된 값을 재사용하도록 했습니다. 확장이나 코어를 변경하면 저장 값이 자동으로 갱신됩니다. -- 설치된 모듈·플러그인의 코드를 찾을 때마다 서버가 파일을 하나하나 탐색하던 부담을 줄여, 특히 공유 호스팅 등 파일 접근이 느린 환경에서 페이지 응답 속도를 개선했습니다. 확장 코드의 위치를 설치·수정 시점에 미리 정리해 두고 이후 요청에서는 그 목록을 바로 참조하도록 했습니다. 확장을 설치·수정·삭제하거나 코어를 업데이트하면 목록이 자동으로 갱신됩니다. -- 코어 업데이트 중 데이터 변환 단계가 현재 환경에서 자동으로 끝까지 실행되지 못하고 중단될 때, 남은 단계를 이어서 실행하는 안내를 실행 권한에 맞게 개선했습니다. 특히 관리자 권한(sudo)으로 업데이트한 경우, 남은 단계를 관리자 권한 그대로 실행하면 그 과정에서 만들어지는 파일이 관리자 소유로 남아 이후 웹사이트가 그 파일에 쓰지 못하는 문제가 생길 수 있습니다. 이제 이런 경우 웹 서버 실행 계정(예: www-data)으로 실행하도록 정확한 명령을 함께 안내하며, 웹 서버 계정을 자동으로 확인하지 못하면 어떤 계정으로 실행해야 하는지 확인 방법을 안내합니다. - -### Fixed - -- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 "디렉토리는 복사할 수 없습니다" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다. -- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, "실행할 업그레이드 단계가 없다"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다. -- 일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 "알 수 없음"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.) ## [7.0.0] - 2026-07-01 diff --git a/INSTALL.md b/INSTALL.md index 4c4d2f2b..dc5e17d6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -246,7 +246,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.2 g7 +# (필요 시) mv g7-7.0.1 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.md b/README.md index 8514de61..c21b4c51 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- Version + Version PHP Laravel React diff --git a/app/Console/Commands/CleanupExtensionBundlesCommand.php b/app/Console/Commands/CleanupExtensionBundlesCommand.php deleted file mode 100644 index cf1ac59b..00000000 --- a/app/Console/Commands/CleanupExtensionBundlesCommand.php +++ /dev/null @@ -1,43 +0,0 @@ -getCurrentVersion(); - $deleted = $service->cleanupStaleBundles($currentVersion); - - $this->info("오래된 확장 번들 {$deleted}건이 삭제되었습니다. (현재 version: {$currentVersion})"); - - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index 3012dc67..25d1b806 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -15,7 +15,6 @@ use App\Extension\Traits\ClearsTemplateCaches; use App\Extension\Vendor\Exceptions\VendorInstallException; use App\Extension\Vendor\VendorMode; use App\Services\CoreUpdateService; -use App\Support\ConfigCacheHelper; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -28,7 +27,6 @@ class CoreUpdateCommand extends Command protected $signature = 'core:update {--force : 버전 비교 없이 강제 업데이트} {--no-backup : 백업 생성 건너뛰기} - {--prune : 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용하고 나머지는 보존} {--no-maintenance : 유지보수 모드 활성화 건너뛰기} {--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)} {--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)} @@ -347,12 +345,6 @@ class CoreUpdateCommand extends Command // // `--no-backup` 모드면 backupPath 가 null 이므로 manifest 생성을 스킵 — 롤백 // 자체가 불가능한 모드이므로 기존 동작 유지. - // 증분 적용(3-way) 대상 목록. 백업 있음 + --prune 미지정 시에만 산출. - // null 로 남으면(백업 부재 또는 --prune) applyUpdate 가 전체 덮어쓰기로 회귀. - $applyList = null; - $applyStats = null; - $prune = (bool) $this->option('prune'); - if ($backupPath !== null) { $bar->setMessage('신규 파일 manifest 생성 중...'); $log('신규 파일 manifest 생성 시작'); @@ -378,51 +370,15 @@ class CoreUpdateCommand extends Command 'error' => $manifestError->getMessage(), ]); } - - // ── 증분 적용 대상 산출 (3-way) ── - // --prune 이면 전체 덮어쓰기이므로 산출 자체를 스킵. - if (! $prune) { - try { - $applyStats = CoreBackupHelper::computeApplyList( - $backupPath, - $pendingPath, - (array) config('app.update.targets', []), - (array) config('app.update.protected_paths', []), - (array) config('app.update.excludes', []), - ); - $applyList = $applyStats['apply']; - $log(sprintf( - '증분 적용 대상 산출 완료 (added=%d, changed=%d, total=%d)', - $applyStats['added_count'], - $applyStats['changed_count'], - count($applyList), - )); - } catch (\Throwable $applyError) { - // 산출 실패 시 안전하게 전체 덮어쓰기로 회귀 (applyList=null 유지) - $applyList = null; - $applyStats = null; - $log("증분 적용 대상 산출 실패 (전체 덮어쓰기로 회귀): {$applyError->getMessage()}"); - Log::warning('코어 업데이트: 증분 적용 대상 산출 실패', [ - 'error' => $applyError->getMessage(), - ]); - } - } } // ── Step 7: 파일 적용 ── $bar->setMessage(__('settings.core_update.step_apply')); $bar->advance(); + $log('코어 파일 덮어쓰기 시작'); - if ($prune) { - $log('코어 파일 덮어쓰기 시작 (--prune: 전체 덮어쓰기 + orphan 정리)'); - } elseif ($applyList !== null) { - $log(sprintf('코어 파일 적용 시작 (증분: 코어 변경분 %d건만 적용, 나머지 보존)', count($applyList))); - } else { - $log('코어 파일 덮어쓰기 시작 (백업 부재 — 전체 덮어쓰기로 회귀)'); - } - - $service->applyUpdate($pendingPath, $onProgress, prune: $prune, applyList: $applyList); - $log('코어 파일 적용 완료'); + $service->applyUpdate($pendingPath, $onProgress); + $log('코어 파일 덮어쓰기 완료'); // ── Step 8: vendor 디렉토리 복사 (_pending → 운영) ── if ($composerSkipped) { @@ -528,13 +484,6 @@ class CoreUpdateCommand extends Command $maintenanceEnabled = false; } - // 모든 파일(코어 config/lang/vendor)이 안착한 뒤 config 캐시를 재생성한다. - // clearAllCaches() 는 흐름 중간에서 stale 캐시를 비우기만 하므로, 여기서 - // 재생성하지 않으면 업데이트 후 config:cache 가 비활성 상태로 남아 이후 모든 - // 요청이 config 파일을 재파싱한다(성능 손실). ConfigCacheHelper 는 설치 미완료 - // 상태를 가드하고 실패를 안전하게 흡수한다. - ConfigCacheHelper::rebuild(); - $log('정리 완료'); $bar->finish(); @@ -546,9 +495,6 @@ class CoreUpdateCommand extends Command $this->info("그누보드7 코어가 {$toVersion} 버전으로 업데이트되었습니다!"); $this->newLine(); - // ── 파일 적용 방식 요약 (부수의무: 설명의무) ── - $this->summarizeApplyMode($prune, $applyList, $applyStats); - // _bundled 확장 일괄 업데이트 프롬프트 (번들에 신버전이 있을 때만 표시). // // 주의: Step 11 restoreOwnership 이후에 실행되므로, 프롬프트 내 실제 @@ -647,7 +593,7 @@ class CoreUpdateCommand extends Command $this->line(" 사유: {$e->reason}"); $this->newLine(); $this->info('나머지 스텝을 적용하려면 아래 명령을 실행하세요 (재다운로드 없이 스텝만 실행):'); - $this->surfaceResumeCommandWithPermissionGuidance($resumeCommand); + $this->line(" {$resumeCommand}"); $this->newLine(); $this->warn('⚠ 위 명령을 실행하지 않으면 버전 표시는 최신이지만 일부 스텝이 미실행 상태로 남습니다.'); $this->newLine(); @@ -860,7 +806,6 @@ class CoreUpdateCommand extends Command // 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용). $handoffPayload = null; $stepsExecuted = null; - $stepsDiscovered = null; while (! feof($pipes[1])) { $line = fgets($pipes[1]); if ($line !== false) { @@ -892,13 +837,7 @@ class CoreUpdateCommand extends Command $decoded = json_decode($json, true); if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) { $stepsExecuted = $decoded['count']; - // discovered 는 신버전 자식만 발행하는 필드. 구버전 자식(필드 부재)은 - // null 로 남아 handleSpawnExit 가 기존(레거시) 판정 경로를 탄다. - if (isset($decoded['discovered']) && is_int($decoded['discovered']) && $decoded['discovered'] >= 0) { - $stepsDiscovered = $decoded['discovered']; - } - $log('[spawn] 실행된 step 수: '.$stepsExecuted - .($stepsDiscovered !== null ? " (발견 {$stepsDiscovered})" : '')); + $log('[spawn] 실행된 step 수: '.$stepsExecuted); continue; } @@ -913,50 +852,6 @@ class CoreUpdateCommand extends Command fclose($pipes[2]); $exitCode = proc_close($process); - return $this->handleSpawnExit( - $exitCode, - $handoffPayload, - $stepsExecuted, - $stepsDiscovered, - $fromVersion, - $toVersion, - $log, - ); - } - - /** - * spawn 자식 종료 후 exit code · 신호를 해석해 성공/실패/핸드오프를 판정합니다. - * - * 판정 규칙 (exit=0 경우): - * - `[STEPS_EXECUTED]` 신호 미수신 (`$stepsExecuted === null`) → fail-fast. - * 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. - * - executed=0 인데 **discovered>0** → fail-fast. 범위 내에 스텝 파일이 실제로 - * 존재하는데 자식이 하나도 실행하지 못함 (gnuboard/g7#28 silent skip — 케이스 A). - * - executed=0 이고 **discovered=0** → 정상 통과. 해당 from~to 범위에 스텝 파일이 - * 애초에 없는 릴리즈 (예: 데이터/설정 변경 없는 패치). fromfailSpawnWithMode( 'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심', @@ -981,27 +878,9 @@ class CoreUpdateCommand extends Command ); } - // executed=0 + discovered=0 → 범위 내 스텝 파일 부재. 정상 통과 (케이스 B). - // 스텝이 필요 없는 릴리즈(데이터/설정 변경 없음)를 실패로 오판하지 않는다. - if ($stepsExecuted === 0 && $stepsDiscovered === 0) { - $log("spawn 완료 (exit=0, 실행할 스텝 없음 — 범위 내 스텝 파일 부재, from={$fromVersion} to={$toVersion})"); - - return true; - } - - // executed=0 + (discovered>0 또는 discovered 신호 부재) + from0: 스텝 파일이 있는데 자식이 실행 못함 (gnuboard/g7#28 케이스 A). - // discovered=null: 구버전 자식 (discovered 미발행) — 레거시 판정 유지. - if ($stepsExecuted === 0 - && $stepsDiscovered !== 0 - && version_compare($fromVersion, $toVersion, '<')) { + if ($stepsExecuted === 0 && version_compare($fromVersion, $toVersion, '<')) { return $this->failSpawnWithMode( - sprintf( - 'spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s%s)', - $fromVersion, - $toVersion, - $stepsDiscovered !== null ? ", 발견 {$stepsDiscovered}건" : '', - ), + sprintf('spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s)', $fromVersion, $toVersion), $log, $fromVersion, $toVersion, @@ -1114,41 +993,6 @@ class CoreUpdateCommand extends Command return true; } - /** - * 파일 적용 방식(증분/prune/fallback)과 통계를 사용자에게 요약 출력합니다. - * - * @param bool $prune --prune 지정 여부 - * @param array|null $applyList 증분 적용 대상 목록 (null 이면 전체 덮어쓰기) - * @param array{apply:array, added_count:int, changed_count:int, has_symlink:bool}|null $applyStats computeApplyList 산출 통계 - */ - private function summarizeApplyMode(bool $prune, ?array $applyList, ?array $applyStats): void - { - if ($prune) { - $this->line(__('settings.core_update.apply_mode_prune')); - $this->newLine(); - - return; - } - - if ($applyList === null) { - // 백업 부재 또는 산출 실패 → 전체 덮어쓰기로 회귀 - $this->warn(__('settings.core_update.apply_mode_fallback')); - $this->newLine(); - - return; - } - - // 증분 모드 - $added = $applyStats['added_count'] ?? 0; - $changed = $applyStats['changed_count'] ?? 0; - $this->line(__('settings.core_update.apply_mode_incremental', [ - 'added' => $added, - 'changed' => $changed, - ])); - $this->line(__('settings.core_update.apply_mode_incremental_prune_hint')); - $this->newLine(); - } - /** * 업데이트 로그를 파일로 저장합니다. * @@ -1261,125 +1105,6 @@ class CoreUpdateCommand extends Command ]); } - /** - * 업그레이드 스텝 재실행 명령을 실행 사용자 권한에 맞는 안내와 함께 출력합니다. - * - * 배경: sudo(root) 로 `core:update` 를 실행하면 업그레이드 스텝 spawn 자식이 root 로 - * 돌아가고, 그 자식이 실패(proc_open 미지원 · 비정상 종료 · silent skip)하면 스텝이 - * 미실행 상태로 남아 운영자에게 재실행을 안내한다. 이때 운영자가 안내받은 명령을 그대로 - * root 로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어, 이후 웹서버 - * (php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다. - * - * 실행 환경을 4가지로 분기해 안내한다: - * 1. non-root (일반 SSH 사용자 = 파일 소유자) 또는 posix 미지원(Windows 등) - * → 명령만 그대로 안내 (권한 분기 불필요, 공유 호스팅 = 웹서버·PHP·실행 유저 동일 포함). - * 2. root 실행 + 웹서버 계정 식별 가능 + 실행 사용자와 다름 - * → `sudo -u {webUser}` 로 재실행 안내 + root 그대로 실행 시 위험 경고. - * 3. root 실행 + 웹서버 계정을 root 로 추정 (root 로 서비스하는 비표준/컨테이너 구성) - * → 권한 문제 없으므로 명령만 그대로 안내. - * 4. root 실행 + 웹서버 계정 식별 불가 (스냅샷/추정 실패) - * → 명령 그대로 안내하되 "웹서버 계정으로 실행" 일반 경고 (계정명 미상). - * - * @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령 - */ - private function surfaceResumeCommandWithPermissionGuidance(string $resumeCommand): void - { - [$mode, $webUser] = $this->classifyResumeExecutionContext(); - - $this->renderResumeGuidance($resumeCommand, $mode, $webUser); - } - - /** - * 실행 환경 모드에 따라 재실행 명령과 권한 안내를 콘솔에 출력합니다. - * - * `classifyResumeExecutionContext()` 판정 결과를 입력으로 받아 출력만 담당한다 — - * posix/파일시스템 상태에 의존하지 않으므로 4가지 모드 전부 결정적으로 테스트 가능하다. - * - * @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령 - * @param string $mode `classifyResumeExecutionContext()` 가 반환한 실행 환경 모드 - * @param string|null $webUser 웹서버 계정명 (`root_web_known` 일 때만 유효) - */ - private function renderResumeGuidance(string $resumeCommand, string $mode, ?string $webUser): void - { - // 케이스 1·3: 권한 분기 불필요 — 명령만 그대로 안내. - // - non_root: 일반 SSH 사용자 / 공유 호스팅(웹서버=PHP=실행 유저 동일) / posix 미지원 - // - root_web_symmetric: root 로 서비스하는 구성 → 재실행도 root 로 무해 - if ($mode === 'non_root' || $mode === 'root_web_symmetric') { - $this->line(" {$resumeCommand}"); - - return; - } - - // 케이스 2: root 실행 + 웹서버 계정 식별 → 웹서버 권한 재실행 안내. - if ($mode === 'root_web_known' && $webUser !== null) { - $this->line(" sudo -u {$webUser} {$resumeCommand}"); - $this->newLine(); - $this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 위 명령을 root 로 그대로 실행하면 업그레이드'); - $this->warn(" 스텝이 생성하는 파일이 root 소유가 되어 이후 웹서버({$webUser}) 요청이 쓰기 실패할 수"); - $this->warn(" 있습니다. 반드시 웹서버 계정({$webUser})으로 재실행하세요."); - - return; - } - - // 케이스 4: root 실행 + 웹서버 계정 식별 불가 — 계정명 미상 상태의 일반 경고. - $this->line(" sudo -u <웹서버계정> {$resumeCommand}"); - $this->newLine(); - $this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 웹서버 계정을 자동으로 식별하지 못했습니다.'); - $this->warn(' 위 명령을 root 로 그대로 실행하면 업그레이드 스텝이 생성하는 파일이 root 소유가 되어'); - $this->warn(' 이후 웹서버(php-fpm) 요청이 쓰기 실패할 수 있습니다. 웹서버 계정(예: www-data,'); - $this->warn(' nginx, apache)을 확인해 그 계정으로 재실행하세요.'); - } - - /** - * 재실행 안내의 실행 환경을 분류합니다. - * - * 반환하는 모드: - * - `non_root` : posix 미지원 또는 현재 유효 사용자가 root 가 아님 - * (일반 SSH 사용자 = 파일 소유자, 공유 호스팅 대칭 구성 포함). - * - `root_web_known` : root 실행 + 웹서버 계정 식별 성공 + 실행 사용자(root)와 다름. - * - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성). - * - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가). - * - * 웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 storage/bootstrap - * 쓰기 영역 소유자로 추정한다. - * - * @return array{0: string, 1: string|null} [모드, 웹서버 계정명 또는 null] - */ - private function classifyResumeExecutionContext(): array - { - if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) { - return ['non_root', null]; - } - - // root(sudo) 실행이 아니면 일반 SSH 사용자 = 파일 소유자 → 권한 분기 안내 불필요. - // (공유 호스팅에서 웹서버·PHP·실행 유저가 같은 경우도 여기서 non_root 로 처리됨.) - if (posix_geteuid() !== 0) { - return ['non_root', null]; - } - - [$owner] = FilePermissionHelper::inferWebServerOwnership(); - - // 추정 실패 (스냅샷 불가) — 계정명 미상 경고 경로. - if ($owner === false) { - return ['root_web_unknown', null]; - } - - // 웹서버 계정이 root 로 추정됨 — root 로 서비스하는 구성이라 재실행도 root 로 무해. - if ($owner === 0) { - return ['root_web_symmetric', null]; - } - - $entry = posix_getpwuid($owner); - $name = $entry['name'] ?? null; - - // uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란). - if ($name === null) { - return ['root_web_unknown', null]; - } - - return ['root_web_known', $name]; - } - /** * `restoreOwnership()` 직후 누적된 권한 정상화 실패 경고를 콘솔/로그에 즉시 노출합니다. * diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php index 04c37a98..19c8f690 100644 --- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php +++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php @@ -164,7 +164,6 @@ class ExecuteUpgradeStepsCommand extends Command } $stepsExecuted = 0; - $stepsDiscovered = 0; try { $service->runUpgradeSteps( @@ -175,9 +174,6 @@ class ExecuteUpgradeStepsCommand extends Command $stepsExecuted++; }, $force, - function (int $discovered) use (&$stepsDiscovered): void { - $stepsDiscovered = $discovered; - }, ); } catch (UpgradeHandoffException $e) { // 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다. @@ -225,7 +221,6 @@ class ExecuteUpgradeStepsCommand extends Command // mode=fallback 시 in-process 진행 (`spawn_failure_mode`). $this->line('[STEPS_EXECUTED] '.json_encode([ 'count' => $stepsExecuted, - 'discovered' => $stepsDiscovered, 'fromVersion' => $from, 'toVersion' => $to, ], JSON_UNESCAPED_UNICODE)); @@ -247,11 +242,6 @@ class ExecuteUpgradeStepsCommand extends Command if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) { $this->info('캐시 정리 (config/route/view/services/packages)'); $service->clearAllCaches(); - // clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가 - // 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를 - // 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다. - // spawn 자식·steps-only 는 이 블록을 스킵하고 부모(CoreUpdateCommand)가 재생성한다. - \App\Support\ConfigCacheHelper::rebuild(); // 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이 // 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump. // spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵. diff --git a/app/Console/Commands/Extension/UpdateAutoloadCommand.php b/app/Console/Commands/Extension/UpdateAutoloadCommand.php index ff3501db..001a3a06 100644 --- a/app/Console/Commands/Extension/UpdateAutoloadCommand.php +++ b/app/Console/Commands/Extension/UpdateAutoloadCommand.php @@ -3,9 +3,6 @@ namespace App\Console\Commands\Extension; use App\Extension\ExtensionManager; -use App\Extension\HookCacheManager; -use App\Extension\ModuleManager; -use App\Extension\PluginManager; use Illuminate\Console\Command; class UpdateAutoloadCommand extends Command @@ -27,12 +24,8 @@ class UpdateAutoloadCommand extends Command /** * Execute the console command. */ - public function handle( - ExtensionManager $extensionManager, - HookCacheManager $hookCacheManager, - ModuleManager $moduleManager, - PluginManager $pluginManager, - ): int { + public function handle(ExtensionManager $extensionManager): int + { $this->info('확장 오토로드 파일을 생성합니다...'); try { @@ -41,16 +34,6 @@ class UpdateAutoloadCommand extends Command $this->info('오토로드 파일이 성공적으로 생성되었습니다.'); $this->line(' → bootstrap/cache/autoload-extensions.php'); - // 오토로드 캐시와 동일 생명주기의 정적 훅 매핑 캐시도 함께 재생성. - // 코어 업데이트(clearAllCaches → 이 커맨드) 시 코어 리스너 추가/변경/삭제가 - // 캐시에 반영되도록 보장한다 (미갱신 시 stale 매핑으로 훅 발화 누락/과잉 위험). - $moduleManager->loadModules(); - $pluginManager->loadPlugins(); - $hookCacheManager->generate($moduleManager, $pluginManager); - - $this->info('정적 훅 매핑 캐시가 재생성되었습니다.'); - $this->line(' → bootstrap/cache/hooks.php'); - return Command::SUCCESS; } catch (\Exception $e) { $this->error('오토로드 파일 생성 중 오류가 발생했습니다: '.$e->getMessage()); diff --git a/app/Console/Commands/HooksCacheCommand.php b/app/Console/Commands/HooksCacheCommand.php deleted file mode 100644 index e398e9d5..00000000 --- a/app/Console/Commands/HooksCacheCommand.php +++ /dev/null @@ -1,57 +0,0 @@ -loadModules(); - $pluginManager->loadPlugins(); - - $counts = $hookCacheManager->generate($moduleManager, $pluginManager); - - $this->info(__('hooks.cache_generated', [ - 'core' => $counts['core'], - 'modules' => $counts['modules'], - 'plugins' => $counts['plugins'], - 'path' => $hookCacheManager->getCacheFilePath(), - ])); - - return Command::SUCCESS; - } -} diff --git a/app/Console/Commands/HooksClearCommand.php b/app/Console/Commands/HooksClearCommand.php deleted file mode 100644 index dcdff9ca..00000000 --- a/app/Console/Commands/HooksClearCommand.php +++ /dev/null @@ -1,45 +0,0 @@ -isCached()) { - $this->info(__('hooks.cache_absent')); - - return Command::SUCCESS; - } - - $hookCacheManager->clear(); - $this->info(__('hooks.cache_cleared')); - - return Command::SUCCESS; - } -} diff --git a/app/Console/Commands/Module/ClearModuleCacheCommand.php b/app/Console/Commands/Module/ClearModuleCacheCommand.php index 6c2d5362..1fad57b9 100644 --- a/app/Console/Commands/Module/ClearModuleCacheCommand.php +++ b/app/Console/Commands/Module/ClearModuleCacheCommand.php @@ -5,7 +5,6 @@ namespace App\Console\Commands\Module; use App\Contracts\Extension\CacheInterface; use App\Contracts\Repositories\ModuleRepositoryInterface; use App\Extension\ModuleManager; -use App\Services\ExtensionBundleService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -28,8 +27,7 @@ class ClearModuleCacheCommand extends Command public function __construct( private ModuleManager $moduleManager, private ModuleRepositoryInterface $moduleRepository, - private CacheInterface $cache, - private ExtensionBundleService $bundleService + private CacheInterface $cache ) { parent::__construct(); } @@ -88,9 +86,6 @@ class ClearModuleCacheCommand extends Command $clearedCount += $this->clearModuleCache($module->getIdentifier()); } - // 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제) - $clearedCount += $this->bundleService->clearBundles('module'); - $this->info('✅ '.__('modules.commands.cache_clear.success_all', ['count' => $clearedCount])); } diff --git a/app/Console/Commands/Plugin/ClearPluginCacheCommand.php b/app/Console/Commands/Plugin/ClearPluginCacheCommand.php index 0dd0aa42..1fd77bb3 100644 --- a/app/Console/Commands/Plugin/ClearPluginCacheCommand.php +++ b/app/Console/Commands/Plugin/ClearPluginCacheCommand.php @@ -5,7 +5,6 @@ namespace App\Console\Commands\Plugin; use App\Contracts\Extension\CacheInterface; use App\Contracts\Repositories\PluginRepositoryInterface; use App\Extension\PluginManager; -use App\Services\ExtensionBundleService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -28,8 +27,7 @@ class ClearPluginCacheCommand extends Command public function __construct( private PluginManager $pluginManager, private PluginRepositoryInterface $pluginRepository, - private CacheInterface $cache, - private ExtensionBundleService $bundleService + private CacheInterface $cache ) { parent::__construct(); } @@ -88,9 +86,6 @@ class ClearPluginCacheCommand extends Command $clearedCount += $this->clearPluginCache($plugin->getIdentifier()); } - // 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제) - $clearedCount += $this->bundleService->clearBundles('plugin'); - $this->info('✅ '.__('plugins.commands.cache_clear.success_all', ['count' => $clearedCount])); } diff --git a/app/Console/Commands/Template/ClearTemplateCacheCommand.php b/app/Console/Commands/Template/ClearTemplateCacheCommand.php index ca13ec43..8c8a8632 100644 --- a/app/Console/Commands/Template/ClearTemplateCacheCommand.php +++ b/app/Console/Commands/Template/ClearTemplateCacheCommand.php @@ -6,7 +6,6 @@ use App\Contracts\Extension\CacheInterface; use App\Extension\TemplateManager; use App\Models\Template; use App\Models\TemplateLayout; -use App\Services\ExtensionBundleService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -28,8 +27,7 @@ class ClearTemplateCacheCommand extends Command */ public function __construct( private TemplateManager $templateManager, - private CacheInterface $cache, - private ExtensionBundleService $bundleService + private CacheInterface $cache ) { parent::__construct(); } @@ -155,9 +153,6 @@ class ClearTemplateCacheCommand extends Command $this->cache->forget('templates.active.user'); $clearedCount += 2; - // 5. 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신) - $clearedCount += $this->bundleService->clearBundles(); - $this->info('✅ '.__('templates.commands.cache_clear.success_all', [ 'count' => $clearedCount, ])); diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php index c7a3f532..8a72e698 100644 --- a/app/Extension/AbstractModule.php +++ b/app/Extension/AbstractModule.php @@ -1190,29 +1190,6 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte return $result; } - /** - * 빌드된 에셋의 절대 파일 경로를 반환합니다. - * - * `getBuiltAssetPaths()` 는 module.json 의 상대 output 경로를 돌려주므로 - * 파일을 실제로 읽으려면 모듈 루트(`getModulePath()`: 활성 dir 또는 `_bundled` - * 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합(ExtensionBundleService)이 - * `getAssetFilePath()` 의 `base_path("modules/{id}/...")` 하드코딩을 복제하지 - * 않고 `_bundled` 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다. - * - * @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...'] - */ - public function getBuiltAssetAbsolutePaths(): array - { - $relative = $this->getBuiltAssetPaths(); - $result = []; - - foreach ($relative as $kind => $output) { - $result[$kind] = $this->getModulePath().'/'.$output; - } - - return $result; - } - /** * 모듈 스토리지 드라이버 인스턴스 반환 * diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php index 1e76a1b8..5dd4ad10 100644 --- a/app/Extension/AbstractPlugin.php +++ b/app/Extension/AbstractPlugin.php @@ -1011,30 +1011,6 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte return $result; } - /** - * 빌드된 에셋의 절대 파일 경로를 반환합니다. - * - * `getBuiltAssetPaths()` 는 plugin.json 의 상대 output 경로를 돌려주므로 - * 파일을 실제로 읽으려면 플러그인 루트(`getPluginPath()`: 활성 dir 또는 - * `_bundled` 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합 - * (ExtensionBundleService)이 `getAssetFilePath()` 의 - * `base_path("plugins/{id}/...")` 하드코딩을 복제하지 않고 `_bundled` - * 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다. - * - * @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...'] - */ - public function getBuiltAssetAbsolutePaths(): array - { - $relative = $this->getBuiltAssetPaths(); - $result = []; - - foreach ($relative as $kind => $output) { - $result[$kind] = $this->getPluginPath().'/'.$output; - } - - return $result; - } - /** * 플러그인이 설정 페이지를 가지고 있는지 확인합니다. * diff --git a/app/Extension/ExtensionManager.php b/app/Extension/ExtensionManager.php index aa085ff1..99447f04 100644 --- a/app/Extension/ExtensionManager.php +++ b/app/Extension/ExtensionManager.php @@ -5,9 +5,6 @@ namespace App\Extension; use App\Contracts\Repositories\ModuleRepositoryInterface; use App\Contracts\Repositories\PluginRepositoryInterface; use App\Extension\Helpers\GithubHelper; -use App\Rules\ValidExtensionIdentifier; -use App\Support\ConfigCacheHelper; -use Composer\Autoload\ClassLoader; use Illuminate\Console\Command; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; @@ -40,6 +37,14 @@ class ExtensionManager $this->autoloadFilePath = base_path('bootstrap/cache/autoload-extensions.php'); } + /** + * 설치된 모듈/플러그인의 오토로드 파일을 생성합니다. + * + * composer.json을 수정하지 않고, bootstrap/cache/autoload-extensions.php 파일을 생성하여 + * 런타임에 Composer ClassLoader에 PSR-4 네임스페이스를 등록합니다. + * + * 테스트 환경(APP_ENV=testing)에서는 자동으로 스킵됩니다. + */ public function updateComposerAutoload(): void { // 테스트 환경에서 오토로드 업데이트 스킵 (성능 최적화) @@ -56,41 +61,6 @@ class ExtensionManager // 신규 네임스페이스(beta 업그레이드로 추가된 Seeder/Model 등) 의 autoload 가 // 실패하지 않도록 런타임 재등록을 수행한다. $this->reregisterRuntimeAutoload(); - - // 정적 훅 매핑 캐시도 오토로드 캐시와 나란히 재생성. - // 훅 매핑은 확장 install/activate/deactivate/uninstall/update 시에만 바뀌므로 - // 이 지점에 편승하면 별도 무효화 발굴 없이 오토로드 캐시와 동일 생명주기를 갖는다. - $this->regenerateHookCache(); - - // config 캐시도 함께 재생성. 확장 설치/삭제/업데이트는 활성 확장 목록을 바꾸므로 - // (optimizeSystem 이 config:cache 를 만든 시점의 스냅샷이 stale 해짐), 오토로드/훅 - // 캐시와 동일 생명주기로 config 캐시를 재빌드해 캐시가 비활성 상태로 남지 않게 한다. - // activate/deactivate 는 이 메서드를 거치지 않으므로 각 Manager 에서 별도 호출한다. - ConfigCacheHelper::rebuild(); - } - - /** - * 정적 훅 매핑 캐시(bootstrap/cache/hooks.php)를 재생성합니다. - * - * 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다. - * 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다. - */ - protected function regenerateHookCache(): void - { - try { - $moduleManager = app(ModuleManager::class); - $moduleManager->loadModules(); - - $pluginManager = app(PluginManager::class); - $pluginManager->loadPlugins(); - - app(HookCacheManager::class)->generate($moduleManager, $pluginManager); - } catch (\Throwable $e) { - // 훅 캐시 생성 실패는 치명적이지 않다 — 부팅 시 스캔 폴백이 안전망. - Log::warning('훅 매핑 캐시 재생성 실패 (스캔 폴백으로 동작)', [ - 'error' => $e->getMessage(), - ]); - } } /** @@ -108,7 +78,7 @@ class ExtensionManager */ protected function reregisterRuntimeAutoload(): void { - if (! class_exists(ClassLoader::class, false)) { + if (! class_exists(\Composer\Autoload\ClassLoader::class, false)) { return; } @@ -116,7 +86,7 @@ class ExtensionManager return; } - $loaders = ClassLoader::getRegisteredLoaders(); + $loaders = \Composer\Autoload\ClassLoader::getRegisteredLoaders(); if (empty($loaders)) { return; } @@ -165,13 +135,8 @@ class ExtensionManager $pluginAutoloads['vendor_autoloads'] ); - // 확장 소스 classmap 생성 (FQCN → 상대경로). - // 런타임에 ClassLoader::addClassMap() 으로 등록되어 findFile 이 파일시스템 스캔 없이 - // 즉시 경로를 반환하도록 한다(성능). 클래스 로딩은 여전히 lazy — 사용 시점에만 include. - $srcClassmap = $this->buildSourceClassmap($psr4); - // 파일 내용 생성 - $content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads, $srcClassmap); + $content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads); // 디렉토리 확인 $dir = dirname($this->autoloadFilePath); @@ -186,7 +151,6 @@ class ExtensionManager 'path' => $this->autoloadFilePath, 'psr4_count' => count($psr4), 'classmap_count' => count($classmap), - 'src_classmap_count' => count($srcClassmap), 'files_count' => count($files), 'vendor_autoloads_count' => count($vendorAutoloads), ]); @@ -200,7 +164,7 @@ class ExtensionManager * @param array $files 헬퍼 파일 목록 * @return string PHP 파일 내용 */ - protected function buildAutoloadFileContent(array $psr4, array $classmap, array $files = [], array $vendorAutoloads = [], array $srcClassmap = []): string + protected function buildAutoloadFileContent(array $psr4, array $classmap, array $files = [], array $vendorAutoloads = []): string { $generatedAt = now()->toDateTimeString(); @@ -209,14 +173,12 @@ class ExtensionManager $classmapJson = json_encode($classmap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $filesJson = json_encode($files, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $vendorAutoloadsJson = json_encode($vendorAutoloads, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $srcClassmapJson = json_encode($srcClassmap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); // JSON을 PHP 배열 문법으로 변환 $psr4Php = $this->jsonToPhpArray($psr4Json, 1); $classmapPhp = $this->jsonToPhpArray($classmapJson, 1); $filesPhp = $this->jsonToPhpArray($filesJson, 1); $vendorAutoloadsPhp = $this->jsonToPhpArray($vendorAutoloadsJson, 1); - $srcClassmapPhp = $this->jsonToPhpArray($srcClassmapJson, 1); return << {$psr4Php}, 'classmap' => {$classmapPhp}, - 'src_classmap' => {$srcClassmapPhp}, 'files' => {$filesPhp}, 'vendor_autoloads' => {$vendorAutoloadsPhp}, ]; @@ -279,7 +240,7 @@ PHP; * * public/index.php 및 artisan에서 호출됩니다. * - * @param ClassLoader $loader Composer ClassLoader 인스턴스 + * @param \Composer\Autoload\ClassLoader $loader Composer ClassLoader 인스턴스 */ public static function registerExtensionAutoload($loader): void { @@ -305,17 +266,6 @@ PHP; } } - // 확장 소스 classmap 등록 (FQCN → 절대경로). - // findFile 이 파일시스템 스캔 없이 즉시 반환하도록 in-memory 맵에 추가한다(lazy include). - // PSR-4 등록은 위에서 유지되므로 classmap 에 없는 클래스는 PSR-4 폴백(안전망). - if (! empty($autoloads['src_classmap'])) { - $absoluteClassmap = []; - foreach ($autoloads['src_classmap'] as $fqcn => $relPath) { - $absoluteClassmap[$fqcn] = base_path($relPath); - } - $loader->addClassMap($absoluteClassmap); - } - // Classmap 파일 로드 (module.php, plugin.php) if (! empty($autoloads['classmap'])) { foreach ($autoloads['classmap'] as $file) { @@ -521,130 +471,6 @@ PHP; return ['psr4' => $psr4, 'classmap' => $classmap, 'files' => $files, 'vendor_autoloads' => $vendorAutoloads]; } - /** - * PSR-4 매핑을 기반으로 확장 소스 클래스의 classmap 을 생성합니다. - * - * 각 PSR-4 네임스페이스 디렉토리를 재귀 스캔하여 PHP 파일마다 FQCN → 상대경로 를 - * 산출한다. 이 classmap 은 런타임에 `ClassLoader::addClassMap()` 으로 등록되어 - * `findFile()` 이 파일시스템 스캔 없이 즉시 경로를 반환하도록 한다(성능: cold OPcache / - * 느린 파일시스템 환경에서 findFile stat 비용 제거). 클래스 로딩은 여전히 지연(lazy)이다. - * - * 확장 **소스** 클래스만 대상으로 하며, 확장별 독립 vendor(서드파티)는 각 확장의 - * `vendor/autoload.php`(별도 ClassLoader) 가 담당하므로 여기서 다루지 않는다 - * (vendor 격리 불변조건 보존). - * - * @param array> $psr4 네임스페이스 → base_path 상대경로(들) - * @return array FQCN → base_path 상대경로 (.php 포함) - */ - protected function buildSourceClassmap(array $psr4): array - { - $classmap = []; - - foreach ($psr4 as $namespace => $paths) { - foreach ((array) $paths as $relativePath) { - $absoluteDir = base_path($relativePath); - - if (! is_dir($absoluteDir)) { - continue; - } - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($absoluteDir, \RecursiveDirectoryIterator::SKIP_DOTS) - ); - - foreach ($iterator as $file) { - if ($file->getExtension() !== 'php') { - continue; - } - - $fqcn = $this->extractFqcnFromFile($file->getPathname()); - if ($fqcn === null) { - continue; - } - - // PSR-4 계약상 이 파일의 네임스페이스는 $namespace 로 시작해야 한다. - // (다른 네임스페이스 파일은 다른 PSR-4 항목이 담당 — 중복 방지) - if (! str_starts_with($fqcn, rtrim($namespace, '\\'))) { - continue; - } - - // base_path 상대경로로 정규화 (autoload-extensions.php 의 다른 경로와 일관) - $rel = str_replace('\\', '/', substr($file->getPathname(), strlen(base_path()) + 1)); - $classmap[$fqcn] = $rel; - } - } - } - - ksort($classmap); - - return $classmap; - } - - /** - * PHP 파일에서 FQCN(네임스페이스 + 클래스/인터페이스/트레이트/enum 명)을 추출합니다. - * - * 토큰 기반 파싱으로 파일당 최상위 선언 1개의 FQCN 을 반환한다. 선언이 없으면 null. - * - * @param string $filePath PHP 파일 절대경로 - * @return string|null FQCN 또는 null - */ - protected function extractFqcnFromFile(string $filePath): ?string - { - $contents = @file_get_contents($filePath); - if ($contents === false) { - return null; - } - - $tokens = token_get_all($contents); - $namespace = ''; - $count = count($tokens); - - for ($i = 0; $i < $count; $i++) { - $token = $tokens[$i]; - if (! is_array($token)) { - continue; - } - - // 네임스페이스 수집 - if ($token[0] === T_NAMESPACE) { - $namespace = ''; - for ($j = $i + 1; $j < $count; $j++) { - $t = $tokens[$j]; - if ($t === ';' || $t === '{') { - break; - } - if (is_array($t) && in_array($t[0], [T_STRING, T_NS_SEPARATOR], true)) { - $namespace .= $t[1]; - } elseif (is_array($t) && defined('T_NAME_QUALIFIED') && $t[0] === T_NAME_QUALIFIED) { - $namespace .= $t[1]; - } - } - - continue; - } - - // 최상위 선언(class/interface/trait/enum) 감지 - if (in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT], true) - || (defined('T_ENUM') && $token[0] === T_ENUM)) { - // ::class, new class 등 익명/상수 사용 배제: 다음 유효 토큰이 T_STRING 이어야 함 - for ($j = $i + 1; $j < $count; $j++) { - $t = $tokens[$j]; - if (is_array($t) && $t[0] === T_WHITESPACE) { - continue; - } - if (is_array($t) && $t[0] === T_STRING) { - $class = $t[1]; - - return $namespace !== '' ? $namespace.'\\'.$class : $class; - } - break; - } - } - } - - return null; - } - /** * 모듈 identifier를 네임스페이스로 변환합니다. * @@ -814,7 +640,7 @@ PHP; $failed = false; $message = ''; - (new ValidExtensionIdentifier)->validate( + (new \App\Rules\ValidExtensionIdentifier)->validate( 'identifier', $identifier, function ($msg) use (&$failed, &$message) { @@ -892,6 +718,7 @@ PHP; return ! empty($this->getComposerDependenciesAt($path)); } + /** * 확장의 composer.json에서 외부 패키지 의존성 목록을 반환합니다. * @@ -1269,6 +1096,7 @@ PHP; * * @param string $zipPath ZIP 파일 경로 * @param string $extractDir 추출 대상 디렉토리 + * @return void * * @throws \RuntimeException 추출 실패 시 */ @@ -1288,6 +1116,7 @@ PHP; * * @param string $zipPath ZIP 파일 경로 * @param string $extractDir 추출 대상 디렉토리 + * @return void * * @throws \RuntimeException 추출 실패 시 */ @@ -1309,7 +1138,7 @@ PHP; /** * unzip 명령어 사용 가능 여부를 확인합니다. * - * @return bool unzip 실행 파일이 PATH 에 존재하면 true + * @return bool */ public function isUnzipAvailable(): bool { diff --git a/app/Extension/Helpers/CoreBackupHelper.php b/app/Extension/Helpers/CoreBackupHelper.php index dfcecb6d..08d305b7 100644 --- a/app/Extension/Helpers/CoreBackupHelper.php +++ b/app/Extension/Helpers/CoreBackupHelper.php @@ -238,7 +238,6 @@ class CoreBackupHelper $newDirs = []; $protectedSet = self::normalizeProtectedSet($protectedPaths); - $allowedTargetSet = self::normalizeProtectedSet($targets); $excludeSet = array_values(array_filter(array_map('trim', $excludes))); foreach ($targets as $target) { @@ -247,9 +246,8 @@ class CoreBackupHelper continue; } - // target 자체가 보호 경로면 스킵 (단, targets 에 더 구체적으로 명시된 - // 경로는 상위 protected 를 오버라이드 — _bundled 갱신 허용) - if (self::isWithinProtectedPath($target, $protectedSet, $allowedTargetSet)) { + // target 자체가 보호 경로면 스킵 + if (self::isWithinProtectedPath($target, $protectedSet)) { continue; } @@ -286,7 +284,7 @@ class CoreBackupHelper if (self::matchesExcludes($relative, $excludeSet)) { continue; } - if (self::isWithinProtectedPath($relative, $protectedSet, $allowedTargetSet)) { + if (self::isWithinProtectedPath($relative, $protectedSet)) { continue; } @@ -338,177 +336,6 @@ class CoreBackupHelper ]; } - /** - * 코어(신 버전)가 실제로 추가·변경한 파일 목록(적용 대상)을 3-way 비교로 산출합니다. - * - * `core:update` 기본(증분) 모드에서 `applyUpdate()` 가 참조하는 "적용 대상 집합"을 - * 만든다. 코어가 건드리지 않은 파일(base == theirs)은 목록에서 제외되어, 활성 - * 디렉토리에 남은 현재 상태(사용자 수정 포함 가능)를 그대로 보존한다. - * - * 3-way 판정: - * - base = 구버전 원본 = 백업 스냅샷 (`$backupPath/$target`) - * - theirs = 신 버전 = _pending 소스 (`$sourcePath/$target`) - * - 규칙: - * · base 없음 (theirs 에만 존재) → added (신규 파일 — 적용) - * · base 있고 size 또는 md5 다름 → changed (코어가 변경 — 적용) - * · base 있고 size·md5 동일 → 제외 (코어 미변경 — 스킵/보존) - * - * 성능: size 가 다르면 md5 를 계산하지 않는다(즉시 changed). size 가 같을 때만 - * `md5_file`. mtime 은 비교하지 않는다 — `_pending` 은 방금 추출되어 mtime 이 - * 전부 추출 시각이므로 항상 "다름"으로 오판한다. - * - * symlink 는 목록에서 제외한다 — 링크 자체는 `copyDirectory` 의 별도 링크 처리 - * 경로가 담당하며, 증분 모드에서도 항상 재생성되어야 한다(target 추적 없이 동일 - * 링크). 따라서 반환값 `is_partial` 이 true 여도 링크는 정상 반영된다. - * - * `writeNewFilesManifest()` 와 동일한 순회 구조·가드(`excludes`/`protectedPaths`)를 - * 재사용한다. 두 산출을 각각 별도 순회하지만, 반환값은 in-memory 로 즉시 소비되어 - * 파일화 비용이 없다(롤백용 `_new_files_manifest.json` 은 별도). - * - * @param string $backupPath 백업 디렉토리 경로 (= base, 활성 사전 스냅샷) - * @param string $sourcePath 소스 디렉토리 경로 (= theirs, _pending 신 버전) - * @param array $targets 처리할 target 경로 목록 (`app.update.targets`) - * @param array $protectedPaths 보호 경로 목록 (목록에서 제외) - * @param array $excludes 제외 패턴 목록 (예: ['node_modules', '.git']) - * @return array{apply:array, added_count:int, changed_count:int, has_symlink:bool} - * apply = 적용 대상 상대경로 목록(정렬됨, 슬래시 정규화) - */ - public static function computeApplyList( - string $backupPath, - string $sourcePath, - array $targets, - array $protectedPaths, - array $excludes, - ): array { - $added = []; - $changed = []; - $hasSymlink = false; - - $protectedSet = self::normalizeProtectedSet($protectedPaths); - $allowedTargetSet = self::normalizeProtectedSet($targets); - $excludeSet = array_values(array_filter(array_map('trim', $excludes))); - - foreach ($targets as $target) { - $target = trim($target); - if ($target === '') { - continue; - } - - if (self::isWithinProtectedPath($target, $protectedSet, $allowedTargetSet)) { - continue; - } - - $sourceTargetPath = $sourcePath.DIRECTORY_SEPARATOR.$target; - if (! file_exists($sourceTargetPath)) { - continue; - } - - // 단일 파일 target - if (is_file($sourceTargetPath) && ! is_link($sourceTargetPath)) { - $rel = self::normalizeRelative($target); - $backupFilePath = $backupPath.DIRECTORY_SEPARATOR.$target; - self::classifyForApply($rel, $sourceTargetPath, $backupFilePath, $added, $changed); - - continue; - } - - if (is_link($sourceTargetPath)) { - $hasSymlink = true; - - continue; - } - - if (! is_dir($sourceTargetPath)) { - continue; - } - - // 디렉토리 target — 재귀 비교 - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($sourceTargetPath, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST, - ); - - foreach ($iterator as $item) { - /** @var SplFileInfo $item */ - $absolute = $item->getPathname(); - $relative = self::normalizeRelative($target.'/'.ltrim(substr($absolute, strlen($sourceTargetPath)), DIRECTORY_SEPARATOR.'/')); - - if (self::matchesExcludes($relative, $excludeSet)) { - continue; - } - if (self::isWithinProtectedPath($relative, $protectedSet, $allowedTargetSet)) { - continue; - } - - // symlink 는 목록 제외 — 링크 처리 경로가 별도 담당 - if ($item->isLink()) { - $hasSymlink = true; - - continue; - } - - // 디렉토리 자체는 목록에 담지 않는다 — 파일 단위 적용이 디렉토리를 - // 필요 시 생성한다(copyDirectory 가 대상 디렉토리를 ensure). - if (! $item->isFile()) { - continue; - } - - $backupItemPath = $backupPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative); - self::classifyForApply($relative, $absolute, $backupItemPath, $added, $changed); - } - } - - $apply = array_values(array_unique(array_merge($added, $changed))); - sort($apply, SORT_STRING); - - return [ - 'apply' => $apply, - 'added_count' => count($added), - 'changed_count' => count($changed), - 'has_symlink' => $hasSymlink, - ]; - } - - /** - * 3-way 판정으로 파일 하나를 added/changed 로 분류하거나 제외합니다. - * - * base(백업) 부재 → added, size 다름 또는 md5 다름 → changed, 동일 → 제외. - * - * @param string $relative 슬래시 정규화된 상대 경로 (목록 등재값) - * @param string $theirsAbsolute 신 버전(_pending) 파일 절대 경로 - * @param string $baseAbsolute 백업(base) 파일 절대 경로 - * @param array $added added 누적 배열 (참조) - * @param array $changed changed 누적 배열 (참조) - */ - private static function classifyForApply( - string $relative, - string $theirsAbsolute, - string $baseAbsolute, - array &$added, - array &$changed, - ): void { - if (! file_exists($baseAbsolute) || is_dir($baseAbsolute)) { - $added[] = $relative; - - return; - } - - // size 선필터 — 다르면 md5 생략하고 즉시 changed - $theirsSize = @filesize($theirsAbsolute); - $baseSize = @filesize($baseAbsolute); - if ($theirsSize !== $baseSize) { - $changed[] = $relative; - - return; - } - - // size 동일 → 내용 비교 - if (@md5_file($theirsAbsolute) !== @md5_file($baseAbsolute)) { - $changed[] = $relative; - } - // 동일하면 제외 (코어 미변경 — 스킵) - } - /** * 백업 디렉토리의 manifest 를 로드해 활성 디렉토리에서 신 버전 신규 파일을 정리합니다. * @@ -664,61 +491,23 @@ class CoreBackupHelper /** * 상대 경로가 protected_paths 목록의 어떤 항목 하위에 위치하는지 검사합니다. - * - * `$allowedTargetSet` 이 주어지면 "targets 에 명시된 더 구체적인 경로" 는 상위 - * protected 를 오버라이드한다. 예: `protected_paths` 에 `modules` 가 있고 - * `targets` 에 `modules/_bundled` 가 명시되어 있으면, `modules/_bundled/...` 파일은 - * protected 로 차단되지 않고 apply 대상에 포함된다. - * - * 배경: `protected_paths` 의 확장 부모(`modules`/`plugins`/`templates`/`lang-packs`)는 - * "자동 발견 폴백" 이 활성 서브디렉토리(`modules/sirsoft-*`)를 통째로 삭제하는 - * 회귀(#347) 를 막기 위한 방어인데, 정상 증분 흐름에서는 `_bundled` 갱신까지 - * 함께 막아 코어 업데이트로 번들 확장 파일이 반영되지 않는 결함(#452 / 공개 #64)을 - * 유발했다. targets 에 명시된 경로가 protected 보다 더 구체적(하위)이면 예외 처리한다. - * - * @param string $relative 검사할 상대 경로 - * @param array $protectedSet 정규화된 보호 경로 집합 - * @param array $allowedTargetSet 정규화된 targets 집합 (protected 오버라이드 허용) */ - private static function isWithinProtectedPath(string $relative, array $protectedSet, array $allowedTargetSet = []): bool + private static function isWithinProtectedPath(string $relative, array $protectedSet): bool { $relative = self::normalizeRelative($relative); - - // 가장 구체적으로 매칭되는 protected 경로 길이(세그먼트 수)를 찾는다. - $matchedProtectedLen = -1; foreach ($protectedSet as $p) { if ($p === '') { continue; } - if ($relative === $p || str_starts_with($relative, $p.'/')) { - $len = substr_count($p, '/') + 1; - if ($len > $matchedProtectedLen) { - $matchedProtectedLen = $len; - } + if ($relative === $p) { + return true; + } + if (str_starts_with($relative, $p.'/')) { + return true; } } - if ($matchedProtectedLen === -1) { - return false; - } - - // protected 하위이지만, targets 에 더 구체적으로(= 더 깊게) 명시된 경로의 - // 하위이면 오버라이드하여 통과(= 보호 해제). target 이 protected 와 같거나 - // 더 얕으면 오버라이드하지 않는다(예: target `storage` == protected `storage`). - foreach ($allowedTargetSet as $t) { - if ($t === '') { - continue; - } - $targetLen = substr_count($t, '/') + 1; - if ($targetLen <= $matchedProtectedLen) { - continue; - } - if ($relative === $t || str_starts_with($relative, $t.'/')) { - return false; - } - } - - return true; + return false; } /** diff --git a/app/Extension/Helpers/FilePermissionHelper.php b/app/Extension/Helpers/FilePermissionHelper.php index 35e99155..67db687d 100644 --- a/app/Extension/Helpers/FilePermissionHelper.php +++ b/app/Extension/Helpers/FilePermissionHelper.php @@ -32,13 +32,8 @@ class FilePermissionHelper * @param string $relativePath 현재 상대 경로 (내부 재귀용) * @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부 * @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부 - * @param array|null $applyList 적용 대상 파일 상대경로 화이트리스트 - * (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면 - * 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어 - * 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink - * 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님). */ - public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false, ?array $applyList = null): void + public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false): void { if (! File::isDirectory($destination)) { // 신규 디렉토리: 부모 디렉토리의 퍼미션/소유권 상속 @@ -58,36 +53,21 @@ class FilePermissionHelper $destPath = $destination.DIRECTORY_SEPARATOR.$itemName; - // symlink / Windows junction 자체 보존: target 추적 없이 동일 링크 재생성. + // symlink 자체 보존: target 추적 없이 동일 symlink 재생성. // SplFileInfo::isDir() 가 symlink 를 추적하므로 검사 순서가 isDir() 보다 먼저여야 한다. - // 미적용 시 `public/storage` 등 링크가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상. - // - // Windows JUNCTION(`mklink /J`)은 reparse point 지만 PHP `isLink()`/`isDir()` 이 모두 - // false 를 반환한다(`storage:link` 가 Windows 에서 생성하는 형태). isLink() 만 검사하면 - // junction 이 파일로 오판되어 copyFile → `copy(디렉토리)` 로 코어 업데이트가 실패한다. - // isReparsePoint() 로 junction 도 이 분기에 포함시킨다. - if ($item->isLink() || static::isReparsePoint($item->getPathname())) { - if (static::copyLink($item->getPathname(), $destPath)) { + // 미적용 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상. + if ($item->isLink()) { + if (static::copySymlink($item->getPathname(), $destPath)) { continue; } // 복원 실패 (Windows SeCreateSymbolicLink 권한 부족 등) — 일반 복사로 fall-through. - // 단, junction 은 isDir() 이 false 라 아래 else 분기의 copyFile 로 새어 파일 복사가 - // 다시 실패하므로, fall-through 대신 skip 하여 손상 없이 넘어간다. // 운영자가 추후 `php artisan storage:link` 수동 실행으로 회복 가능. - if (! $item->isLink()) { - continue; - } } if ($item->isDir()) { // preserveTopLevelOrphans 는 최상위 한 레벨 한정 — 자식 재귀에는 항상 false 전달. - static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false, applyList: $applyList); + static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false); } else { - // 증분 모드(applyList 지정): 화이트리스트에 없는 파일은 스킵 — - // 코어가 변경하지 않은 파일의 현재 디스크 상태(사용자 수정 포함)를 보존. - if ($applyList !== null && ! isset($applyList[$itemRelativePath])) { - continue; - } static::copyFile($item->getPathname(), $destPath); } } @@ -99,63 +79,25 @@ class FilePermissionHelper } /** - * 경로가 Windows JUNCTION 등 PHP `isLink()` 가 인식하지 못하는 reparse point 인지 판정합니다. + * symlink 자체를 보존 복사합니다 (target 미추적). * - * Windows JUNCTION(`mklink /J`)은 디렉토리 reparse point 로, `is_link()` / `is_file()` / - * `is_dir()` 이 **모두** false 를 반환한다(그러나 경로 항목으로는 존재하며 `readlink()` 로 - * target 을 얻을 수 있다). `php artisan storage:link` 가 Windows 에서 생성하는 - * `public/storage` 가 이 형태다. + * 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 symlink 재생성. + * readlink / symlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through. * - * 주의: Windows 의 `readlink()` 는 일반 파일/디렉토리에도 자기 경로를 반환하므로 junction - * 판별 기준이 될 수 없다. 따라서 "존재하는 경로인데 link/file/dir 어디에도 해당하지 않음" - * 을 판정 기준으로 삼는다. 여기에 추가로 `readlink()` 성공(!== false)을 요구해 존재하지 - * 않는 경로를 배제한다. + * Windows 환경에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하며 일반 사용자는 + * 권한이 없을 가능성이 높다. 실패 시 warning 로그 후 false 반환하여 호출자가 일반 복사로 + * fall-through 하도록 한다. 운영자는 업그레이드 후 `php artisan storage:link` 등 수동 + * 명령으로 symlink 를 회복할 수 있다. * - * 일반 symlink(`is_link()` true)는 호출부가 별도로 처리하므로 여기서는 false 를 반환한다. - * junction 은 Windows 전용이므로 비-Windows 에서는 항상 false. - * - * @param string $path 검사할 경로 - * @return bool junction 등 isLink() 미인식 reparse point 여부 + * @param string $source 소스 symlink 경로 + * @param string $destination 대상 symlink 경로 + * @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백) */ - protected static function isReparsePoint(string $path): bool - { - if (PHP_OS_FAMILY !== 'Windows') { - return false; - } - - // 일반 symlink / 파일 / 디렉토리는 각 검사가 true → junction 아님. - if (is_link($path) || is_file($path) || is_dir($path)) { - return false; - } - - // 위 셋에 모두 해당하지 않으면서 readlink 가 target 을 반환하면 junction(reparse point). - return @readlink($path) !== false; - } - - /** - * symlink 또는 Windows junction 자체를 보존 복사합니다 (target 미추적). - * - * 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 링크 재생성. - * readlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through. - * - * 재생성 전략: - * - 일반 symlink: `symlink()` - * - target 이 디렉토리인 경우(Windows junction 포함): `symlink()` 실패 시 `mklink /J` 로 폴백. - * Windows 에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하지만 junction 은 - * 권한 없이 생성 가능하므로, junction 복원의 실질 경로가 된다. - * - * 모든 재생성 시도 실패 시 warning 로그 후 false 반환. 운영자는 업그레이드 후 - * `php artisan storage:link` 등 수동 명령으로 링크를 회복할 수 있다. - * - * @param string $source 소스 링크(symlink/junction) 경로 - * @param string $destination 대상 링크 경로 - * @return bool 링크 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백) - */ - protected static function copyLink(string $source, string $destination): bool + protected static function copySymlink(string $source, string $destination): bool { $target = @readlink($source); if ($target === false) { - Log::warning('copyLink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]); + Log::warning('copySymlink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]); return false; } @@ -167,29 +109,17 @@ class FilePermissionHelper File::deleteDirectory($destination); } - if (@symlink($target, $destination)) { - return true; + if (! @symlink($target, $destination)) { + Log::warning('copySymlink: symlink 생성 실패 — 일반 복사로 폴백', [ + 'source' => $source, + 'destination' => $destination, + 'target' => $target, + ]); + + return false; } - // symlink 실패 + target 이 디렉토리 → Windows junction 으로 폴백 재생성. - if (PHP_OS_FAMILY === 'Windows' && is_dir($target)) { - exec( - 'cmd /c mklink /J '.escapeshellarg($destination).' '.escapeshellarg($target).' 2>&1', - $out, - $code - ); - if ($code === 0 && is_dir($destination)) { - return true; - } - } - - Log::warning('copyLink: 링크 생성 실패 — 일반 복사로 폴백', [ - 'source' => $source, - 'destination' => $destination, - 'target' => $target, - ]); - - return false; + return true; } /** @@ -227,19 +157,11 @@ class FilePermissionHelper // 소스에 존재하지 않는 항목만 삭제 if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) { - // symlink / Windows junction 은 링크 자체만 제거 — File::deleteDirectory 는 is_dir() - // 추적 검사 후 재귀 삭제하므로 link-to-dir 인 경우 target 의 모든 파일을 삭제하는 - // 사고 발생 가능. 링크 검사가 isDir() 보다 먼저 평가되어야 한다. - // - // junction 은 is_link() 가 false 지만 isReparsePoint() 가 감지한다. junction 링크 - // 제거는 파일 대상 unlink 가 아닌 rmdir() 로 수행해야 target 내용을 보존한다. + // symlink 는 항상 unlink — File::deleteDirectory 는 is_dir() 추적 검사 후 + // 재귀 삭제하므로 symlink-to-dir 인 경우 target 의 모든 파일을 삭제하는 사고 + // 발생 가능. is_link() 가 isDir() 보다 먼저 평가되어야 한다. if (is_link($destItem->getPathname())) { @unlink($destItem->getPathname()); - } elseif (static::isReparsePoint($destItem->getPathname())) { - // junction: rmdir 로 링크만 제거 (target 미추적). 폴백으로 unlink 시도. - if (! @rmdir($destItem->getPathname())) { - @unlink($destItem->getPathname()); - } } elseif ($destItem->isDir()) { File::deleteDirectory($destItem->getPathname()); } else { diff --git a/app/Extension/HookCacheManager.php b/app/Extension/HookCacheManager.php deleted file mode 100644 index 1521200b..00000000 --- a/app/Extension/HookCacheManager.php +++ /dev/null @@ -1,320 +0,0 @@ -cacheFilePath = base_path('bootstrap/cache/hooks.php'); - } - - /** - * 캐시 파일 경로를 반환합니다. - * - * @return string bootstrap/cache/hooks.php 절대 경로 - */ - public function getCacheFilePath(): string - { - return $this->cacheFilePath; - } - - /** - * 캐시 파일 존재 여부를 반환합니다. - * - * @return bool 캐시 파일이 존재하면 true - */ - public function isCached(): bool - { - return File::exists($this->cacheFilePath); - } - - /** - * 캐시 파일을 읽어 반환합니다. - * - * 파일이 없거나 구조가 손상된 경우 null 을 반환하여 호출측이 스캔 폴백하도록 한다. - * - * @return array{core: array>, modules: array>>, plugins: array>>}|null - */ - public function read(): ?array - { - // Request-scoped 메모리 캐시: 한 요청 동안 부팅 경로에서 - // core/module/plugin 등록이 이 메서드를 여러 번 호출한다 - // (호출당 86KB 캐시 파일 재파싱 — OPcache 부재 시 요청당 십수 ms 누적). - // 캐시 파일 내용은 요청 수명 동안 불변이므로 첫 로드 결과를 static 에 보관해 - // 파일 접근을 요청당 1회로 고정한다. self::$memo 는 세 상태를 구분한다: - // - 미설정(unset): 아직 read() 호출 전 - // - false: 이번 요청에서 캐시 미스(파일 부재/손상) 확정 — 재시도 없이 폴백 유지 - // - array: 유효 캐시 데이터 - if (self::$memo !== null) { - return self::$memo === false ? null : self::$memo; - } - - if (! $this->isCached()) { - self::$memo = false; - - return null; - } - - try { - $data = require $this->cacheFilePath; - } catch (\Throwable $e) { - Log::warning('훅 캐시 파일 로드 실패 — 스캔 폴백', [ - 'path' => $this->cacheFilePath, - 'error' => $e->getMessage(), - ]); - self::$memo = false; - - return null; - } - - if (! is_array($data) || ! isset($data['core'], $data['modules'], $data['plugins'])) { - Log::warning('훅 캐시 파일 구조가 올바르지 않습니다 — 스캔 폴백', [ - 'path' => $this->cacheFilePath, - ]); - self::$memo = false; - - return null; - } - - self::$memo = $data; - - return $data; - } - - /** - * 캐시 파일을 삭제합니다. - * - * @return bool 삭제 성공 또는 파일 부재 시 true - */ - public function clear(): bool - { - self::$memo = null; - - if (! $this->isCached()) { - return true; - } - - return File::delete($this->cacheFilePath); - } - - /** - * 정적 훅 매핑을 스캔·리플렉션하여 캐시 파일로 생성합니다. - * - * 모듈/플러그인 리스너는 로드된 Module/Plugin 인스턴스의 getHookListeners() 에서 - * 수집하므로, 호출 전에 각 Manager 의 loadModules()/loadPlugins() 가 선행되어야 한다. - * - * @param ModuleManager $moduleManager 로드 완료된 모듈 매니저 - * @param PluginManager $pluginManager 로드 완료된 플러그인 매니저 - * @return array{core: int, modules: int, plugins: int} 소스별 리스너 수 - */ - public function generate(ModuleManager $moduleManager, PluginManager $pluginManager): array - { - $cache = [ - 'core' => $this->collectCoreListeners(), - 'modules' => $this->collectExtensionListeners($moduleManager->getActiveModules()), - 'plugins' => $this->collectExtensionListeners($pluginManager->getActivePlugins()), - ]; - - $content = $this->buildCacheFileContent($cache); - - $dir = dirname($this->cacheFilePath); - if (! File::isDirectory($dir)) { - File::makeDirectory($dir, 0755, true); - } - - File::put($this->cacheFilePath, $content); - - // 방금 쓴 새 매핑으로 request-scoped 메모리 캐시를 갱신 - // (이후 read() 가 옛 memo 를 반환하지 않도록 무효화). - self::$memo = null; - - $counts = [ - 'core' => count($cache['core']), - 'modules' => array_sum(array_map('count', $cache['modules'])), - 'plugins' => array_sum(array_map('count', $cache['plugins'])), - ]; - - Log::info('훅 매핑 캐시 파일 생성 완료', [ - 'path' => $this->cacheFilePath, - 'core' => $counts['core'], - 'modules' => $counts['modules'], - 'plugins' => $counts['plugins'], - ]); - - return $counts; - } - - /** - * app/Listeners/ 재귀 스캔으로 코어 리스너 항목을 수집합니다. - * - * registerCoreHookListeners() 스캔 로직과 동일하게 HookListenerInterface 구현체만 대상. - * - * @return array> [['listener' => FQCN, 'hooks' => [...], 'dynamic' => bool], ...] - */ - protected function collectCoreListeners(): array - { - $listenersPath = app_path('Listeners'); - - if (! is_dir($listenersPath)) { - return []; - } - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($listenersPath, \RecursiveDirectoryIterator::SKIP_DOTS) - ); - - $entries = []; - - foreach ($iterator as $file) { - if ($file->getExtension() !== 'php') { - continue; - } - - $relativePath = str_replace($listenersPath.DIRECTORY_SEPARATOR, '', $file->getPathname()); - $relativePath = str_replace('.php', '', $relativePath); - $relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath); - $listenerClass = 'App\\Listeners\\'.$relativePath; - - $entry = $this->buildListenerEntry($listenerClass); - if ($entry !== null) { - $entries[] = $entry; - } - } - - return $entries; - } - - /** - * 로드된 모듈/플러그인 인스턴스에서 훅 리스너 항목을 수집합니다. - * - * @param array $extensions 식별자(디렉토리명) => 확장 인스턴스 - * @return array>> 확장 식별자 => 리스너 항목 목록 - */ - protected function collectExtensionListeners(array $extensions): array - { - $result = []; - - foreach ($extensions as $extension) { - if (! method_exists($extension, 'getHookListeners')) { - continue; - } - - $identifier = $extension->getIdentifier(); - $entries = []; - - foreach ($extension->getHookListeners() as $listenerClass) { - $entry = $this->buildListenerEntry($listenerClass); - if ($entry !== null) { - $entries[] = $entry; - } - } - - if (! empty($entries)) { - $result[$identifier] = $entries; - } - } - - return $result; - } - - /** - * 단일 리스너 클래스의 캐시 항목을 생성합니다. - * - * HookListenerInterface 미구현·클래스 부재 시 null 을 반환하여 제외합니다. - * 사전 계산된 getSubscribedHooks() 결과와 동적 훅 여부(registerDynamicHooks 존재)를 함께 저장합니다. - * - * @param string $listenerClass 리스너 FQCN - * @return array{listener: string, hooks: array>, dynamic: bool}|null - */ - protected function buildListenerEntry(string $listenerClass): ?array - { - if (! class_exists($listenerClass)) { - Log::warning('훅 캐시 생성: 리스너 클래스를 찾을 수 없습니다', ['listener' => $listenerClass]); - - return null; - } - - if (! in_array(HookListenerInterface::class, class_implements($listenerClass), true)) { - return null; - } - - try { - $hooks = $listenerClass::getSubscribedHooks(); - } catch (\Throwable $e) { - Log::warning('훅 캐시 생성: getSubscribedHooks() 오류 — 항목 제외', [ - 'listener' => $listenerClass, - 'error' => $e->getMessage(), - ]); - - return null; - } - - return [ - 'listener' => $listenerClass, - 'hooks' => $hooks, - 'dynamic' => method_exists($listenerClass, 'registerDynamicHooks'), - ]; - } - - protected function buildCacheFileContent(array $cache): string - { - $exported = var_export($cache, true); - $generatedAt = now()->toDateTimeString(); - - $header = " $listenerClass, 'source' => $source, @@ -60,49 +62,6 @@ class HookListenerRegistrar return; } - self::applySubscribedHooks($listenerClass, $subscribedHooks, $source); - } - - /** - * 캐시된 구독 훅 매핑으로 리스너를 등록합니다 (클래스 로딩 없음). - * - * bootstrap/cache/hooks.php 에서 읽어온 사전 계산 getSubscribedHooks() 결과를 - * 그대로 받아 HookManager 에 등록한다. register() 와 달리 $listenerClass::getSubscribedHooks() - * 를 호출하지 않으므로 리스너 클래스가 부팅 시점에 오토로딩되지 않는다 — - * 실제 클래스 로딩은 훅 발화 시 클로저 내부 app($listenerClass) 에서 지연 수행된다. - * - * 등록 결과(HookManager 훅 매핑)는 register() 스캔 경로와 바이트 동일해야 한다 - * (동일 applySubscribedHooks 위임). - * - * @param string $listenerClass 리스너 FQCN - * @param array> $subscribedHooks 사전 계산된 getSubscribedHooks() 결과 - * @param string|null $source 등록 출처 ('core', 모듈/플러그인 식별자) - */ - public static function registerFromCache(string $listenerClass, array $subscribedHooks, ?string $source = null): void - { - $key = ($source ?? 'unknown').'::'.$listenerClass; - if (isset(self::$registered[$key])) { - return; // 동일 PHP process 내 중복 등록 방지 (스캔 경로와 동일 계약) - } - - self::applySubscribedHooks($listenerClass, $subscribedHooks, $source); - } - - /** - * 구독 훅 배열을 HookManager 에 등록합니다 (스캔/캐시 경로 공통). - * - * register()(런타임 getSubscribedHooks 호출)와 registerFromCache()(캐시 읽기)가 - * 이 메서드에 위임하여 등록 결과의 동일성을 보장한다. - * - * @param string $listenerClass 리스너 FQCN - * @param array> $subscribedHooks 구독 훅 매핑 - * @param string|null $source 등록 출처 - */ - private static function applySubscribedHooks(string $listenerClass, array $subscribedHooks, ?string $source): void - { - $key = ($source ?? 'unknown').'::'.$listenerClass; - self::$registered[$key] = true; - foreach ($subscribedHooks as $hookName => $config) { $method = $config['method'] ?? 'handle'; $priority = $config['priority'] ?? 10; diff --git a/app/Extension/ModuleManager.php b/app/Extension/ModuleManager.php index c91ae44b..148727c0 100644 --- a/app/Extension/ModuleManager.php +++ b/app/Extension/ModuleManager.php @@ -2961,6 +2961,13 @@ class ModuleManager implements ModuleManagerInterface } } + /** + * 모듈의 훅 리스너를 자동으로 등록합니다. + * + * 비활성화된 모듈의 훅 리스너는 등록하지 않습니다. + * + * @param ModuleInterface $module 모듈 인스턴스 + */ protected function registerModuleHookListeners(ModuleInterface $module): void { // 모듈이 getHookListeners 메서드를 가지고 있는지 확인 @@ -2974,11 +2981,6 @@ class ModuleManager implements ModuleManagerInterface return; } - // 캐시 우선: 사전 계산된 훅 매핑이 있으면 클래스 로딩 없이 등록. - if ($this->registerExtensionHookListenersFromCache('modules', $module->getIdentifier())) { - return; - } - $listeners = $module->getHookListeners(); foreach ($listeners as $listenerClass) { @@ -3008,44 +3010,6 @@ class ModuleManager implements ModuleManagerInterface } } - /** - * 훅 캐시에서 확장(모듈/플러그인)의 정적 훅 리스너를 등록합니다. - * - * 캐시 파일(bootstrap/cache/hooks.php)에 해당 확장 식별자의 항목이 있으면 - * getSubscribedHooks() 클래스 로딩·리플렉션 없이 사전 계산 매핑으로 등록한다. - * 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔 폴백). - * - * @param string $bucket 캐시 버킷 ('modules' 또는 'plugins') - * @param string $identifier 확장 식별자 - * @return bool 캐시로 등록했으면 true, 캐시 부재/해당 항목 없음/테스트 환경이면 false (스캔 폴백) - */ - protected function registerExtensionHookListenersFromCache(string $bucket, string $identifier): bool - { - if (app()->environment('testing')) { - return false; - } - - $cache = app(HookCacheManager::class)->read(); - - if ($cache === null || ! isset($cache[$bucket][$identifier])) { - return false; - } - - foreach ($cache[$bucket][$identifier] as $entry) { - try { - HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], $identifier); - } catch (\Throwable $e) { - Log::error('확장 훅 리스너 캐시 등록 중 오류 발생', [ - 'listener' => $entry['listener'] ?? null, - 'identifier' => $identifier, - 'error' => $e->getMessage(), - ]); - } - } - - return true; - } - /** * 모듈의 브로드캐스트 채널을 등록합니다. * diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php index 3fbd6a32..ab92426a 100644 --- a/app/Extension/PluginManager.php +++ b/app/Extension/PluginManager.php @@ -2802,6 +2802,13 @@ class PluginManager implements PluginManagerInterface } } + /** + * 플러그인의 훅 리스너를 자동으로 등록합니다. + * + * 비활성화된 플러그인의 훅 리스너는 등록하지 않습니다. + * + * @param PluginInterface $plugin 플러그인 인스턴스 + */ protected function registerPluginHookListeners(PluginInterface $plugin): void { // 플러그인 활성화 상태 확인 (비활성화된 플러그인의 훅은 등록하지 않음) @@ -2810,11 +2817,6 @@ class PluginManager implements PluginManagerInterface return; } - // 캐시 우선: 사전 계산된 훅 매핑이 있으면 클래스 로딩 없이 등록. - if ($this->registerExtensionHookListenersFromCache('plugins', $plugin->getIdentifier())) { - return; - } - $listeners = $plugin->getHookListeners(); foreach ($listeners as $listenerClass) { @@ -2844,44 +2846,6 @@ class PluginManager implements PluginManagerInterface } } - /** - * 훅 캐시에서 플러그인의 정적 훅 리스너를 등록합니다. - * - * 캐시 파일(bootstrap/cache/hooks.php)에 해당 플러그인 식별자의 항목이 있으면 - * getSubscribedHooks() 클래스 로딩·리플렉션 없이 사전 계산 매핑으로 등록한다. - * 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔 폴백). - * - * @param string $bucket 캐시 버킷 ('plugins') - * @param string $identifier 플러그인 식별자 - * @return bool 캐시로 등록했으면 true, 캐시 부재/해당 항목 없음/테스트 환경이면 false (스캔 폴백) - */ - protected function registerExtensionHookListenersFromCache(string $bucket, string $identifier): bool - { - if (app()->environment('testing')) { - return false; - } - - $cache = app(HookCacheManager::class)->read(); - - if ($cache === null || ! isset($cache[$bucket][$identifier])) { - return false; - } - - foreach ($cache[$bucket][$identifier] as $entry) { - try { - HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], $identifier); - } catch (\Throwable $e) { - Log::error('확장 훅 리스너 캐시 등록 중 오류 발생', [ - 'listener' => $entry['listener'] ?? null, - 'identifier' => $identifier, - 'error' => $e->getMessage(), - ]); - } - } - - return true; - } - /** * 플러그인의 브로드캐스트 채널을 등록합니다. * diff --git a/app/Helpers/ResponseHelper.php b/app/Helpers/ResponseHelper.php index f83a3ea6..e7114513 100644 --- a/app/Helpers/ResponseHelper.php +++ b/app/Helpers/ResponseHelper.php @@ -9,23 +9,14 @@ use Illuminate\Support\Facades\App; class ResponseHelper { - /** - * JSON 응답 인코딩 옵션. - * - * JSON_UNESCAPED_UNICODE: 한글 등 멀티바이트 문자를 `\uXXXX` (6바이트) 로 - * 이스케이프하지 않고 raw UTF-8 (한글 3바이트) 로 직렬화하여 응답 크기를 줄인다. - * 특히 다국어 팩(lang) 응답에서 전송량 절감 효과가 크다. - */ - public const JSON_ENCODE_OPTIONS = JSON_UNESCAPED_UNICODE; - /** * 성공 응답을 생성합니다. * - * @param string $messageKey 메시지 키 (기본값: 'messages.success') - * @param mixed $data 응답 데이터 - * @param int $statusCode HTTP 상태 코드 (기본값: 200) - * @param array $messageParams 메시지 매개변수 - * @param string $domain 번역 도메인 (기본값: 'core') + * @param string $messageKey 메시지 키 (기본값: 'messages.success') + * @param mixed $data 응답 데이터 + * @param int $statusCode HTTP 상태 코드 (기본값: 200) + * @param array $messageParams 메시지 매개변수 + * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse JSON 응답 */ public static function success( @@ -38,18 +29,18 @@ class ResponseHelper return response()->json([ 'success' => true, 'message' => self::trans($messageKey, $messageParams, $domain), - 'data' => $data, - ], $statusCode, [], self::JSON_ENCODE_OPTIONS); + 'data' => $data + ], $statusCode); } /** * 실패 응답을 생성합니다. * - * @param string $messageKey 메시지 키 (기본값: 'messages.failed') - * @param int $statusCode HTTP 상태 코드 (기본값: 400) - * @param mixed $errors 오류 정보 - * @param array $messageParams 메시지 매개변수 - * @param string $domain 번역 도메인 (기본값: 'core') + * @param string $messageKey 메시지 키 (기본값: 'messages.failed') + * @param int $statusCode HTTP 상태 코드 (기본값: 400) + * @param mixed $errors 오류 정보 + * @param array $messageParams 메시지 매개변수 + * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse JSON 응답 */ public static function error( @@ -68,28 +59,28 @@ class ResponseHelper if (config('app.debug')) { // messageParams에 이미 에러 메시지가 포함된 경우 중복 방지 if (empty($messageParams)) { - $response['message'] .= ': '.$errors->getMessage(); + $response['message'] .= ': ' . $errors->getMessage(); } $response['debug'] = self::formatException($errors); } } elseif ($errors !== null) { - if ($statusCode >= 500 && is_string($errors) && ! config('app.debug')) { + if ($statusCode >= 500 && is_string($errors) && !config('app.debug')) { // 프로덕션 500+ 에러의 string errors 차단 (내부 예외 메시지 노출 방지) } else { $response['errors'] = $errors; } } - return response()->json($response, $statusCode, [], self::JSON_ENCODE_OPTIONS); + return response()->json($response, $statusCode); } /** * 입력 검증 실패 응답을 생성합니다. * - * @param mixed $errors 검증 오류 정보 - * @param string $messageKey 메시지 키 (기본값: 'messages.validation_failed') - * @param array $messageParams 메시지 매개변수 - * @param string $domain 번역 도메인 (기본값: 'core') + * @param mixed $errors 검증 오류 정보 + * @param string $messageKey 메시지 키 (기본값: 'messages.validation_failed') + * @param array $messageParams 메시지 매개변수 + * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 422 상태 코드를 가진 JSON 응답 */ public static function validationError( @@ -101,16 +92,16 @@ class ResponseHelper return response()->json([ 'success' => false, 'message' => self::trans($messageKey, $messageParams, $domain), - 'errors' => $errors, - ], 422, [], self::JSON_ENCODE_OPTIONS); + 'errors' => $errors + ], 422); } /** * 인증 실패 응답을 생성합니다. * - * @param string $messageKey 메시지 키 (기본값: 'messages.unauthorized') - * @param array $messageParams 메시지 매개변수 - * @param string $domain 번역 도메인 (기본값: 'core') + * @param string $messageKey 메시지 키 (기본값: 'messages.unauthorized') + * @param array $messageParams 메시지 매개변수 + * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 401 상태 코드를 가진 JSON 응답 */ public static function unauthorized( @@ -120,16 +111,16 @@ class ResponseHelper ): JsonResponse { return response()->json([ 'success' => false, - 'message' => self::trans($messageKey, $messageParams, $domain), - ], 401, [], self::JSON_ENCODE_OPTIONS); + 'message' => self::trans($messageKey, $messageParams, $domain) + ], 401); } /** * 권한 부족 응답을 생성합니다. * - * @param string $messageKey 멤시지 키 (기본값: 'messages.forbidden') - * @param array $messageParams 멤시지 매개변수 - * @param string $domain 번역 도메인 (기본값: 'core') + * @param string $messageKey 멤시지 키 (기본값: 'messages.forbidden') + * @param array $messageParams 멤시지 매개변수 + * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 403 상태 코드를 가진 JSON 응답 */ public static function forbidden( @@ -139,16 +130,16 @@ class ResponseHelper ): JsonResponse { return response()->json([ 'success' => false, - 'message' => self::trans($messageKey, $messageParams, $domain), - ], 403, [], self::JSON_ENCODE_OPTIONS); + 'message' => self::trans($messageKey, $messageParams, $domain) + ], 403); } /** * 리소스를 찾을 수 없음 응답을 생성합니다. * - * @param string $messageKey 멤시지 키 (기본값: 'messages.not_found') - * @param array $messageParams 멤시지 매개변수 - * @param string $domain 번역 도메인 (기본값: 'core') + * @param string $messageKey 멤시지 키 (기본값: 'messages.not_found') + * @param array $messageParams 멤시지 매개변수 + * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 404 상태 코드를 가진 JSON 응답 */ public static function notFound( @@ -158,17 +149,17 @@ class ResponseHelper ): JsonResponse { return response()->json([ 'success' => false, - 'message' => self::trans($messageKey, $messageParams, $domain), - ], 404, [], self::JSON_ENCODE_OPTIONS); + 'message' => self::trans($messageKey, $messageParams, $domain) + ], 404); } /** * 서버 내부 오류 응답을 생성합니다. * - * @param string $messageKey 멤시지 키 (기본값: 'messages.error_occurred') - * @param mixed $error 오류 정보 (디버그 모드에서만 표시) - * @param array $messageParams 멤시지 매개변수 - * @param string $domain 번역 도메인 (기본값: 'core') + * @param string $messageKey 멤시지 키 (기본값: 'messages.error_occurred') + * @param mixed $error 오류 정보 (디버그 모드에서만 표시) + * @param array $messageParams 멤시지 매개변수 + * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 500 상태 코드를 가진 JSON 응답 */ public static function serverError( @@ -184,22 +175,22 @@ class ResponseHelper if ($error instanceof \Throwable) { if (config('app.debug')) { - $response['message'] .= ': '.$error->getMessage(); + $response['message'] .= ': ' . $error->getMessage(); $response['debug'] = self::formatException($error); } } elseif ($error !== null && config('app.debug')) { $response['error'] = $error; } - return response()->json($response, 500, [], self::JSON_ENCODE_OPTIONS); + return response()->json($response, 500); } /** * 다국어 메시지를 변환합니다. * - * @param string $key 번역 키 - * @param array $params 번역 매개변수 - * @param string $domain 번역 도메인 + * @param string $key 번역 키 + * @param array $params 번역 매개변수 + * @param string $domain 번역 도메인 * @return string 번역된 메시지 */ private static function trans( @@ -208,10 +199,10 @@ class ResponseHelper string $domain = 'core' ): string { $locale = self::getUserLocale(); - + // 도메인별로 다른 경로에서 번역 파일 로드 $translationKey = $domain === 'core' ? $key : "{$domain}::{$key}"; - + return __($translationKey, $params, $locale); } @@ -234,7 +225,7 @@ class ResponseHelper /** * 예외 정보를 디버그용 배열로 변환합니다. * - * @param \Throwable $e 예외 인스턴스 + * @param \Throwable $e 예외 인스턴스 * @return array 디버그 정보 배열 */ private static function formatException(\Throwable $e): array @@ -247,7 +238,7 @@ class ResponseHelper 'trace' => collect($e->getTrace())->take(10)->map(fn ($frame) => [ 'file' => $frame['file'] ?? null, 'line' => $frame['line'] ?? null, - 'function' => ($frame['class'] ?? '').($frame['type'] ?? '').($frame['function'] ?? ''), + 'function' => ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? ''), ])->toArray(), ]; } @@ -255,11 +246,11 @@ class ResponseHelper /** * 모듈별 성공 응답을 생성합니다. * - * @param string $module 모듈명 - * @param string $messageKey 멤시지 키 - * @param mixed $data 응답 데이터 - * @param int $statusCode HTTP 상태 코드 (기본값: 200) - * @param array $messageParams 멤시지 매개변수 + * @param string $module 모듈명 + * @param string $messageKey 멤시지 키 + * @param mixed $data 응답 데이터 + * @param int $statusCode HTTP 상태 코드 (기본값: 200) + * @param array $messageParams 멤시지 매개변수 * @return JsonResponse JSON 응답 */ public static function moduleSuccess( @@ -275,11 +266,11 @@ class ResponseHelper /** * 모듈별 실패 응답을 생성합니다. * - * @param string $module 모듈명 - * @param string $messageKey 멤시지 키 - * @param int $statusCode HTTP 상태 코드 (기본값: 400) - * @param mixed $errors 오류 정보 - * @param array $messageParams 멤시지 매개변수 + * @param string $module 모듈명 + * @param string $messageKey 멤시지 키 + * @param int $statusCode HTTP 상태 코드 (기본값: 400) + * @param mixed $errors 오류 정보 + * @param array $messageParams 멤시지 매개변수 * @return JsonResponse JSON 응답 */ public static function moduleError( @@ -295,11 +286,11 @@ class ResponseHelper /** * 플러그인별 성공 응답을 생성합니다. * - * @param string $plugin 플러그인명 - * @param string $messageKey 멤시지 키 - * @param mixed $data 응답 데이터 - * @param int $statusCode HTTP 상태 코드 (기본값: 200) - * @param array $messageParams 멤시지 매개변수 + * @param string $plugin 플러그인명 + * @param string $messageKey 멤시지 키 + * @param mixed $data 응답 데이터 + * @param int $statusCode HTTP 상태 코드 (기본값: 200) + * @param array $messageParams 멤시지 매개변수 * @return JsonResponse JSON 응답 */ public static function pluginSuccess( @@ -315,11 +306,11 @@ class ResponseHelper /** * 플러그인별 실패 응답을 생성합니다. * - * @param string $plugin 플러그인명 - * @param string $messageKey 멤시지 키 - * @param int $statusCode HTTP 상태 코드 (기본값: 400) - * @param mixed $errors 오류 정보 - * @param array $messageParams 멤시지 매개변수 + * @param string $plugin 플러그인명 + * @param string $messageKey 멤시지 키 + * @param int $statusCode HTTP 상태 코드 (기본값: 400) + * @param mixed $errors 오류 정보 + * @param array $messageParams 멤시지 매개변수 * @return JsonResponse JSON 응답 */ public static function pluginError( @@ -335,11 +326,11 @@ class ResponseHelper /** * JSON Resource를 사용한 성공 응답을 생성합니다. * - * @param string $messageKey 멤시지 키 (기본값: 'messages.success') - * @param JsonResource|ResourceCollection|null $resource JSON 리소스 - * @param int $statusCode HTTP 상태 코드 (기본값: 200) - * @param array $messageParams 멤시지 매개변수 - * @param string $domain 번역 도메인 (기본값: 'core') + * @param string $messageKey 멤시지 키 (기본값: 'messages.success') + * @param JsonResource|ResourceCollection|null $resource JSON 리소스 + * @param int $statusCode HTTP 상태 코드 (기본값: 200) + * @param array $messageParams 멤시지 매개변수 + * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse JSON 응답 */ public static function successWithResource( @@ -350,22 +341,22 @@ class ResponseHelper string $domain = 'core' ): JsonResponse { $data = $resource ? $resource->resolve() : null; - + return response()->json([ 'success' => true, 'message' => self::trans($messageKey, $messageParams, $domain), - 'data' => $data, - ], $statusCode, [], self::JSON_ENCODE_OPTIONS); + 'data' => $data + ], $statusCode); } /** * JSON Resource를 사용한 모듈 성공 응답을 생성합니다. * - * @param string $module 모듈명 - * @param string $messageKey 멤시지 키 - * @param JsonResource|ResourceCollection|null $resource JSON 리소스 - * @param int $statusCode HTTP 상태 코드 (기본값: 200) - * @param array $messageParams 멤시지 매개변수 + * @param string $module 모듈명 + * @param string $messageKey 멤시지 키 + * @param JsonResource|ResourceCollection|null $resource JSON 리소스 + * @param int $statusCode HTTP 상태 코드 (기본값: 200) + * @param array $messageParams 멤시지 매개변수 * @return JsonResponse JSON 응답 */ public static function moduleSuccessWithResource( @@ -381,11 +372,11 @@ class ResponseHelper /** * JSON Resource를 사용한 플러그인 성공 응답을 생성합니다. * - * @param string $plugin 플러그인명 - * @param string $messageKey 멤시지 키 - * @param JsonResource|ResourceCollection|null $resource JSON 리소스 - * @param int $statusCode HTTP 상태 코드 (기본값: 200) - * @param array $messageParams 멤시지 매개변수 + * @param string $plugin 플러그인명 + * @param string $messageKey 멤시지 키 + * @param JsonResource|ResourceCollection|null $resource JSON 리소스 + * @param int $statusCode HTTP 상태 코드 (기본값: 200) + * @param array $messageParams 멤시지 매개변수 * @return JsonResponse JSON 응답 */ public static function pluginSuccessWithResource( @@ -401,21 +392,21 @@ class ResponseHelper /** * 페이지네이션된 리소스 응답을 생성합니다. * - * @param string $messageKey 멤시지 키 (기본값: 'messages.success') - * @param ResourceCollection|null $collection 페이지네이션된 리소스 컬렉션 - * @param array $messageParams 멤시지 매개변수 - * @param string $domain 번역 도메인 (기본값: 'core') + * @param string $messageKey 멤시지 키 (기본값: 'messages.success') + * @param ResourceCollection|null $collection 페이지네이션된 리소스 컬렉션 + * @param array $messageParams 멤시지 매개변수 + * @param string $domain 번역 도메인 (기본값: 'core') * @return JsonResponse 페이지네이션 메타 데이터를 포함한 JSON 응답 */ public static function successWithPagination( string $messageKey = 'messages.success', - ?ResourceCollection $collection = null, + ResourceCollection|null $collection = null, array $messageParams = [], string $domain = 'core' ): JsonResponse { $response = [ 'success' => true, - 'message' => self::trans($messageKey, $messageParams, $domain), + 'message' => self::trans($messageKey, $messageParams, $domain) ]; if ($collection) { @@ -433,7 +424,7 @@ class ResponseHelper $response['links'] = $paginationData['links'] ?? null; } - return response()->json($response, 200, [], self::JSON_ENCODE_OPTIONS); + return response()->json($response); } /** @@ -453,6 +444,6 @@ class ResponseHelper 'error_code' => 'identity_verification_required', 'message' => self::trans('identity.errors.verification_required', [], 'core'), 'verification' => $verification, - ], 428, [], self::JSON_ENCODE_OPTIONS); + ], 428); } } diff --git a/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php b/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php index d53ac9a9..f8af3027 100644 --- a/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php +++ b/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php @@ -187,7 +187,7 @@ class AdminTemplateAssetController extends AdminBaseController /** * 편집기 프리뷰 전용 CSS 서빙 — 다크 셀렉터를 프리뷰 마커로 치환. * - * 관리자 admin 환경이 다크 테마면 `` 가 조상으로 남아, 프리뷰 + * PO admin 환경이 다크 테마면 `` 가 조상으로 남아, 프리뷰 * 프레임에서 `.dark` 를 빼도(라이트 토글) Tailwind `.dark &` 가 활성돼 프리뷰가 * 라이트로 격리되지 않는다. 이를 해소하기 위해, 편집기 진입 시에만 components.css 의 * 다크 조상 셀렉터(`.dark`)를 프리뷰 전용 마커(`.g7le-preview-dark`)로 치환해 서빙한다. diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php index f3f3fc9e..b5662e2a 100644 --- a/app/Http/Controllers/Api/Admin/SettingsController.php +++ b/app/Http/Controllers/Api/Admin/SettingsController.php @@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api\Admin; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Requests\Settings\RegenerateAppKeyRequest; -use App\Http\Requests\Settings\RestoreSettingsRequest; use App\Http\Requests\Settings\SaveSettingsRequest; use App\Http\Requests\Settings\TestDriverConnectionRequest; use App\Http\Requests\Settings\TestMailRequest; @@ -14,7 +13,7 @@ use App\Services\DriverConnectionTester; use App\Services\DriverRegistryService; use App\Services\SettingsService; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Log; +use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; /** @@ -138,9 +137,7 @@ class SettingsController extends AdminBaseController $systemInfo = $this->settingsService->getSystemInfo(); return $this->success('common.success', $systemInfo); - } catch (\Throwable $e) { - Log::error('시스템 정보 조회 실패', ['error' => $e->getMessage()]); - + } catch (\Exception $e) { return $this->error('common.error_occurred', 500, $e->getMessage()); } } @@ -267,13 +264,16 @@ class SettingsController extends AdminBaseController /** * 백업에서 설정을 복원합니다. * - * @param RestoreSettingsRequest $request 복원 요청 데이터 (백업 경로) * @return JsonResponse 복원 결과 JSON 응답 */ - public function restore(RestoreSettingsRequest $request): JsonResponse + public function restore(Request $request): JsonResponse { try { - $backupPath = $request->validated('backup_path'); + $backupPath = $request->input('backup_path'); + + if (empty($backupPath)) { + return $this->error('settings.backup_path_required', 422); + } $result = $this->settingsService->restoreSettings($backupPath); diff --git a/app/Http/Controllers/Api/Base/BaseApiController.php b/app/Http/Controllers/Api/Base/BaseApiController.php index c8f4e2f5..84e57b35 100644 --- a/app/Http/Controllers/Api/Base/BaseApiController.php +++ b/app/Http/Controllers/Api/Base/BaseApiController.php @@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api\Base; use App\Helpers\ResponseHelper; use App\Http\Controllers\Controller; -use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Illuminate\Support\Facades\Auth; @@ -21,11 +20,11 @@ abstract class BaseApiController extends Controller /** * 성공 응답을 생성합니다. * - * @param string $messageKey 메시지 키 - * @param mixed $data 응답 데이터 - * @param int $statusCode HTTP 상태 코드 - * @param array $messageParams 메시지 매개변수 - * @return JsonResponse + * @param string $messageKey 메시지 키 + * @param mixed $data 응답 데이터 + * @param int $statusCode HTTP 상태 코드 + * @param array $messageParams 메시지 매개변수 + * @return \Illuminate\Http\JsonResponse */ protected function success( string $messageKey = 'common.success', @@ -39,11 +38,11 @@ abstract class BaseApiController extends Controller /** * 실패 응답을 생성합니다. * - * @param string $messageKey 메시지 키 - * @param int $statusCode HTTP 상태 코드 - * @param mixed $errors 오류 정보 - * @param array $messageParams 메시지 매개변수 - * @return JsonResponse + * @param string $messageKey 메시지 키 + * @param int $statusCode HTTP 상태 코드 + * @param mixed $errors 오류 정보 + * @param array $messageParams 메시지 매개변수 + * @return \Illuminate\Http\JsonResponse */ protected function error( string $messageKey = 'common.failed', @@ -57,11 +56,11 @@ abstract class BaseApiController extends Controller /** * 리소스와 함께 성공 응답을 생성합니다. * - * @param string $messageKey 메시지 키 - * @param mixed $resource JSON 리소스 - * @param int $statusCode HTTP 상태 코드 - * @param array $messageParams 메시지 매개변수 - * @return JsonResponse + * @param string $messageKey 메시지 키 + * @param mixed $resource JSON 리소스 + * @param int $statusCode HTTP 상태 코드 + * @param array $messageParams 메시지 매개변수 + * @return \Illuminate\Http\JsonResponse */ protected function successWithResource( string $messageKey = 'common.success', @@ -75,7 +74,7 @@ abstract class BaseApiController extends Controller /** * 현재 인증된 사용자를 반환합니다. * - * @return User|null + * @return \App\Models\User|null */ protected function getCurrentUser() { @@ -85,9 +84,9 @@ abstract class BaseApiController extends Controller /** * Not Found 응답을 생성합니다. * - * @param string $messageKey 메시지 키 - * @param array $messageParams 메시지 매개변수 - * @return JsonResponse + * @param string $messageKey 메시지 키 + * @param array $messageParams 메시지 매개변수 + * @return \Illuminate\Http\JsonResponse */ protected function notFound( string $messageKey = 'common.not_found', @@ -99,9 +98,9 @@ abstract class BaseApiController extends Controller /** * Unauthorized 응답을 생성합니다. * - * @param string $messageKey 메시지 키 - * @param array $messageParams 메시지 매개변수 - * @return JsonResponse + * @param string $messageKey 메시지 키 + * @param array $messageParams 메시지 매개변수 + * @return \Illuminate\Http\JsonResponse */ protected function unauthorized( string $messageKey = 'common.unauthorized', @@ -113,9 +112,9 @@ abstract class BaseApiController extends Controller /** * Forbidden 응답을 생성합니다. * - * @param string $messageKey 메시지 키 - * @param array $messageParams 메시지 매개변수 - * @return JsonResponse + * @param string $messageKey 메시지 키 + * @param array $messageParams 메시지 매개변수 + * @return \Illuminate\Http\JsonResponse */ protected function forbidden( string $messageKey = 'common.forbidden', @@ -127,10 +126,10 @@ abstract class BaseApiController extends Controller /** * Validation Error 응답을 생성합니다. * - * @param mixed $errors 검증 오류 - * @param string $messageKey 메시지 키 - * @param array $messageParams 메시지 매개변수 - * @return JsonResponse + * @param mixed $errors 검증 오류 + * @param string $messageKey 메시지 키 + * @param array $messageParams 메시지 매개변수 + * @return \Illuminate\Http\JsonResponse */ protected function validationError( mixed $errors, @@ -143,9 +142,9 @@ abstract class BaseApiController extends Controller /** * 파일 응답을 반환합니다 (ETag 및 캐싱 헤더 포함). * - * @param string $filePath 파일 경로 - * @param string $mimeType MIME 타입 - * @param int $maxAge 캐시 유지 시간 (초, 기본: 1년) + * @param string $filePath 파일 경로 + * @param string $mimeType MIME 타입 + * @param int $maxAge 캐시 유지 시간 (초, 기본: 1년) */ protected function fileResponse(string $filePath, string $mimeType, int $maxAge = 31536000): BinaryFileResponse|Response { @@ -177,35 +176,35 @@ abstract class BaseApiController extends Controller /** * JSON 응답을 반환합니다 (캐싱 헤더 포함). * - * @param mixed $data JSON으로 변환할 데이터 - * @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간) - * @param int $status HTTP 상태 코드 + * @param mixed $data JSON으로 변환할 데이터 + * @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간) + * @param int $status HTTP 상태 코드 * @return JsonResponse JSON 응답 */ protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse { return response()->json($data, $status, [ 'Cache-Control' => "public, max-age={$maxAge}", - ], ResponseHelper::JSON_ENCODE_OPTIONS); + ]); } /** * ETag를 생성합니다. * - * @param mixed $data 해시할 데이터 + * @param mixed $data 해시할 데이터 * @return string ETag 값 (따옴표 포함) */ protected function generateETag(mixed $data): string { $content = is_string($data) ? $data : json_encode($data); - return '"'.md5($content).'"'; + return '"' . md5($content) . '"'; } /** * 클라이언트 캐시가 유효한지 확인합니다. * - * @param string $etag 현재 ETag 값 + * @param string $etag 현재 ETag 값 * @return bool 캐시가 유효하면 true */ protected function isNotModified(string $etag): bool @@ -218,8 +217,8 @@ abstract class BaseApiController extends Controller /** * 304 Not Modified 응답을 반환합니다. * - * @param string $etag ETag 값 - * @param int $maxAge 캐시 TTL (초) + * @param string $etag ETag 값 + * @param int $maxAge 캐시 TTL (초) * @return Response 304 응답 */ protected function notModifiedResponse(string $etag, int $maxAge = 3600): Response @@ -234,10 +233,10 @@ abstract class BaseApiController extends Controller * * 클라이언트의 ETag가 일치하면 304 Not Modified를 반환합니다. * - * @param string $messageKey 메시지 키 - * @param mixed $data 응답 데이터 - * @param int $maxAge 캐시 TTL (초, 기본: 1시간) - * @param array $messageParams 메시지 매개변수 + * @param string $messageKey 메시지 키 + * @param mixed $data 응답 데이터 + * @param int $maxAge 캐시 TTL (초, 기본: 1시간) + * @param array $messageParams 메시지 매개변수 * @return JsonResponse|Response JSON 응답 또는 304 응답 */ protected function successWithCache( @@ -257,7 +256,7 @@ abstract class BaseApiController extends Controller 'success' => true, 'message' => __($messageKey, $messageParams), 'data' => $data, - ], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS) + ]) ->header('ETag', $etag) ->header('Cache-Control', "public, max-age={$maxAge}") ->header('Vary', 'Accept-Encoding, Accept-Language'); diff --git a/app/Http/Controllers/Api/Public/PublicModuleController.php b/app/Http/Controllers/Api/Public/PublicModuleController.php index 85de88f1..cf46e435 100644 --- a/app/Http/Controllers/Api/Public/PublicModuleController.php +++ b/app/Http/Controllers/Api/Public/PublicModuleController.php @@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api\Public; use App\Http\Controllers\Api\Base\PublicBaseController; use App\Http\Requests\Public\Module\ServeModuleAssetRequest; -use App\Services\ExtensionBundleService; use App\Services\ModuleService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; @@ -18,54 +17,11 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse; class PublicModuleController extends PublicBaseController { public function __construct( - private readonly ModuleService $moduleService, - private readonly ExtensionBundleService $bundleService + private readonly ModuleService $moduleService ) { parent::__construct(); } - /** - * 활성 모듈 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다. - * - * 활성 global 모듈 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다 - * (프론트는 빈 스크립트 로드로 무해). 그 외에는 병합 파일을 fileResponse 로 - * 서빙(ETag/304/환경별 Cache-Control 재사용). - * - * @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답 - */ - public function serveBundleJs(): BinaryFileResponse|Response - { - $this->logApiUsage('modules.bundle', ['kind' => 'js']); - - $version = $this->bundleService->getCurrentVersion(); - $path = $this->bundleService->getBundleFilePath('module', 'js', $version); - - if ($path === '') { - return response('', 200)->header('Content-Type', 'text/javascript'); - } - - return $this->fileResponse($path, 'text/javascript', 31536000); - } - - /** - * 활성 모듈 프론트엔드 병합 번들(CSS)을 서빙합니다. - * - * @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답 - */ - public function serveBundleCss(): BinaryFileResponse|Response - { - $this->logApiUsage('modules.bundle', ['kind' => 'css']); - - $version = $this->bundleService->getCurrentVersion(); - $path = $this->bundleService->getBundleFilePath('module', 'css', $version); - - if ($path === '') { - return response('', 200)->header('Content-Type', 'text/css'); - } - - return $this->fileResponse($path, 'text/css', 31536000); - } - /** * 모듈 에셋 서빙 * diff --git a/app/Http/Controllers/Api/Public/PublicPluginController.php b/app/Http/Controllers/Api/Public/PublicPluginController.php index b187f984..bb3e0f7b 100644 --- a/app/Http/Controllers/Api/Public/PublicPluginController.php +++ b/app/Http/Controllers/Api/Public/PublicPluginController.php @@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api\Public; use App\Http\Controllers\Api\Base\PublicBaseController; use App\Http\Requests\Public\Plugin\ServePluginAssetRequest; -use App\Services\ExtensionBundleService; use App\Services\PluginService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; @@ -18,53 +17,11 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse; class PublicPluginController extends PublicBaseController { public function __construct( - private readonly PluginService $pluginService, - private readonly ExtensionBundleService $bundleService + private readonly PluginService $pluginService ) { parent::__construct(); } - /** - * 활성 플러그인 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다. - * - * 활성 global 플러그인 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다. - * 그 외에는 병합 파일을 fileResponse 로 서빙(ETag/304/환경별 Cache-Control 재사용). - * - * @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답 - */ - public function serveBundleJs(): BinaryFileResponse|Response - { - $this->logApiUsage('plugins.bundle', ['kind' => 'js']); - - $version = $this->bundleService->getCurrentVersion(); - $path = $this->bundleService->getBundleFilePath('plugin', 'js', $version); - - if ($path === '') { - return response('', 200)->header('Content-Type', 'text/javascript'); - } - - return $this->fileResponse($path, 'text/javascript', 31536000); - } - - /** - * 활성 플러그인 프론트엔드 병합 번들(CSS)을 서빙합니다. - * - * @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답 - */ - public function serveBundleCss(): BinaryFileResponse|Response - { - $this->logApiUsage('plugins.bundle', ['kind' => 'css']); - - $version = $this->bundleService->getCurrentVersion(); - $path = $this->bundleService->getBundleFilePath('plugin', 'css', $version); - - if ($path === '') { - return response('', 200)->header('Content-Type', 'text/css'); - } - - return $this->fileResponse($path, 'text/css', 31536000); - } - /** * 플러그인 에셋 서빙 * diff --git a/app/Http/Middleware/GzipEncodeResponse.php b/app/Http/Middleware/GzipEncodeResponse.php index f8d56022..e66953d1 100644 --- a/app/Http/Middleware/GzipEncodeResponse.php +++ b/app/Http/Middleware/GzipEncodeResponse.php @@ -4,7 +4,6 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; -use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Response; /** @@ -99,15 +98,6 @@ class GzipEncodeResponse } // 최소 크기 이상인지 확인 - // BinaryFileResponse 는 getContent() 가 false 를 반환하므로 파일 크기로 판정한다. - // (번들 JS/CSS 서빙은 response()->file() 로 BinaryFileResponse 를 반환하며, - // 이 분기가 없으면 미들웨어가 압축 없이 통과시켜 비압축 전송된다.) - if ($response instanceof BinaryFileResponse) { - $file = $response->getFile(); - - return $file !== null && $file->getSize() >= self::MIN_COMPRESS_SIZE; - } - $content = $response->getContent(); if ($content === false || strlen($content) < self::MIN_COMPRESS_SIZE) { return false; @@ -168,12 +158,6 @@ class GzipEncodeResponse */ private function compressResponse(Response $response): Response { - // BinaryFileResponse 는 본문을 setContent() 로 교체할 수 없으므로 - // 파일 내용을 읽어 일반 Response 로 치환한 뒤 압축한다 (헤더는 승계). - if ($response instanceof BinaryFileResponse) { - return $this->compressBinaryFileResponse($response); - } - $content = $response->getContent(); // gzip 압축 적용 @@ -189,70 +173,14 @@ class GzipEncodeResponse $response->headers->set('Content-Encoding', 'gzip'); $response->headers->set('Content-Length', (string) strlen($compressed)); - $this->ensureVaryAcceptEncoding($response); - - return $response; - } - - /** - * BinaryFileResponse 를 gzip 압축된 일반 Response 로 치환합니다. - * - * BinaryFileResponse 는 파일 스트림 기반이라 setContent() 로 본문을 교체할 수 없으므로, - * 파일 내용을 읽어 gzip 압축한 새 Response 를 생성하고 기존 헤더(Content-Type, - * ETag, Cache-Control, Expires 등)를 승계한다. Content-Encoding/Content-Length 는 - * 압축 결과로 재설정한다. - * - * @param BinaryFileResponse $response 파일 응답 - * @return Response gzip 압축된 응답 (실패 시 원본 반환) - */ - private function compressBinaryFileResponse(BinaryFileResponse $response): Response - { - $file = $response->getFile(); - - if ($file === null) { - return $response; - } - - $content = @file_get_contents($file->getPathname()); - - if ($content === false) { - // 파일 읽기 실패 시 원본(비압축) 반환 - return $response; - } - - $compressed = gzencode($content, self::COMPRESSION_LEVEL); - - if ($compressed === false) { - return $response; - } - - // 기존 헤더를 승계한 새 Response 생성 (본문만 압축된 내용으로 교체) - $compressedResponse = new Response( - $compressed, - $response->getStatusCode(), - $response->headers->all() - ); - - $compressedResponse->headers->set('Content-Encoding', 'gzip'); - $compressedResponse->headers->set('Content-Length', (string) strlen($compressed)); - - $this->ensureVaryAcceptEncoding($compressedResponse); - - return $compressedResponse; - } - - /** - * 응답에 `Vary: Accept-Encoding` 헤더를 보장합니다 (캐시 프록시 지원). - * - * @param Response $response HTTP 응답 - */ - private function ensureVaryAcceptEncoding(Response $response): void - { + // Vary 헤더가 없으면 추가 (캐시 프록시 지원) if (! $response->headers->has('Vary')) { $response->headers->set('Vary', 'Accept-Encoding'); } elseif (! str_contains($response->headers->get('Vary', ''), 'Accept-Encoding')) { $currentVary = $response->headers->get('Vary'); $response->headers->set('Vary', $currentVary.', Accept-Encoding'); } + + return $response; } } diff --git a/app/Http/Requests/Settings/RestoreSettingsRequest.php b/app/Http/Requests/Settings/RestoreSettingsRequest.php deleted file mode 100644 index 2be11e66..00000000 --- a/app/Http/Requests/Settings/RestoreSettingsRequest.php +++ /dev/null @@ -1,55 +0,0 @@ -|string> - */ - public function rules(): array - { - $rules = [ - 'backup_path' => 'required|string', - ]; - - // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 - return HookManager::applyFilters('core.settings.restore_validation_rules', $rules, $this); - } - - /** - * 검증 오류 메시지를 반환합니다. - * - * @return array - */ - public function messages(): array - { - return [ - 'backup_path.required' => __('settings.backup_path_required'), - ]; - } -} diff --git a/app/Http/View/Composers/TemplateComposer.php b/app/Http/View/Composers/TemplateComposer.php index 5d565fb3..ff7c9320 100644 --- a/app/Http/View/Composers/TemplateComposer.php +++ b/app/Http/View/Composers/TemplateComposer.php @@ -99,9 +99,6 @@ class TemplateComposer // 확장 기능 캐시 버전 (브라우저 캐시 무효화용) $extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion(); - // 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null) - $bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion); - $view->with('activeAdminTemplate', $activeTemplate); $view->with('extensionCacheVersion', $extensionCacheVersion); $view->with('frontendSettings', $frontendSettings); @@ -109,39 +106,12 @@ class TemplateComposer $view->with('moduleSettings', $moduleSettings); $view->with('moduleAssets', $moduleAssets); $view->with('pluginAssets', $pluginAssets); - $view->with('bundleUrls', $bundleUrls); $view->with('activeModulesMeta', $activeModulesMeta); $view->with('activePluginsMeta', $activePluginsMeta); $view->with('appConfig', $appConfig); $view->with('templateExternals', $templateExternals); } - /** - * 확장 프론트엔드 병합 번들 URL을 계산합니다. - * - * 활성 global 에셋(JS/CSS)이 하나라도 있는 타입만 해당 번들 URL을 채우고, - * 없으면 null을 두어 프론트가 로드를 스킵하게 한다. URL은 same-origin - * (`/api/...`)이어야 gdpr preblocker 의 same-origin 통과 규칙에 자기 차단되지 - * 않는다(CDN 호스팅 금지). - * - * @param array $moduleAssets 수집된 모듈 개별 에셋 맵 - * @param array $pluginAssets 수집된 플러그인 개별 에셋 맵 - * @param int $version 확장 캐시 버전 - * @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string} - */ - private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array - { - $hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js']))); - $hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css']))); - - return [ - 'moduleJs' => $hasJs($moduleAssets) ? "/api/modules/bundle.js?v={$version}" : null, - 'moduleCss' => $hasCss($moduleAssets) ? "/api/modules/bundle.css?v={$version}" : null, - 'pluginJs' => $hasJs($pluginAssets) ? "/api/plugins/bundle.js?v={$version}" : null, - 'pluginCss' => $hasCss($pluginAssets) ? "/api/plugins/bundle.css?v={$version}" : null, - ]; - } - /** * 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다. * diff --git a/app/Http/View/Composers/UserTemplateComposer.php b/app/Http/View/Composers/UserTemplateComposer.php index d145b0b1..c6460938 100644 --- a/app/Http/View/Composers/UserTemplateComposer.php +++ b/app/Http/View/Composers/UserTemplateComposer.php @@ -105,9 +105,6 @@ class UserTemplateComposer // 확장 기능 캐시 버전 (브라우저 캐시 무효화용) $extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion(); - // 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null) - $bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion); - $view->with('activeUserTemplate', $activeTemplate); $view->with('extensionCacheVersion', $extensionCacheVersion); $view->with('frontendSettings', $frontendSettings); @@ -115,39 +112,12 @@ class UserTemplateComposer $view->with('moduleSettings', $moduleSettings); $view->with('moduleAssets', $moduleAssets); $view->with('pluginAssets', $pluginAssets); - $view->with('bundleUrls', $bundleUrls); $view->with('activeModulesMeta', $activeModulesMeta); $view->with('activePluginsMeta', $activePluginsMeta); $view->with('appConfig', $appConfig); $view->with('templateExternals', $templateExternals); } - /** - * 확장 프론트엔드 병합 번들 URL을 계산합니다. - * - * 활성 global 에셋(JS/CSS)이 하나라도 있는 타입만 해당 번들 URL을 채우고, - * 없으면 null을 두어 프론트가 로드를 스킵하게 한다. URL은 same-origin - * (`/api/...`)이어야 gdpr preblocker 의 same-origin 통과 규칙에 자기 차단되지 - * 않는다(CDN 호스팅 금지). - * - * @param array $moduleAssets 수집된 모듈 개별 에셋 맵 - * @param array $pluginAssets 수집된 플러그인 개별 에셋 맵 - * @param int $version 확장 캐시 버전 - * @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string} - */ - private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array - { - $hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js']))); - $hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css']))); - - return [ - 'moduleJs' => $hasJs($moduleAssets) ? "/api/modules/bundle.js?v={$version}" : null, - 'moduleCss' => $hasCss($moduleAssets) ? "/api/modules/bundle.css?v={$version}" : null, - 'pluginJs' => $hasJs($pluginAssets) ? "/api/plugins/bundle.js?v={$version}" : null, - 'pluginCss' => $hasCss($pluginAssets) ? "/api/plugins/bundle.css?v={$version}" : null, - ]; - } - /** * 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다. * diff --git a/app/Listeners/ExtensionConfigCacheListener.php b/app/Listeners/ExtensionConfigCacheListener.php deleted file mode 100644 index 7ceb3d8f..00000000 --- a/app/Listeners/ExtensionConfigCacheListener.php +++ /dev/null @@ -1,86 +0,0 @@ - 훅 이름 → 메서드/우선순위 매핑 - */ - public static function getSubscribedHooks(): array - { - $hooks = []; - $hookNames = [ - 'core.modules.activated', - 'core.modules.after_deactivate', - 'core.plugins.activated', - 'core.plugins.after_deactivate', - 'core.templates.activated', - 'core.templates.after_deactivate', - ]; - - foreach ($hookNames as $hookName) { - $hooks[$hookName] = [ - 'method' => 'onExtensionToggled', - 'priority' => 30, - // config 캐시 재생성은 인프라 작업이라 큐로 미루면 안 된다. Action 훅의 기본 - // 등록은 큐 디스패치(HookListenerRegistrar) 이므로, sync 를 명시하지 않으면 - // 워커 미가동 환경에서 config:cache 가 재생성되지 않는다. 또한 확장 토글은 - // CLI 커맨드(plugin:deactivate 등)로도 수행되며, 커맨드 프로세스는 실행 후 - // 즉시 종료되어 큐에 쌓인 작업을 처리할 주체가 없다. 반드시 동기 실행한다. - 'sync' => true, - ]; - } - - return $hooks; - } - - /** - * 기본 훅 핸들러 (HookListenerInterface 필수 메서드). - * - * @param mixed ...$args 훅 인자 - */ - public function handle(...$args): void - { - // 개별 메서드에서 처리 - } - - /** - * 확장 활성화/비활성화 직후 config 캐시를 재생성합니다. - * - * @param mixed ...$args 훅 인자 (확장 식별자 등) - */ - public function onExtensionToggled(...$args): void - { - try { - ConfigCacheHelper::rebuild(); - } catch (\Throwable $e) { - Log::warning('[Config] 확장 토글 후 config 캐시 재생성 실패', [ - 'error' => $e->getMessage(), - ]); - } - } -} diff --git a/app/Listeners/NotificationHookListener.php b/app/Listeners/NotificationHookListener.php index 490cc8d1..262fa694 100644 --- a/app/Listeners/NotificationHookListener.php +++ b/app/Listeners/NotificationHookListener.php @@ -50,9 +50,7 @@ class NotificationHookListener implements HookListenerInterface */ public function registerDynamicHooks(): void { - // 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거). - // 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 안전하게 스킵. - if (! config('app.installer_completed') && ! Schema::hasTable('notification_definitions')) { + if (! Schema::hasTable('notification_definitions')) { return; } diff --git a/app/Models/TemplateLayoutAttachment.php b/app/Models/TemplateLayoutAttachment.php index 853125c4..a7b8b0a8 100644 --- a/app/Models/TemplateLayoutAttachment.php +++ b/app/Models/TemplateLayoutAttachment.php @@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)을 표현한다. 파일 자체는 * 코어 StorageInterface 로 저장되고 본 모델은 저장 위치(disk/path)와 메타데이터를 * 보관한다. 템플릿 삭제 시 행은 cascadeOnDelete 로 삭제되나, 스토리지 파일 - * 실삭제는 Service 가 명시적으로 수행한다(코어 규정: DB CASCADE 의존 삭제 금지). + * 실삭제는 Service 가 명시적으로 수행한다(CLAUDE.md "DB CASCADE 의존 삭제 금지"). * * @property int $id 첨부 파일 ID * @property int $template_id 소속 템플릿 ID diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php index 529c5fdc..c30ca62e 100644 --- a/app/Providers/CoreServiceProvider.php +++ b/app/Providers/CoreServiceProvider.php @@ -42,7 +42,6 @@ use App\Enums\DeactivationReason; use App\Extension\Cache\CoreCacheDriver; use App\Extension\CoreVersionChecker; use App\Extension\ExtensionManager; -use App\Extension\HookCacheManager; use App\Extension\HookListenerRegistrar; use App\Extension\HookManager; use App\Extension\IdentityVerification\IdentityVerificationManager; @@ -152,15 +151,6 @@ class CoreServiceProvider extends ServiceProvider } } - // 확장 소스 classmap 등록 (FQCN → 절대경로) — findFile 파일시스템 스캔 제거 (lazy include). - if (! empty($extensionAutoloads['src_classmap'])) { - $absoluteClassmap = []; - foreach ($extensionAutoloads['src_classmap'] as $fqcn => $relPath) { - $absoluteClassmap[$fqcn] = base_path($relPath); - } - $loader->addClassMap($absoluteClassmap); - } - // Classmap 파일 로드 (module.php, plugin.php) if (! empty($extensionAutoloads['classmap'])) { foreach ($extensionAutoloads['classmap'] as $file) { @@ -876,14 +866,12 @@ class CoreServiceProvider extends ServiceProvider // registerActivityLogManager() 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체 + /** + * app/Listeners/ 디렉토리에서 HookListenerInterface 구현체를 자동 발견하여 등록합니다. + * 하위 디렉토리까지 재귀적으로 스캔합니다. + */ private function registerCoreHookListeners(): void { - // 캐시 우선: bootstrap/cache/hooks.php 가 있으면 스캔·리플렉션 없이 등록. - // 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔). - if (! $this->app->environment('testing') && $this->registerCoreHookListenersFromCache()) { - return; - } - $listenersPath = app_path('Listeners'); if (! is_dir($listenersPath)) { @@ -922,41 +910,6 @@ class CoreServiceProvider extends ServiceProvider } } - /** - * 훅 캐시에서 코어 리스너를 등록합니다. - * - * 캐시 파일(bootstrap/cache/hooks.php)이 존재하면 디렉토리 스캔·class_implements - * 리플렉션·getSubscribedHooks() 클래스 로딩 없이 사전 계산 매핑으로 등록한다. - * 동적 훅(registerDynamicHooks) 보유 리스너는 스캔 경로와 동일하게 boot 후반부 실행을 위해 지연 목록에 담는다. - * - * @return bool 캐시로 등록했으면 true, 캐시 부재/손상 시 false (스캔 폴백) - */ - private function registerCoreHookListenersFromCache(): bool - { - $cache = $this->app->make(HookCacheManager::class)->read(); - - if ($cache === null) { - return false; - } - - foreach ($cache['core'] as $entry) { - try { - HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], 'core'); - - if (! empty($entry['dynamic'])) { - $this->deferredDynamicListeners[] = $entry['listener']; - } - } catch (\Throwable $e) { - Log::error('코어 훅 리스너 캐시 등록 중 오류 발생', [ - 'listener' => $entry['listener'] ?? null, - 'error' => $e->getMessage(), - ]); - } - } - - return true; - } - /** * 단일 코어 리스너를 HookManager에 등록합니다. * diff --git a/app/Repositories/IdentityPolicyRepository.php b/app/Repositories/IdentityPolicyRepository.php index a6b7611b..004cf036 100644 --- a/app/Repositories/IdentityPolicyRepository.php +++ b/app/Repositories/IdentityPolicyRepository.php @@ -340,9 +340,7 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface public function listHookTargets(): array { try { - // 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거). - // 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 안전하게 빈 배열 반환. - if (! config('app.installer_completed') && ! Schema::hasTable('identity_policies')) { + if (! Schema::hasTable('identity_policies')) { return []; } @@ -406,9 +404,7 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface { try { $table = (new $modelClass)->getTable(); - // 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 information_schema 쿼리 제거). - // 인스톨러 이전/마이그레이션 전 환경에서는 기존 hasTable 폴백으로 null 반환(필터 미적용). - if (! config('app.installer_completed') && ! Schema::hasTable($table)) { + if (! Schema::hasTable($table)) { return null; } diff --git a/app/Seo/Editor/BroadcastCatalogService.php b/app/Seo/Editor/BroadcastCatalogService.php index e1ecd889..0d66d907 100644 --- a/app/Seo/Editor/BroadcastCatalogService.php +++ b/app/Seo/Editor/BroadcastCatalogService.php @@ -11,7 +11,7 @@ use App\Contracts\Extension\PluginManagerInterface; * 데이터소스 종류=websocket 편집 시 채널을 "구독 가능한 목록 기준 검색 드롭다운"으로 * 제시하기 위해, "지금 이 설치본에서 구독 가능한 채널"을 런타임 수집한다. 코어/확장이 * `getChannels()` 오버라이드로 등록한 채널명을 출처(core/module/plugin)와 함께 모은다 - * (코어 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인 + * (CLAUDE.md 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인 * `plugin.{id}.*`). * * 이벤트는 `HookManager::broadcast($channel, $eventName, $payload)` 호출 시점에 이름이 diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php index 10cd302d..57f6dc6e 100644 --- a/app/Services/CoreUpdateService.php +++ b/app/Services/CoreUpdateService.php @@ -885,16 +885,7 @@ class CoreUpdateService } /** - * 코어 업데이트 대상 파일을 덮어씁니다. - * - * 기본(증분) 모드 — `$applyList` 가 주어지고 `$prune === false`: - * - 코어(신 버전)가 실제로 추가·변경한 파일만 적용한다(3-way 판정 산출물). - * - 코어가 건드리지 않은 파일은 복사·chmod·chown·mtime 갱신을 전부 스킵하여 현재 - * 디스크 상태(사용자 수정 포함 가능)를 그대로 보존한다. - * - orphan(소스에 없는 대상 파일) 삭제를 하지 않는다 — 사용자가 추가한 신규 파일 보존. - * - * prune 모드 — `$prune === true` 또는 `$applyList === null`(백업 부재 fallback): - * - targets 전체를 무조건 덮어쓰고, 소스에 없는 orphan 을 삭제한다(기존 동작). + * 코어 업데이트 대상 파일만 선택적으로 덮어씁니다. * * 주의: ExtensionPendingHelper::copyToActive()는 PHP copy()를 사용하여 * 파일 퍼미션/소유자를 보존하지 않으므로, 코어 업데이트에서는 사용하지 않습니다. @@ -902,29 +893,12 @@ class CoreUpdateService * * @param string $sourcePath 소스 경로 (_pending 내) * @param \Closure|null $onProgress 진행 콜백 - * @param bool $prune true 면 전체 덮어쓰기 + orphan 삭제(기존 동작), false 면 증분 적용 - * @param array|null $applyList 적용 대상 상대경로 목록(target 접두사 포함). - * `CoreBackupHelper::computeApplyList()` 산출물. null 이면 증분 불가로 판단해 전체 덮어쓰기. */ - public function applyUpdate(string $sourcePath, ?\Closure $onProgress = null, bool $prune = false, ?array $applyList = null): void + public function applyUpdate(string $sourcePath, ?\Closure $onProgress = null): void { $targets = config('app.update.targets', []); $excludes = config('app.update.excludes', []); - // 증분 모드 여부: prune 이 아니고 적용 목록이 주어진 경우에만 증분 적용. - // applyList 가 null 이면(백업 부재 등) 안전을 위해 전체 덮어쓰기로 회귀한다. - $incremental = ! $prune && $applyList !== null; - - // 증분 모드에서 각 target 하위 파일을 O(1) 로 조회하기 위한 lookup 맵. - // computeApplyList 는 target 접두사를 포함한 상대경로(예: 'public/.htaccess')를 - // 반환하므로, 그대로 정규화 키로 담는다. - $applySet = []; - if ($incremental) { - foreach ($applyList as $rel) { - $applySet[$this->normalizeRelativePath((string) $rel)] = true; - } - } - $applied = []; foreach ($targets as $target) { @@ -935,45 +909,21 @@ class CoreUpdateService continue; } - $normalizedTarget = $this->normalizeRelativePath($target); - $onProgress?->__invoke('apply', $target); if (File::isDirectory($src)) { - // 증분 모드: 이 target 하위의 적용 대상만 추린 뒤, target 접두사를 벗겨 - // copyDirectory 의 내부 상대경로($itemRelativePath)와 직접 매칭되는 - // 화이트리스트 맵을 만든다. - $targetApplyList = null; - if ($incremental) { - $targetApplyList = $this->buildTargetApplyList($applySet, $normalizedTarget); - } - // `{domain}/_bundled` 타깃은 최상위 한 레벨의 orphan 을 보존한다 — 사용자가 // `_bundled/` 바로 아래에 직접 만든 커스텀 확장 디렉토리/파일이 코어 업데이트 // 소스(번들 확장만 포함)에 없다는 이유로 삭제되던 결함 차단. - // 번들 확장 디렉토리 *내부* stale 정리는 prune 모드에서만 수행된다. - $preserveTopLevelOrphans = str_ends_with($normalizedTarget, '_bundled'); - FilePermissionHelper::copyDirectory( - $src, - $dest, - $onProgress, - $excludes, - removeOrphans: $prune, - preserveTopLevelOrphans: $preserveTopLevelOrphans, - applyList: $targetApplyList, - ); + // 번들 확장 디렉토리 *내부* stale 정리는 그대로 유지된다. + $preserveTopLevelOrphans = str_ends_with($this->normalizeRelativePath($target), '_bundled'); + FilePermissionHelper::copyDirectory($src, $dest, $onProgress, $excludes, removeOrphans: true, preserveTopLevelOrphans: $preserveTopLevelOrphans); } else { - // 단일 파일 target — 증분 모드에서는 적용 목록에 있을 때만 복사. - if ($incremental && ! isset($applySet[$normalizedTarget])) { - $applied[$normalizedTarget] = true; - - continue; - } File::ensureDirectoryExists(dirname($dest)); FilePermissionHelper::copyFile($src, $dest); } - $applied[$normalizedTarget] = true; + $applied[$this->normalizeRelativePath($target)] = true; } // 자동 발견 폴백 — 부모 프로세스(구버전) 의 stale `app.update.targets` 가 @@ -1055,31 +1005,6 @@ class CoreUpdateService return trim(str_replace(['\\', '/'], '/', $path), '/'); } - /** - * 전체 적용 집합에서 특정 target 하위 항목만 추려, target 접두사를 제거한 - * `copyDirectory` 내부 상대경로 화이트리스트 맵을 만듭니다. - * - * `computeApplyList` 는 target 접두사를 포함한 경로(예: `public/.htaccess`)를 담지만, - * `copyDirectory` 는 target 루트 기준 상대경로(`.htaccess`)로 항목을 순회하므로 접두사를 - * 벗겨야 매칭된다. - * - * @param array $applySet 전체 적용 대상 정규화 경로 lookup 맵 - * @param string $normalizedTarget 정규화된 target 경로 (예: `public`) - * @return array target 내부 상대경로 화이트리스트 (빈 맵일 수 있음 → 전부 스킵) - */ - private function buildTargetApplyList(array $applySet, string $normalizedTarget): array - { - $prefix = $normalizedTarget.'/'; - $out = []; - foreach ($applySet as $rel => $_) { - if (str_starts_with((string) $rel, $prefix)) { - $out[substr((string) $rel, strlen($prefix))] = true; - } - } - - return $out; - } - /** * 정규화된 path 가 이미 처리된 상위/하위 path 와 관계가 있는지 검사합니다. * @@ -1633,9 +1558,8 @@ class CoreUpdateService * @param string $toVersion 종료 버전 * @param \Closure|null $onStep 각 스텝 실행 시 콜백 (버전 문자열 전달) * @param bool $force true 시 fromVersion == toVersion이면 해당 버전 스텝도 포함 - * @param \Closure|null $onDiscovered 범위 내 발견된 스텝 파일 수를 실행 전 1회 통지 (int 전달) */ - public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false, ?\Closure $onDiscovered = null): void + public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false): void { // 부모 in-process fallback 진입 시 stale 메모리 가드. // @@ -1673,9 +1597,6 @@ class CoreUpdateService $upgradesPath = base_path('upgrades'); if (! File::isDirectory($upgradesPath)) { - // upgrades 디렉토리 자체가 없으면 발견된 스텝 0건 — 정상 통지 후 종료. - $onDiscovered?->__invoke(0); - return; } @@ -1735,12 +1656,6 @@ class CoreUpdateService uksort($steps, 'version_compare'); - // 범위 내에서 발견된 스텝 파일 수(discovered)를 먼저 통지한다. - // 호출자(spawn 자식)는 이 값으로 "스텝 파일이 애초에 없어 0건(정상)" 과 "스텝 파일은 - // 있는데 실행이 0건(비정상 — gnuboard/g7#28 silent skip)" 을 구분한다. onStep 은 실제 실행 - // 건마다 호출되므로 executed 수만 세며, discovered 는 실행 전에 1회 통지한다. - $onDiscovered?->__invoke(count($steps)); - $context = new UpgradeContext($fromVersion, $toVersion); foreach ($steps as $version => $step) { diff --git a/app/Services/ExtensionBundleService.php b/app/Services/ExtensionBundleService.php deleted file mode 100644 index 0125ff39..00000000 --- a/app/Services/ExtensionBundleService.php +++ /dev/null @@ -1,475 +0,0 @@ -` 로 실행해도 등록 로직은 동일하게 동작한다. - * - * 정렬/필터(`hasAssets()` && strategy==='global' + `uasort(priority)`) 는 - * TemplateComposer 와 공유하는 SSoT 로 이 서비스에 둔다(drift 방지). - * - * 경로는 절대경로 게터(`getBuiltAssetAbsolutePaths()`)를 재사용한다 — - * `ModuleService::getAssetFilePath()` 의 `base_path("modules/{id}/...")` - * 하드코딩을 복제하지 않아야 `_bundled` 확장에서도 정확히 읽는다(제약 4). - * - * @see TemplateComposer - */ -class ExtensionBundleService -{ - // ext.cache_version 게터 재사용 — 트레이트를 use 해 self:: 로 호출(트레이트명 - // 직접 정적 호출은 PHP 8.3+ deprecated). 인스턴스 캐시 무효화 메서드는 미사용. - use ClearsTemplateCaches; - - /** - * 번들 캐시 파일이 저장되는 스토리지 디스크(= storage/app/ext-bundles). - */ - private const BUNDLE_DISK = 'ext-bundles'; - - /** - * 서비스 주입 - * - * @param ModuleManager $moduleManager 모듈 매니저 - * @param PluginManager $pluginManager 플러그인 매니저 - */ - public function __construct( - private readonly ModuleManager $moduleManager, - private readonly PluginManager $pluginManager - ) {} - - /** - * 확장 타입별 global 전략 에셋을 priority 오름차순으로 정렬해 반환합니다. - * - * TemplateComposer 의 개별 에셋 URL 생성과 번들러의 concat 이 동일한 - * 필터/정렬을 쓰도록 하는 SSoT. 순서 제어는 오직 manifest - * `loading.priority` 숫자 오름차순뿐이며 특정 확장 이름 하드코딩은 없다(제약 1). - * - * @param string $type 'module' | 'plugin' - * @return array - * identifier => 절대경로/우선순위 (priority 오름차순 정렬) - */ - public function getOrderedGlobalAssetPaths(string $type): array - { - $extensions = $type === 'plugin' - ? $this->pluginManager->getActivePlugins() - : $this->moduleManager->getActiveModules(); - - $ordered = []; - - foreach ($extensions as $extension) { - if (! $extension->hasAssets()) { - continue; - } - - $loadingConfig = $extension->getAssetLoadingConfig(); - - // global 전략만 번들 대상 (layout, lazy 는 레이아웃에서 개별 처리) - if (($loadingConfig['strategy'] ?? 'global') !== 'global') { - continue; - } - - $absolutePaths = $extension->getBuiltAssetAbsolutePaths(); - - $jsAbsPath = $absolutePaths['js'] ?? null; - $cssAbsPath = $absolutePaths['css'] ?? null; - - // JS/CSS 둘 다 없으면 번들에 기여할 것이 없으므로 제외 - if ($jsAbsPath === null && $cssAbsPath === null) { - continue; - } - - $ordered[$extension->getIdentifier()] = [ - 'jsAbsPath' => $jsAbsPath, - 'cssAbsPath' => $cssAbsPath, - 'priority' => (int) ($loadingConfig['priority'] ?? 100), - ]; - } - - // priority 오름차순 (낮을수록 먼저) — 개별 로딩과 동일 규칙 - uasort($ordered, fn ($a, $b) => $a['priority'] <=> $b['priority']); - - return $ordered; - } - - /** - * 확장 타입의 JS 번들 문자열을 생성합니다. - * - * priority 순으로 각 IIFE 파일을 읽어 `\n;\n` 구분자로 이어붙인다(제약 2 — - * ASI 경계 보호). 각 파일 끝의 `//# sourceMappingURL` 주석은 prod 에서는 - * strip(맵 생략), dev 에서는 개별 에셋 서빙 절대 URL 로 rewrite 한다(제약 3). - * - * 확장별 fine-grained try/catch — 파일 읽기 실패 시 해당 확장만 skip + - * Log::warning, 나머지 병합 지속(한 확장 실패가 번들 전체를 붕괴시키지 않음). - * - * @param string $type 'module' | 'plugin' - * @return string 병합된 JS (활성 global 에셋이 없으면 빈 문자열) - */ - public function buildJsBundle(string $type): string - { - $ordered = $this->getOrderedGlobalAssetPaths($type); - $isProduction = app()->environment('production'); - $segments = []; - - foreach ($ordered as $identifier => $paths) { - if (empty($paths['jsAbsPath'])) { - continue; - } - - try { - $content = @file_get_contents($paths['jsAbsPath']); - - if ($content === false) { - Log::warning('확장 JS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [ - 'type' => $type, - 'identifier' => $identifier, - 'path' => $paths['jsAbsPath'], - ]); - - continue; - } - - $segments[] = $this->processJsSourceMap($content, $type, $identifier, $isProduction); - } catch (\Throwable $e) { - Log::warning('확장 JS 번들 병합 중 오류, 해당 확장 skip', [ - 'type' => $type, - 'identifier' => $identifier, - 'error' => $e->getMessage(), - ]); - } - } - - return implode("\n;\n", $segments); - } - - /** - * 확장 타입의 CSS 번들 문자열을 생성합니다. - * - * priority 순으로 각 CSS 파일을 읽어 `\n` 구분자로 이어붙인다. 상대경로 - * `url(...)` 참조가 있는 CSS 는 병합 시 경로가 깨지므로 번들에서 제외하고 - * 경고 로그를 남긴다(안전장치 — 현재 번들 CSS 는 url() 0건). - * - * @param string $type 'module' | 'plugin' - * @return string 병합된 CSS (활성 global 에셋이 없으면 빈 문자열) - */ - public function buildCssBundle(string $type): string - { - $ordered = $this->getOrderedGlobalAssetPaths($type); - $segments = []; - - foreach ($ordered as $identifier => $paths) { - if (empty($paths['cssAbsPath'])) { - continue; - } - - try { - $content = @file_get_contents($paths['cssAbsPath']); - - if ($content === false) { - Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [ - 'type' => $type, - 'identifier' => $identifier, - 'path' => $paths['cssAbsPath'], - ]); - - continue; - } - - // 상대경로 url() 참조가 있으면 병합 시 폰트/이미지 경로가 깨진다. - // 절대/data URI 는 안전하므로 상대경로만 검출해 해당 CSS 제외. - if ($this->hasRelativeUrl($content)) { - Log::warning('확장 CSS 에 상대경로 url() 존재 — 번들에서 제외(개별 폴백 유지)', [ - 'type' => $type, - 'identifier' => $identifier, - ]); - - continue; - } - - $segments[] = $content; - } catch (\Throwable $e) { - Log::warning('확장 CSS 번들 병합 중 오류, 해당 확장 skip', [ - 'type' => $type, - 'identifier' => $identifier, - 'error' => $e->getMessage(), - ]); - } - } - - return implode("\n", $segments); - } - - /** - * 캐시된 번들 파일의 절대 경로를 반환합니다(없으면 build → 원자적 write). - * - * 파일명에 version 을 포함(`{type}.{version}.{js|css}`)하므로 활성 조합이 - * 바뀌어 version 이 bump 되면 새 파일명으로 자연 무효화된다. 프로덕션에서만 - * 디스크 캐시하며, 비프로덕션(dev/watch)에서는 캐시하지 않고 매 요청 build 해 - * rebuild 를 즉시 반영한다. - * - * @param string $type 'module' | 'plugin' - * @param string $kind 'js' | 'css' - * @param int $version 확장 캐시 버전(ClearsTemplateCaches::getExtensionCacheVersion) - * @return string 캐시(또는 방금 build 한) 파일의 절대 경로. 병합 결과가 빈 문자열이면 빈 문자열. - */ - public function getBundleFilePath(string $type, string $kind, int $version): string - { - $content = $kind === 'css' - ? $this->buildCssBundle($type) - : $this->buildJsBundle($type); - - // 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단). - if ($content === '') { - return ''; - } - - $storage = $this->bundleStorage(); - $relativeName = $this->bundleFileName($type, $kind, $version); - - // 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영 - if (! app()->environment('production')) { - return $this->writeAtomically($storage, $relativeName, $content, cache: false); - } - - // 프로덕션: 동일 version 캐시가 있으면 그대로 사용 - if ($storage->exists('', $relativeName)) { - return $storage->getBasePath('').'/'.$relativeName; - } - - return $this->writeAtomically($storage, $relativeName, $content, cache: true); - } - - /** - * 현재 version 외의 오래된 번들 파일을 삭제하고 삭제 건수를 반환합니다. - * - * @param int $currentVersion 보존할 현재 캐시 버전 - * @return int 삭제된 파일 수 - */ - public function cleanupStaleBundles(int $currentVersion): int - { - $storage = $this->bundleStorage(); - $deleted = 0; - - foreach ($storage->files('', '') as $file) { - $name = basename($file); - - // 현재 version 파일과 .gitignore 는 보존 - if ($name === '.gitignore' || $this->matchesVersion($name, $currentVersion)) { - continue; - } - - if ($this->isBundleFile($name) && $storage->delete('', $name)) { - $deleted++; - } - } - - return $deleted; - } - - /** - * 번들 캐시 파일을 삭제합니다(cache-clear 커맨드용). - * - * @param string|null $type 'module' | 'plugin' 지정 시 해당 타입만, null 이면 전체 - * @return int 삭제된 파일 수 - */ - public function clearBundles(?string $type = null): int - { - $storage = $this->bundleStorage(); - $deleted = 0; - - foreach ($storage->files('', '') as $file) { - $name = basename($file); - - if ($name === '.gitignore' || ! $this->isBundleFile($name)) { - continue; - } - - // 타입 필터 (파일명 접두사 `{type}.`) - if ($type !== null && ! str_starts_with($name, $type.'.')) { - continue; - } - - if ($storage->delete('', $name)) { - $deleted++; - } - } - - return $deleted; - } - - /** - * 번들 파일명을 생성합니다(`{type}.{version}.{kind}`). - * - * @param string $type 'module' | 'plugin' - * @param string $kind 'js' | 'css' - * @param int $version 캐시 버전 - * @return string 파일명 (디렉토리 제외) - */ - private function bundleFileName(string $type, string $kind, int $version): string - { - return "{$type}.{$version}.{$kind}"; - } - - /** - * 파일명이 번들 파일 패턴(`{type}.{version}.{kind}`)인지 확인합니다. - * - * @param string $name 파일명 - * @return bool 번들 파일이면 true - */ - private function isBundleFile(string $name): bool - { - return (bool) preg_match('/^(module|plugin)\.\d+\.(js|css)$/', $name); - } - - /** - * 파일명이 지정한 version 의 번들인지 확인합니다. - * - * @param string $name 파일명 - * @param int $version 비교할 버전 - * @return bool 해당 version 파일이면 true - */ - private function matchesVersion(string $name, int $version): bool - { - return (bool) preg_match('/^(module|plugin)\.'.preg_quote((string) $version, '/').'\.(js|css)$/', $name); - } - - /** - * 병합 결과를 원자적으로(임시 파일 → rename) 기록하고 절대 경로를 반환합니다. - * - * @param CoreStorageDriver $storage 번들 디스크 스토리지 - * @param string $relativeName 대상 파일명 - * @param string $content 기록할 내용 - * @param bool $cache true 면 version 파일명 유지, false 면 임시 파일 사용 - * @return string 기록된 파일의 절대 경로 - */ - private function writeAtomically(CoreStorageDriver $storage, string $relativeName, string $content, bool $cache): string - { - $basePath = $storage->getBasePath(''); - - if (! is_dir($basePath)) { - @mkdir($basePath, 0o755, true); - } - - $finalPath = $basePath.'/'.$relativeName; - - if (! $cache) { - // 비프로덕션: 매 요청 덮어써도 무방(원자성 불요), 그대로 write - $storage->put('', $relativeName, $content); - - return $finalPath; - } - - // 프로덕션: 임시 파일에 쓴 뒤 rename 으로 원자적 게시(부분 파일 서빙 방지) - $tmpName = $relativeName.'.tmp.'.getmypid(); - $storage->put('', $tmpName, $content); - - $tmpPath = $basePath.'/'.$tmpName; - - if (! @rename($tmpPath, $finalPath)) { - // rename 실패 시(경합으로 이미 존재 등) 임시 파일 정리 후 최종 경로 사용 - $storage->delete('', $tmpName); - } - - return $finalPath; - } - - /** - * IIFE 소스맵 주석을 환경에 맞게 처리합니다. - * - * prod: `//# sourceMappingURL` 라인 strip(맵 생략). - * dev: 개별 에셋 서빙 절대 URL(`/api/{type}s/assets/{id}/dist/js/*.map`)로 - * rewrite → 브라우저가 확장별 원본 맵을 추적(완벽한 통합 맵은 아님). - * - * @param string $content 원본 IIFE 내용 - * @param string $type 'module' | 'plugin' - * @param string $identifier 확장 식별자 - * @param bool $isProduction 프로덕션 여부 - * @return string 처리된 내용 - */ - private function processJsSourceMap(string $content, string $type, string $identifier, bool $isProduction): string - { - // 구분자로 `~` 사용 — 패턴 자체에 `#`(`//#`)가 포함되어 `#` 구분자는 못 씀 - $pattern = '~//# sourceMappingURL=(\S+)~'; - - if ($isProduction) { - // prod: 맵 참조 제거 - return preg_replace($pattern, '', $content) ?? $content; - } - - // dev: 상대 맵 파일명을 개별 에셋 서빙 절대 URL 로 rewrite - $typeSegment = $type === 'plugin' ? 'plugins' : 'modules'; - - return preg_replace_callback($pattern, function (array $m) use ($typeSegment, $identifier) { - $mapFile = ltrim($m[1], './'); - - return '//# sourceMappingURL=/api/'.$typeSegment.'/assets/'.$identifier.'/dist/js/'.basename($mapFile); - }, $content) ?? $content; - } - - /** - * CSS 내용에 상대경로 url() 참조가 있는지 확인합니다. - * - * 절대 URL(http/https), 루트 절대경로(/), data URI 는 병합에 안전하므로 - * 그 외의 url() 참조만 상대경로로 간주한다. - * - * @param string $css CSS 내용 - * @return bool 상대경로 url() 이 하나라도 있으면 true - */ - private function hasRelativeUrl(string $css): bool - { - if (! preg_match_all('/url\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)/i', $css, $matches)) { - return false; - } - - foreach ($matches[1] as $url) { - $url = trim($url); - - if ($url === '') { - continue; - } - - $isAbsolute = str_starts_with($url, 'http://') - || str_starts_with($url, 'https://') - || str_starts_with($url, '//') - || str_starts_with($url, '/') - || str_starts_with($url, 'data:'); - - if (! $isAbsolute) { - return true; - } - } - - return false; - } - - /** - * 번들 디스크용 스토리지 드라이버를 반환합니다(StorageInterface 경유). - * - * @return CoreStorageDriver ext-bundles 디스크 스토리지 - */ - private function bundleStorage(): CoreStorageDriver - { - return (new CoreStorageDriver)->withDisk(self::BUNDLE_DISK); - } - - /** - * 현재 확장 캐시 버전을 반환합니다. - * - * @return int 캐시 버전 - */ - public function getCurrentVersion(): int - { - return self::getExtensionCacheVersion(); - } -} diff --git a/app/Services/GeoIpDatabaseService.php b/app/Services/GeoIpDatabaseService.php index 48a20746..43f89345 100644 --- a/app/Services/GeoIpDatabaseService.php +++ b/app/Services/GeoIpDatabaseService.php @@ -4,7 +4,7 @@ namespace App\Services; use App\Contracts\Repositories\ConfigRepositoryInterface; use App\Extension\HookManager; -use App\Support\ConfigCacheHelper; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -369,8 +369,8 @@ class GeoIpDatabaseService $current['last_updated_at'] = now()->toIso8601String(); $this->configRepository->saveCategory('geoip', $current); - // config 캐시 재로드 강제 + 즉시 재생성 (clear 만 하면 캐시 비활성 잔존). - ConfigCacheHelper::rebuild(); + // Laravel config 캐시 재로드 강제 + Artisan::call('config:clear'); } catch (\Throwable $e) { Log::warning('GeoIP last_updated_at 갱신 실패', ['error' => $e->getMessage()]); } diff --git a/app/Services/LayoutService.php b/app/Services/LayoutService.php index 0e5fe794..71057529 100644 --- a/app/Services/LayoutService.php +++ b/app/Services/LayoutService.php @@ -807,12 +807,6 @@ class LayoutService // 캐시 비활성 시 매번 병합 실행 if (! $cacheEnabled) { $merged = $this->loadAndMergeLayoutInternal($templateId, $layoutName, $withSourceMeta); - - // 공개 서빙 응답에서 개발자용 comment 필드 제거 (편집 모드는 보존) - if (! $withSourceMeta) { - $merged = $this->stripDeveloperComments($merged); - } - HookManager::doAction('core.layout.after_load', $merged, $templateId, $layoutName, false); return $merged; @@ -850,12 +844,6 @@ class LayoutService // 병합된 레이아웃 생성 $mergedLayout = $this->loadAndMergeLayoutInternal($templateId, $layoutName, $withSourceMeta); - // 공개 서빙 응답에서 개발자용 comment 필드 제거 (편집 모드는 보존). - // withSourceMeta 가 캐시 키에 반영되므로 공개/편집 캐시가 분리되어 안전. - if (! $withSourceMeta) { - $mergedLayout = $this->stripDeveloperComments($mergedLayout); - } - // 캐시에 저장 $cacheTtl = $this->getCacheTtl(); $this->cache->put($cacheKey, $mergedLayout, $cacheTtl); @@ -1613,30 +1601,6 @@ class LayoutService }, $components); } - /** - * 병합된 레이아웃에서 개발자용 comment 필드를 재귀적으로 제거합니다. - * - * 레이아웃 JSON 의 `comment` / `_comment` 키는 편집기(layout-editor)와 개발자를 위한 - * 설명 주석으로, 프론트엔드 런타임 렌더러(DynamicRenderer)는 이를 참조하지 않는다. - * 공개 서빙 응답에서만 제거하여 전송 크기를 줄이며, 편집 모드(withSourceMeta)에서는 - * 호출하지 않아 보존한다. 배열/객체 노드를 재귀 순회하며 두 키만 unset 한다. - * - * @param array $node 정리 대상 레이아웃(또는 하위) 배열 - * @return array comment 필드가 제거된 배열 - */ - private function stripDeveloperComments(array $node): array - { - unset($node['comment'], $node['_comment']); - - foreach ($node as $key => $value) { - if (is_array($value)) { - $node[$key] = $this->stripDeveloperComments($value); - } - } - - return $node; - } - /** * 컴포넌트의 props를 sanitize합니다. * diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 475a8f0d..d9fb5e12 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -7,7 +7,6 @@ use App\Contracts\Repositories\AttachmentRepositoryInterface; use App\Contracts\Repositories\ConfigRepositoryInterface; use App\Extension\HookManager; use App\Http\Resources\AttachmentResource; -use App\Support\ConfigCacheHelper; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -33,14 +32,11 @@ class SettingsService * 시스템 설정 캐시를 무효화합니다. * * 설정 저장 시 4곳에서 중복되던 로직을 단일 메서드로 통합했습니다. - * config 캐시는 clear 후 즉시 재생성한다(ConfigCacheHelper). config:clear 만 - * 하면 config:cache 가 비워진 채 재생성되지 않아 이후 모든 요청이 config 파일을 - * 재파싱하는 성능 손실이 발생하기 때문이다. */ private function invalidateSettingsCache(): void { $this->cache->forget('settings.system'); - ConfigCacheHelper::rebuild(); + Artisan::call('config:clear'); } /** @@ -826,74 +822,30 @@ class SettingsService private function buildSystemInfo(): array { return [ - // php_uname / ini_get 은 disable_functions 로 차단될 수 있어 개별 격리한다. - 'os_info' => $this->safeSystemProbe('os_info', fn () => php_uname('s').' '.php_uname('r'), __('common.unknown')), + 'os_info' => php_uname('s').' '.php_uname('r'), 'web_server' => $_SERVER['SERVER_SOFTWARE'] ?? __('common.unknown'), 'php_version' => PHP_VERSION, - 'mysql_version' => $this->safeSystemProbe('mysql_version', fn () => $this->getDatabaseInfo(), __('common.unknown')), + 'mysql_version' => $this->getDatabaseInfo(), 'g7_version' => config('app.version', '1.0.0'), 'g7_release_year' => config('app.release_year', '2026'), 'laravel_version' => app()->version(), 'environment' => app()->environment(), - 'cpu_info' => $this->safeSystemProbe('cpu_info', fn () => $this->getCpuInfo(), __('common.unknown')), - 'memory_usage' => $this->safeSystemProbe('memory_usage', fn () => $this->getMemoryUsage(), $this->unknownUsage()), - 'disk_usage' => $this->safeSystemProbe('disk_usage', fn () => $this->getDiskUsage(), $this->unknownUsage()), - 'php_memory_limit' => $this->safeSystemProbe('php_memory_limit', fn () => ini_get('memory_limit'), __('common.unknown')), - 'max_execution_time' => $this->safeSystemProbe('max_execution_time', fn () => ini_get('max_execution_time').__('settings.seconds'), __('common.unknown')), - 'upload_max_filesize' => $this->safeSystemProbe('upload_max_filesize', fn () => ini_get('upload_max_filesize'), __('common.unknown')), + 'cpu_info' => $this->getCpuInfo(), + 'memory_usage' => $this->getMemoryUsage(), + 'disk_usage' => $this->getDiskUsage(), + 'php_memory_limit' => ini_get('memory_limit'), + 'max_execution_time' => ini_get('max_execution_time').__('settings.seconds'), + 'upload_max_filesize' => ini_get('upload_max_filesize'), 'install_path' => base_path(), 'config_path' => storage_path('app/settings'), 'log_path' => storage_path('logs'), 'upload_path' => storage_path('app/public'), - 'php_extensions' => $this->safeSystemProbe('php_extensions', fn () => $this->getPhpExtensions(), ['required' => [], 'optional' => []]), - 'database_config' => $this->safeSystemProbe('database_config', fn () => $this->getDatabaseConfig(), ['has_read_write_split' => false, 'write' => [], 'read' => []]), + 'php_extensions' => $this->getPhpExtensions(), + 'database_config' => $this->getDatabaseConfig(), 'timezone' => config('app.timezone'), ]; } - /** - * 시스템 정보 probe 를 안전하게 실행합니다. - * - * probe 가 예외(ErrorException·Error·disable_functions 로 인한 Error 포함)를 - * 던지면 전파하지 않고 폴백값을 반환하고 경고 로그를 남깁니다. 개별 항목 수집 - * 실패가 system-info API 전체를 500 으로 만들지 않도록 격리합니다. - * - * @param string $label 실패 로그 식별용 항목명 (예: 'cpu_info') - * @param callable $cb 실행할 probe 콜백 - * @param mixed $fallback 실패 시 반환할 폴백 값 - * @return mixed probe 결과 또는 폴백 - */ - private function safeSystemProbe(string $label, callable $cb, mixed $fallback): mixed - { - try { - return $cb(); - } catch (\Throwable $e) { - Log::warning('시스템 정보 수집 실패', [ - 'item' => $label, - 'error' => $e->getMessage(), - ]); - - return $fallback; - } - } - - /** - * 메모리/디스크 사용량 조회 실패 시 사용할 폴백 배열을 반환합니다. - * - * total/used/free/percentage 형식을 memory_usage·disk_usage 와 동일하게 유지합니다. - * - * @return array 사용량 폴백 배열 - */ - private function unknownUsage(): array - { - return [ - 'total' => __('common.unknown'), - 'used' => __('common.unknown'), - 'free' => __('common.unknown'), - 'percentage' => 0, - ]; - } - /** * 시스템 캐시를 정리합니다. * @@ -907,12 +859,10 @@ class SettingsService } Artisan::call('cache:clear'); + Artisan::call('config:clear'); Artisan::call('route:clear'); Artisan::call('view:clear'); - // config 는 clear 후 즉시 재생성 (비운 채 두면 이후 모든 요청이 config 재파싱). - ConfigCacheHelper::rebuild(); - return true; } catch (\Exception $e) { return false; @@ -958,8 +908,7 @@ class SettingsService $envContent = preg_replace('/^APP_KEY=.*/m', 'APP_KEY='.$newKey, $envContent); file_put_contents($envPath, $envContent); - // .env 변경 반영 + config 캐시 재생성 (clear 만 하면 캐시 비활성 상태로 잔존). - ConfigCacheHelper::rebuild(); + Artisan::call('config:clear'); return [ 'success' => true, @@ -1000,16 +949,11 @@ class SettingsService try { $connection = DB::connection(); $driver = $connection->getDriverName(); - $version = $connection->select('SELECT VERSION() as version')[0]->version ?? __('common.unknown'); + $version = $connection->select('SELECT VERSION() as version')[0]->version ?? 'Unknown'; return ucfirst($driver).' '.$version; - } catch (\Throwable $e) { - Log::warning('시스템 정보 수집 실패', [ - 'item' => 'mysql_version', - 'error' => $e->getMessage(), - ]); - - return __('common.unknown'); + } catch (\Exception $e) { + return 'Unknown'; } } @@ -1055,7 +999,12 @@ class SettingsService } if ($total <= 0) { - return $this->unknownUsage(); + return [ + 'total' => __('common.unknown'), + 'used' => __('common.unknown'), + 'free' => __('common.unknown'), + 'percentage' => 0, + ]; } $used = max(0, $total - $free); @@ -1085,7 +1034,12 @@ class SettingsService $free = is_numeric($freeRaw) ? (int) $freeRaw : 0; if ($total <= 0) { - return $this->unknownUsage(); + return [ + 'total' => __('common.unknown'), + 'used' => __('common.unknown'), + 'free' => __('common.unknown'), + 'percentage' => 0, + ]; } $used = max(0, $total - $free); @@ -1123,7 +1077,7 @@ class SettingsService * * @return string CPU 정보 문자열 */ - protected function getCpuInfo(): string + private function getCpuInfo(): string { if (PHP_OS_FAMILY === 'Windows') { $output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1'); diff --git a/app/Services/TemplateLayoutAttachmentService.php b/app/Services/TemplateLayoutAttachmentService.php index d9596476..929e68b0 100644 --- a/app/Services/TemplateLayoutAttachmentService.php +++ b/app/Services/TemplateLayoutAttachmentService.php @@ -18,7 +18,7 @@ use Illuminate\Support\Str; * 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)의 업로드·조회·삭제를 처리한다. * 파일 저장은 코어 StorageInterface(CoreStorageDriver)를 통해서만 수행하고, * 저장 위치(disk/path)와 메타데이터를 template_layout_attachments 에 기록한다. - * (Storage::disk() 직접 호출 금지 — 코어 스토리지 규칙) + * (Storage::disk() 직접 호출 금지 — CLAUDE.md 스토리지 규칙) */ class TemplateLayoutAttachmentService { @@ -114,7 +114,7 @@ class TemplateLayoutAttachmentService * 첨부 파일을 삭제합니다 — 스토리지 파일 실삭제 후 DB 행 삭제. * * DB CASCADE 에 의존하지 않고 스토리지 파일을 명시적으로 삭제한다 - * (코어 규정: DB CASCADE 의존 삭제 금지 — 파일 정리 보장). + * (CLAUDE.md "DB CASCADE 의존 삭제 금지" — 파일 정리 보장). * * @param TemplateLayoutAttachment $attachment 삭제할 첨부 파일 * @return bool 삭제 성공 여부 diff --git a/app/Support/ConfigCacheHelper.php b/app/Support/ConfigCacheHelper.php deleted file mode 100644 index b0d27889..00000000 --- a/app/Support/ConfigCacheHelper.php +++ /dev/null @@ -1,89 +0,0 @@ -environment('testing') || ! self::isInstalled()) { - return; - } - - try { - Artisan::call('config:cache'); - } catch (\Throwable $e) { - Log::warning('config 캐시 재생성 실패 (config:clear 로 stale 은 제거됨 — 다음 요청은 비캐시 부팅)', [ - 'error' => $e->getMessage(), - ]); - } - } - - /** - * config 캐시만 제거합니다 (재생성 없음). - * - * 재생성이 부적절한 특수 경로(예: 설치 직전 초기화)를 위한 보조 진입점. - * - * @return void - */ - public static function clear(): void - { - Artisan::call('config:clear'); - } - - /** - * G7 설치 완료 여부를 확인합니다. - * - * config('app.installer_completed') 는 config:clear 직후 재로드되어 신뢰 가능하며, - * 보조로 storage/app/g7_installed 플래그 파일도 확인한다. - * - * @return bool 설치 완료 시 true - */ - private static function isInstalled(): bool - { - if (config('app.installer_completed')) { - return true; - } - - return File::exists(storage_path('app/g7_installed')); - } -} diff --git a/config/app.php b/config/app.php index a7d6823b..536b5173 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.0.2'), + 'version' => env('APP_VERSION', '7.0.1'), /* |-------------------------------------------------------------------------- @@ -270,7 +270,7 @@ return [ 'github_url' => env('G7_UPDATE_GITHUB_URL', 'https://github.com/gnuboard/g7'), 'github_token' => env('G7_UPDATE_GITHUB_TOKEN', ''), 'pending_path' => env('G7_UPDATE_PENDING_PATH') ?: storage_path('app/core_pending'), - 'targets' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_TARGETS', 'app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vite.config.editor.js,vite.config.devtools.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,.editorconfig,.gitattributes,.gitignore,README.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled')))), + 'targets' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_TARGETS', 'app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,.editorconfig,.gitattributes,.gitignore,README.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled')))), 'excludes' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_EXCLUDES', 'node_modules,.git,bootstrap/cache')))), // applyUpdate 의 "신규 최상위 항목 자동 발견" 폴백이 절대 덮어쓰면 안 되는 경로 목록. // 런타임 데이터(`storage`), 로컬 환경(`.env*`), 별도 파이프라인 산출물(`vendor`), diff --git a/config/database.php b/config/database.php index 05fcc509..da01b564 100644 --- a/config/database.php +++ b/config/database.php @@ -45,20 +45,14 @@ return [ 'mysql' => [ 'driver' => 'mysql', - // Read 커넥션 — DB_READ_* 가 비어 있으면(미설정/빈 값/주석) 대응하는 - // DB_WRITE_* 로 fallback 한다. Read DB 미사용 환경에서도 SELECT 가 write DB 로 - // 정확히 향하도록 보장한다. (이슈 #63) - // 중첩 env(A, env(B)) 대신 Elvis(?:) 를 쓰는 이유: env('DB_READ_HOST') 가 - // 빈 문자열('')을 반환하면 두 번째 인자(default)가 적용되지 않아 fallback 이 - // 무력화된다. ?: 는 빈 문자열까지 falsy 로 흡수하여 write 로 넘긴다. 'read' => [ 'host' => [ - env('DB_READ_HOST') ?: env('DB_WRITE_HOST', '127.0.0.1'), + env('DB_READ_HOST', '127.0.0.1'), ], - 'port' => env('DB_READ_PORT') ?: env('DB_WRITE_PORT', '3306'), - 'database' => env('DB_READ_DATABASE') ?: env('DB_WRITE_DATABASE', 'laravel'), - 'username' => env('DB_READ_USERNAME') ?: env('DB_WRITE_USERNAME', 'root'), - 'password' => env('DB_READ_PASSWORD') ?: env('DB_WRITE_PASSWORD', ''), + 'port' => env('DB_READ_PORT', '3306'), + 'database' => env('DB_READ_DATABASE', 'laravel'), + 'username' => env('DB_READ_USERNAME', 'root'), + 'password' => env('DB_READ_PASSWORD', ''), ], 'write' => [ 'host' => [ diff --git a/config/filesystems.php b/config/filesystems.php index 56165197..ede7cb8e 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -74,16 +74,6 @@ return [ 'report' => false, ], - // 확장(모듈/플러그인) 프론트엔드 IIFE/CSS 번들 병합 결과 캐시. - // ExtensionBundleService 가 version-in-path 파일명으로 저장/서빙한다. - 'ext-bundles' => [ - 'driver' => 'local', - 'root' => storage_path('app/ext-bundles'), - 'serve' => false, - 'throw' => true, - 'report' => false, - ], - 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/docs/backend/core-update-system.md b/docs/backend/core-update-system.md index 0debc90d..7380763e 100644 --- a/docs/backend/core-update-system.md +++ b/docs/backend/core-update-system.md @@ -111,8 +111,7 @@ ├─ Step 4: 다운로드 (GitHub zipball 또는 --source/--local에서 복사) ├─ Step 5: 백업 생성 (--no-backup 시 스킵) ├─ Step 6: Composer install (_pending에서 실행, 변경 없으면 스킵) -├─ Step 6.5: 신규 파일 manifest 생성 + 증분 적용 대상(3-way) 산출 (백업 있을 때) -├─ Step 7: 파일 적용 (_pending → base_path, 기본=코어 변경분만 / --prune=전체 덮어쓰기) +├─ Step 7: 파일 적용 (_pending → base_path 선택적 덮어쓰기) ├─ Step 8: vendor 복사 (_pending/vendor → base_path/vendor, Step 6 스킵 시 함께 스킵) ├─ Step 9: 마이그레이션 + 동기화 (migrate, roles, permissions, menus, mail templates) ├─ Step 10: 업그레이드 스텝 실행 (upgrades/Upgrade_X_Y_Z.php) @@ -179,49 +178,16 @@ v접두사 자동 감지 (resolveGithubArchiveUrl): > Vendor 번들 시스템 상세: [docs/extension/vendor-bundle.md](../extension/vendor-bundle.md) -### Step 6.5: 신규 파일 manifest + 증분 적용 대상 산출 - -```text -백업이 있을 때(--no-backup 아님)만 수행: - -1. writeNewFilesManifest() — 자동 롤백용 `_new_files_manifest.json` 기록 - (base=백업 vs theirs=_pending: 신 버전이 추가한 파일/디렉토리 목록) - -2. computeApplyList() — 기본(증분) 모드의 3-way 적용 대상 산출 (--prune 시 스킵) - · base = 구버전 원본 = 백업 스냅샷 - · theirs = 신 버전 = _pending - · base 없음 → added / size·md5 다름 → changed / 동일 → 제외(스킵) - · size 선필터 후 size 동일할 때만 md5 (mtime 비교 안 함 — _pending 은 추출 시각) - · symlink / excludes / protected_paths 하위 → 목록 제외 - · 단, targets 에 더 구체적으로 명시된 경로는 상위 protected 를 오버라이드(아래 주석) -``` - -> **protected_paths 오버라이드 (공개 #64 / 내부 #452)**: `protected_paths` 에는 확장 부모(`modules`·`plugins`·`templates`·`lang-packs`)가 포함되지만, `targets` 에는 `{domain}/_bundled` 가 명시된다. 3-way 산출(`computeApplyList`)과 신규 파일 manifest(`writeNewFilesManifest`)는 "targets 에 더 구체적(하위)으로 명시된 경로가 상위 protected 를 오버라이드"하도록 판정한다. 이로써 코어 배포본에 포함된 번들 확장의 갱신 파일(`_bundled/{id}/composer.json`·`vendor-bundle.json` 등)이 코어 업데이트로 정상 반영된다. 오버라이드는 target 이 protected 보다 **더 깊을 때만** 적용되므로, `storage`(target) == `storage`(protected) 같은 동일 경로는 여전히 제외된다. 확장 부모를 protected 에 둔 원래 의도(자동 발견 폴백이 활성 서브디렉토리 `modules/sirsoft-*` 를 삭제하는 #347 방어)는 그대로 유지된다 — 자동 발견 폴백은 targets 순회가 아니므로 오버라이드 영향을 받지 않는다. - ### Step 7: 파일 적용 ```text -기본(증분) 모드 — --prune 미지정 + 백업 있음: -- Step 6.5 의 applyList(코어가 실제 변경/추가한 파일)에 있는 파일만 적용 -- 코어가 건드리지 않은 파일은 복사·chmod·chown·mtime 갱신을 전부 스킵 → 현재 디스크 - 상태(사용자 수정 포함 가능)를 그대로 보존 -- orphan(소스에 없는 대상 파일) 삭제 안 함 → 사용자가 추가한 신규 파일 보존 - ---prune 모드 (또는 백업 부재 fallback): -- targets 전체 무조건 덮어쓰기 + orphan 삭제 (기존 동작) -- 백업 부재 시 base 가 없어 3-way 불가 → 안전하게 전체 덮어쓰기로 회귀 + 안내 출력 - -공통: +- _pending 소스에서 config('app.update.targets') 에 해당하는 파일/디렉토리 선택적 덮어쓰기 - 자동 발견 폴백: targets 에 미등재된 source 최상위 항목도 스캔하여 적용 (config('app.update.protected_paths') 와 config('app.update.excludes') 매치 시 스킵) - FilePermissionHelper::copyDirectory() 사용 → 원본 파일 권한 보존 - ExtensionPendingHelper::copyToActive() 미사용 (권한 유실 방지) ``` -> **기본 동작 변경 배경 (공개 #64)**: 이전에는 Step 7 이 targets 전체를 무조건 재복사하고 orphan 을 삭제하여, 사용자가 수정한 `public/.htaccess` 커스텀 블록이나 `_bundled/` 아래 커스텀 확장이 소실되는 사고가 반복 제보되었다. 기본 동작을 "코어가 실제로 변경/추가한 파일만 적용(3-way)"으로 전환해 발생 표면을 제거했다. 전체 덮어쓰기 + 정리를 원하면 `--prune` 을 지정한다. "코어도 바꾸고 사용자도 바꾼" 파일은 코어 버전으로 갱신되지만 백업에 원본이 보존되어 복구 가능하다. - -> **증분 모드 잔존 stale 파일 정리**: 기본(증분) 모드는 orphan 을 삭제하지 않으므로, 신 버전에서 제거된 파일이 활성 디렉토리에 잔존할 수 있다. 완료 요약이 잔존을 안내하며, 정리하려면 다음 업데이트를 `--prune` 으로 실행하거나 단발성 정리 도구 `php artisan hotfix:rollback-stale-files --prune` 을 사용한다 (진단 모드 기본, `--prune` 시 확인 프롬프트 후 정리, symlink/protected_paths 가드 적용). 상세 사용법: [docs/cheatsheet.md](../cheatsheet.md) "단발성 결함 보정 (hotfix)". - > **자동 발견 폴백의 배경 (engine-v / beta.4 이후)**: Step 7 은 부모 프로세스의 `config('app.update.targets')` 를 사용한다. 부모는 업그레이드 *직전* 의 코드/메모리 상태이므로 신버전이 도입한 신규 최상위 디렉토리(예: beta.4 의 `lang-packs/`) 가 부모의 stale targets 에서 누락된다. 폴백은 이 결함을 안전망으로 차단하며, `config/app.php` 의 `update.protected_paths` 가 런타임 데이터(`storage`)·로컬 환경(`.env*`)·별도 파이프라인 산출물(`vendor`)·개발 메타(`.git`/`.claude`/`.serena` 등) 의 의도치 않은 덮어쓰기를 방지한다. ### Step 9: 마이그레이션 + 동기화 @@ -251,21 +217,6 @@ v접두사 자동 감지 (resolveGithubArchiveUrl): > **단독 실행 안전성 (beta.6 이후)**: `core:execute-upgrade-steps` 는 HANDOFF 안내 또는 수동 복구 목적으로 운영자가 직접 호출되는 경로가 있다. 단독 실행 시 자식은 기본값으로 부모 Step 9 (`runMigrations` + `reloadCoreConfigAndResync`), Step 11 (`updateVersionInEnv` + `clearAllCaches`), Step 12 (번들 확장 일괄 업데이트) 를 자체적으로 수행해 단일 명령으로 업그레이드를 완결한다. 부모 `CoreUpdateCommand::spawnUpgradeStepsProcess()` 는 자식 명령 라인에 `--skip-migrations`, `--skip-resync`, `--skip-version-env`, `--skip-cache-clear`, `--skip-bundled-updates` 5개를 무조건 추가해 중복 회피한다 — 부모가 자식 종료 후 동일 단계를 직접 수행하기 때문이다. -#### 재실행 안내의 권한 분기 (핸드오프 catch) - -spawn 자식이 실패(`proc_open` 미지원 · 비정상 종료 · silent skip)하고 `spawn_failure_mode=abort`(기본값) 이면, 파일·버전은 이미 `toVersion` 으로 반영되지만 업그레이드 스텝이 미실행 상태로 남아 운영자에게 `core:execute-upgrade-steps` 재실행을 안내한다. 이때 **sudo(root) 로 `core:update` 를 실행한 경우**, 안내받은 명령을 root 로 그대로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어 이후 웹서버(php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다. - -`CoreUpdateCommand::surfaceResumeCommandWithPermissionGuidance()` 는 실행 환경을 4가지로 분류(`classifyResumeExecutionContext()`)하여 안내를 분기한다: - -| 모드 | 조건 | 안내 | -|------|------|------| -| `non_root` | root 아님(일반 SSH 사용자 = 파일 소유자) / posix 미지원(Windows) / 공유 호스팅(웹서버·PHP·실행 유저 동일) | 명령만 그대로 출력 | -| `root_web_known` | root 실행 + 웹서버 계정 식별 가능 + 실행 사용자와 다름 | `sudo -u {계정} {명령}` + 계정명 명시 경고 | -| `root_web_symmetric` | root 실행 + 웹서버 계정이 root 로 추정 (root 서비스 구성) | 명령만 그대로 출력 | -| `root_web_unknown` | root 실행 + 웹서버 계정 추정 실패 | `sudo -u <웹서버계정>` placeholder + 계정 확인 안내 | - -웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 `storage/*`·`bootstrap/cache` 쓰기 영역 소유자로 추정한다. - ### Step 11: 마무리 ```text @@ -665,13 +616,12 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값') |--------|---------|------| | `createBackup()` | `(?Closure $onProgress): string` | CoreBackupHelper로 백업 생성 | | `restoreFromBackup()` | `(string $backupPath, ?Closure $onProgress): void` | 백업에서 파일 복원 | -| `CoreBackupHelper::computeApplyList()` | `(string $backupPath, string $sourcePath, array $targets, array $protectedPaths, array $excludes): array` | 3-way 판정으로 증분 적용 대상(added/changed) 산출 (`apply`, `added_count`, `changed_count`, `has_symlink`). targets 에 명시된 `{domain}/_bundled` 는 상위 protected(`modules` 등)를 오버라이드해 목록에 포함 (공개 #64 / 내부 #452) | ### 적용 및 설치 | 메서드 | 시그니처 | 설명 | |--------|---------|------| -| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제 | +| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress): void` | _pending → base_path 선택적 덮어쓰기 | | `runComposerInstallInPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending에서 composer install (--no-scripts) | | `isComposerUnchangedForCore()` | `(string $pendingPath): bool` | composer.json/lock MD5 비교 | | `runComposerInstall()` | `(?Closure $onProgress): void` | base_path에서 composer install | @@ -713,14 +663,13 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값') ### core:update ```bash -php artisan core:update [--force] [--no-backup] [--prune] [--no-maintenance] [--local] [--source={path}] +php artisan core:update [--force] [--no-backup] [--no-maintenance] [--local] [--source={path}] ``` | 옵션 | 설명 | |------|------| | `--force` | 버전 비교 스킵, 동일 버전이어도 강제 업데이트 | -| `--no-backup` | 백업 생성 스킵 (Step 5). 증분 적용 불가 → 전체 덮어쓰기로 회귀 | -| `--prune` | 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용(3-way)하고 나머지는 보존 | +| `--no-backup` | 백업 생성 스킵 (Step 5) | | `--no-maintenance` | 유지보수 모드 스킵 (Step 3) | | `--local` | 현재 코드베이스를 소스로 사용 (GitHub 스킵) | | `--source={path}` | 지정 디렉토리를 소스로 사용 (GitHub 스킵) | diff --git a/docs/backend/response-helper.md b/docs/backend/response-helper.md index 76a92414..fcc8d259 100644 --- a/docs/backend/response-helper.md +++ b/docs/backend/response-helper.md @@ -587,27 +587,6 @@ class PublicLayoutController extends PublicBaseController --- -## JSON 인코딩 옵션 (JSON_UNESCAPED_UNICODE) - -모든 `response()->json()` 호출은 `ResponseHelper::JSON_ENCODE_OPTIONS`(= `JSON_UNESCAPED_UNICODE`)를 인코딩 옵션으로 전달한다. 한글 등 멀티바이트 문자를 `\uXXXX`(6바이트)로 이스케이프하지 않고 raw UTF-8(한글 3바이트)로 직렬화하여 응답 전송 크기를 줄인다. 다국어 팩(lang) 응답처럼 한글 비중이 큰 페이로드에서 절감 효과가 크다. - -```php -// ✅ 올바른 사용 — 네 번째 인자로 옵션 전달 -return response()->json($payload, $status, [], ResponseHelper::JSON_ENCODE_OPTIONS); - -// 헤더 배열이 필요한 경우 (세 번째 인자) -return response()->json($payload, $status, ['Cache-Control' => '...'], ResponseHelper::JSON_ENCODE_OPTIONS); - -// ❌ 옵션 누락 — 한글이 \uXXXX 로 팽창 -return response()->json($payload, $status); -``` - -`ResponseHelper` 의 모든 메서드(`success`/`error`/`validationError`/… )와 `BaseApiController::successWithCache`/`cachedJsonResponse` 는 이미 이 옵션을 적용한다. 컨트롤러/서비스는 `ResponseHelper` 를 사용하면 자동으로 적용되므로 별도 조치가 불필요하다. `response()->json()` 을 직접 호출하는 신규 코드에서만 옵션 전달을 잊지 않는다. - -> 슬래시 언이스케이프(`JSON_UNESCAPED_SLASHES`)는 적용하지 않는다 — endpoint 경로의 `\/` 는 크기 영향이 미미하고, 기존 응답 본문 정확 매칭 테스트의 안전성을 우선한다. - ---- - ## 관련 문서 - [index.md](index.md) - 백엔드 가이드 인덱스 diff --git a/docs/backend/service-provider.md b/docs/backend/service-provider.md index aefd901d..69818513 100644 --- a/docs/backend/service-provider.md +++ b/docs/backend/service-provider.md @@ -346,7 +346,7 @@ protected function loadModuleRoutes(): void - **`hasTable` 폴백 경로는 반드시 유지** — `installer_completed=false` 환경에서도 안전하게 부팅되어야 함 - **try/catch 도 그대로 유지** — DB 연결 실패 시 안전한 스킵 계약 준수 - 가드는 hasTable 체크 **전체 블록을 `if (! config('app.installer_completed'))` 로 래핑** 하는 형태 -- `config:cache` 는 config 소스를 변경하는 라이프사이클에서 `App\Support\ConfigCacheHelper::rebuild()` 로 자동 재생성된다 (아래 "config 캐시 자동 재생성" 참조). `.env` 를 직접 편집한 경우처럼 헬퍼를 거치지 않는 변경은 여전히 `php artisan config:clear && php artisan config:cache` 를 수동 실행한다 +- `config:cache` 를 사용하는 환경에서는 `.env` 값 변경 후 반드시 `php artisan config:clear && php artisan config:cache` 를 실행해야 함 ### 확장 Trait 패턴 @@ -369,28 +369,6 @@ private static function isExtensionTableReady(string $table): bool } ``` -### 부팅 경로 Listener / Repository 적용 - -ServiceProvider 와 Trait 외에도, `CoreServiceProvider::boot()` 말미(동적 훅 등록 / IDV 정책 동기화)에서 매 요청 실행되는 다음 지점도 동일 가드를 적용합니다: - -- `NotificationHookListener::registerDynamicHooks()` — `notification_definitions` 존재 확인 (단일 최대 비용) -- `IdentityPolicyRepository::listHookTargets()` — `identity_policies` 존재 확인 -- `IdentityPolicyRepository::activeExtensionIdentifiers()` — `modules` / `plugins` 존재 확인 (테이블 부재 시 `null` 반환으로 필터 미적용 계약 보존) - -```php -// hasTable 을 단독 조건이 아니라 installer_completed 와 && 로 단락. -// 설치 완료 시 hasTable 호출 자체를 건너뛰고 후속 로직으로 진행한다. -if (! config('app.installer_completed') && ! Schema::hasTable('notification_definitions')) { - return; -} -``` - -`try/catch` 블록 안에 있는 Repository 조회는 catch 계약(테이블 부재/DB 오류 시 `[]` 또는 `null` 반환)을 그대로 보존한 채 조건만 확장한다. - -### 부팅 경로 정적 훅 등록 캐시 - -`installer_completed` 가드가 부팅 시 DB 조회를 줄이는 것과 별개로, `CoreServiceProvider::boot()` 의 정적 훅 리스너 등록(`app/Listeners` 재귀 스캔 + 모듈/플러그인 `getHookListeners()` 클래스 로딩)은 `bootstrap/cache/hooks.php` 캐시로 매 요청 스캔·리플렉션을 제거한다. 캐시 부재/테스트 환경에서는 기존 스캔 경로로 안전 폴백하며, 등록 결과는 스캔 경로와 바이트 동일하다. 상세: [extension/hooks.md "정적 훅 매핑 캐시"](../extension/hooks.md). - ### 주의사항 - **인스톨러 설치 완료 시점에 `INSTALLER_COMPLETED=true` 가 `.env` 에 기록되어야 함** — G7 인스톨러(`public/install/includes/task-runner.php`)는 이를 이미 수행 @@ -430,38 +408,6 @@ php artisan migrate --- -## config 캐시 자동 재생성 (ConfigCacheHelper) - -config 소스(`config/*.php`, `.env`, `storage/app/settings/*.json`, 활성 확장 목록)를 변경하는 라이프사이클은 `App\Support\ConfigCacheHelper::rebuild()` 를 호출해 config 캐시를 재빌드한다. - -### 배경 - -과거 설정 변경/코어 업데이트/APP_KEY 재생성 등은 `config:clear` 만 하고 `config:cache` 를 재생성하지 않았다. 그 결과 관리자가 "시스템 최적화"(`optimizeSystem`)로 캐시를 만들어도, 이후 설정을 한 번 저장하면 캐시가 비워진 채 재생성되지 않아 이후 모든 요청이 config 파일을 재파싱했다(성능 손실). `rebuild()` 는 clear 직후 재생성까지 수행해 캐시가 비활성 상태로 잔존하지 않게 한다. - -### 정책 - -- **환경 무관 항상 재생성** — config:cache 는 그 자체로 부팅 비용을 줄이고, G7 설정은 config 캐시에 박제되지 않고 매 요청 런타임 `Config::set()` 으로 재주입되므로(설정 stale 없음) 항상 켜두는 것이 이득이다. -- **testing 환경**: `config:clear` 는 수행(값 반영 계약 유지)하되 `config:cache` 생성은 스킵(캐시된 config 가 다음 테스트로 누출되는 격리 파괴 방지). -- **설치 미완료 상태**: 불완전 config 박제를 피하려 재생성을 스킵하고 clear 만 한다. -- **local 개발 주의**: config:cache 가 켜지면 `config/*.php` 를 직접 수정해도 다음 요청에 반영되지 않는다. 이는 개발자가 `php artisan config:clear` 로 대응하는 개발자 책임 영역이다. - -### 적용 지점 - -| 라이프사이클 | 재생성 위치 | -| --- | --- | -| 코어 설치 완료 | 인스톨러 `complete_flag` 직후 `config:cache` task | -| 코어 업데이트 | `CoreUpdateCommand` Step 11 완료 지점 | -| 코어 업그레이드 스텝(단독 실행) | `ExecuteUpgradeStepsCommand` 캐시 정리 블록 (spawn 자식은 부모가 처리) | -| 코어 관리자 설정 저장 / 캐시 정리 / GeoIP / APP_KEY | `SettingsService` / `GeoIpDatabaseService` 의 각 지점 | -| 확장 설치/삭제/업데이트 | `ExtensionManager::updateComposerAutoload()` (오토로드·훅 캐시와 동일 생명주기) | -| 확장 활성화/비활성화 | `ExtensionConfigCacheListener` (`core.*.activated` / `core.*.after_deactivate` 훅 구독) | - -`ExtensionConfigCacheListener` 의 구독 훅은 `getSubscribedHooks()` 에서 `'sync' => true` 로 선언한다. 훅 Action 리스너의 기본 등록 정책은 큐 디스패치(`HookListenerRegistrar`)인데, config 캐시 재생성을 큐로 미루면 (1) 워커 미가동 환경에서 영영 재생성되지 않고, (2) 확장 토글을 수행하는 CLI 커맨드(`plugin:deactivate` 등)는 실행 후 프로세스가 종료되어 큐 작업을 처리할 주체가 없다. config 캐시 재생성은 인프라 부수효과이므로 반드시 동기 실행한다. 회귀 방지 테스트: `ExtensionConfigCacheListenerTest::test_all_hooks_are_synchronous`. - -**확장별 개별 환경설정 저장(board/ecommerce/plugin SettingsService)은 재생성 대상이 아니다.** 확장 설정은 `config/*.php` 가 아니라 settings JSON 에 저장되고, 매 요청 `CoreServiceProvider::boot` 의 `loadModule/PluginSettingsToConfig` 가 런타임 `Config::set('g7_settings.*', 최신값)` 으로 config 캐시에 박제된 값을 덮어쓴다. 따라서 값 stale 이 없고(즉시 반영), config 캐시를 clear 하지도 않으므로(성능 손실 없음) 재생성이 실효가 없다. - ---- - ## 관련 이슈 - 인스톨러에서 `composer install` 시 DB 접근 오류 diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 621254f3..2a2d0fb2 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -27,8 +27,6 @@ _bundled에서 TSX/TS 파일 수정한 경우 → 빌드 후 확장 업데이 | `modules/**/resources/js/**/*.ts` (핸들러) | `module:build` + `module:update {id} --force` | | `templates/**/src/**/*.tsx` (컴포넌트) | `template:build` + `template:update {id} --force` | -> **확장 프론트엔드 번들**: 활성 모듈/플러그인 IIFE 는 서버측에서 종류별 1개 번들로 병합 서빙된다(`/api/{modules,plugins}/bundle.{js,css}`). `{type}:update` 후 확장 캐시 버전이 bump 되면 번들이 자동 재생성된다(prod 캐시, dev 매 요청 concat). 구버전 번들 파일은 `ext-bundles:cleanup` 또는 `{type}:cache-clear` 로 정리. 상세: [module-assets.md](extension/module-assets.md#서버측-번들-병합). - --- ## 확장 업데이트 (_bundled → 활성 반영) @@ -174,15 +172,9 @@ php artisan language-pack:update [identifier] [--force] [--source=auto|bundled|g php artisan extension:composer-install # 모든 모듈+플러그인 # 오토로드 -php artisan extension:update-autoload # 오토로드 캐시 + 정적 훅 매핑 캐시 함께 재생성 - -# 정적 훅 매핑 캐시 (부팅 비용 절감 — route:cache 동형) -php artisan hooks:cache # bootstrap/cache/hooks.php 생성 -php artisan hooks:clear # 캐시 삭제 (삭제 후 스캔 폴백 — 항상 안전) +php artisan extension:update-autoload ``` -> 훅 캐시는 확장 install/update 및 코어 업데이트(`clearAllCaches` → `extension:update-autoload`) 시 자동 재생성됩니다. 코어 리스너 코드 배포 시에만 `hooks:cache` 수동 실행. 상세: [extension/hooks.md "정적 훅 매핑 캐시"](extension/hooks.md). - ### 단발성 결함 보정 (hotfix) `hotfix:*` prefix 는 특정 버전의 결함 회복을 위해 신설되는 단발성 도구를 위한 표준 prefix 다. `core:*` (영구 운영 도구) 와 명확히 구분되며 dev-dashboard 자동 노출 면제 대상이다. diff --git a/docs/extension/editor-spec.md b/docs/extension/editor-spec.md index 1abeee75..0aa3d3b3 100644 --- a/docs/extension/editor-spec.md +++ b/docs/extension/editor-spec.md @@ -136,7 +136,7 @@ editor-spec.json 이 커지면(코어 admin 템플릿은 단일 파일 18,000줄 ### actionRecipes — 친화 명칭 → 핸들러 JSON -`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 코어 핸들러 규칙의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다. +`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 CLAUDE.md CRITICAL RULES 의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다. `build.handler` 는 리터럴 핸들러명 대신 `{{paramKey}}` **placeholder** 도 가능하다 — 호출할 핸들러를 응답값 등 데이터로 결정하는 provider-agnostic 액션(예: ecommerce 모듈 `requestPgPayment` "결제 진입" — 핸들러를 `{{response.data.pg_payment_handler}}` 데이터 칩으로 연결)에 쓴다. 결제 같은 **도메인 동작은 코어가 아니라 그 도메인 확장(모듈/플러그인/템플릿)이 자기 editor-spec `actionRecipes` 에 소유**한다 — 코어는 PG/도메인을 모른다. 확장 레시피는 로더가 `__source` 메타를 붙여 〔확장식별자〕 배지·'extension' 그룹으로 자동 분류한다. 라벨은 그 확장의 lang 네임스페이스를 명시한다(예: `$t:sirsoft-ecommerce.editor.action.request_pg_payment.label`). diff --git a/docs/extension/extension-manager.md b/docs/extension/extension-manager.md index 906f07e6..18e392ab 100644 --- a/docs/extension/extension-manager.md +++ b/docs/extension/extension-manager.md @@ -13,7 +13,6 @@ 3. 설치/삭제 시 자동 갱신: updateComposerAutoload() → generateAutoloadFile() 4. 수동 갱신: php artisan extension:update-autoload 5. _bundled/_pending 디렉토리 자동 제외 (str_starts_with($name, '_') → skip) -6. src_classmap: 확장 소스 FQCN→경로 사전 계산 → findFile 파일시스템 stat 제거 (지연 로드·vendor 격리 유지) ``` --- @@ -80,10 +79,6 @@ return [ "modules/sirsoft-ecommerce/module.php", "plugins/sirsoft-payment/plugin.php", ], - 'src_classmap' => [ - "Modules\\Sirsoft\\Ecommerce\\Services\\OrderService" => "modules/sirsoft-ecommerce/src/Services/OrderService.php", - "Plugins\\Sirsoft\\Payment\\Providers\\PaymentServiceProvider" => "plugins/sirsoft-payment/src/Providers/PaymentServiceProvider.php", - ], 'files' => [], 'vendor_autoloads' => [ "modules/sirsoft-ecommerce/vendor/autoload.php", @@ -91,19 +86,8 @@ return [ ]; ``` -- `classmap`: `module.php` / `plugin.php` 등 부트스트랩 파일 목록 — 진입점에서 `require_once` (즉시 로드) -- `src_classmap`: 확장 **소스** 클래스의 `FQCN → 상대경로` 맵 — 진입점에서 `$loader->addClassMap()` (지연 로드) - `vendor_autoloads`: 모듈/플러그인의 `vendor/autoload.php` 경로 목록 (Composer 의존성이 설치된 경우만 포함) -#### src_classmap — findFile 파일시스템 스캔 제거 (성능) - -확장 소스 클래스는 `addPsr4()` 로만 등록되면 Composer `findFile()` 이 클래스마다 파일시스템을 스캔(`is_dir`/`file_exists` stat)한다. OPcache 가 cold 이거나 파일시스템이 느린 환경에서는 이 stat 비용이 요청 시간의 큰 비중을 차지할 수 있다. `src_classmap` 은 `generateAutoloadFile()` 이 각 PSR-4 디렉토리를 스캔해 `FQCN → 경로` 를 사전 계산하고, 진입점(`public/index.php` / `CoreServiceProvider` / `ExtensionManager::registerExtensionAutoload`)이 `ClassLoader::addClassMap()` 으로 등록한다. `findFile()` 은 classMap 을 **최우선 조회**하므로 확장 클래스는 파일시스템 접근 0 으로 즉시 경로를 얻는다. - -- **지연 로드 유지**: classmap 은 경로만 제공하며 `include` 는 실제 사용 시점에. 매 요청 전량 로드가 아니다. -- **PSR-4 폴백 보존**: classmap 에 없는 클래스(신규 추가 후 미재생성 등)는 기존 PSR-4 경로로 폴백 — 안전망. -- **vendor 격리 불변**: `src_classmap` 은 확장 **소스**(`Modules\*`/`Plugins\*`)만 담는다. 확장별 독립 서드파티 vendor 는 각 확장 `vendor/autoload.php`(별도 ClassLoader) 가 담당하므로 여기 포함되지 않는다. -- **재생성**: `psr4` 와 동일 생명주기 — install / update / uninstall 시 `updateComposerAutoload()`, 코어 업데이트 시 `extension:update-autoload` 가 함께 재생성. - ### 중요 규칙 ``` diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md index 86e2a40e..e17a0a76 100644 --- a/docs/extension/hooks.md +++ b/docs/extension/hooks.md @@ -484,44 +484,6 @@ private function registerCoreHookListeners(): void | 위치 | `app/Listeners/**/*.php` | `modules/**/Listeners/`, `plugins/**/Listeners/` | | 등록 주체 | `CoreServiceProvider` | `ModuleServiceProvider`, `PluginServiceProvider` | ---- - -## 정적 훅 매핑 캐시 (Static Hook Cache) - -매 요청 부팅 시 코어(`app/Listeners` 재귀 스캔) + 모듈/플러그인(`getHookListeners()`)의 정적 훅 리스너를 발견·리플렉션·`getSubscribedHooks()` 클래스 로딩하는 비용을 제거하기 위해, 사전 계산한 훅 매핑을 `bootstrap/cache/hooks.php` 에 캐시합니다 (오토로드 캐시 `autoload-extensions.php` 와 동일 위치·생명주기). - -캐시는 "무엇을 등록할지 목록" 만 제공하며 **등록 자체는 여전히 부팅에서 수행** 되므로 등록↔발화 순서 계약은 불변입니다. 캐시 경로 등록 결과는 스캔 경로와 **훅 매핑이 바이트 동일** 합니다 (`HookListenerRegistrar::registerFromCache()` 가 `register()` 와 동일한 `applySubscribedHooks()` 에 위임). - -### 동작 - -| 상태 | 부팅 시 동작 | -|------|-------------| -| 캐시 존재 (`bootstrap/cache/hooks.php`) | 스캔·리플렉션·클래스 로딩 없이 캐시 매핑으로 등록 | -| 캐시 부재 / 손상 / 구조 불일치 | 기존 스캔 경로로 **안전 폴백** (항상 동작) | -| 테스트 환경 (`APP_ENV=testing`) | 캐시 미사용 — 매 setUp 스캔이 정확·격리 우선 | - -- 동적 훅(알림 등 `registerDynamicHooks()`)은 캐시 대상이 아니며 코드 변경 없이 그대로 동작합니다. 캐시에는 각 리스너의 `dynamic` 플래그만 저장하여, 동적 훅 보유 코어 리스너의 boot 후반부 지연 실행 순서를 스캔 경로와 동일하게 유지합니다. -- 동적 훅의 DB 조회(`NotificationDefinitionService::getAllActive()`)는 이미 `['notification']` 태그로 캐시되어 있으므로 첫 요청(캐시 워밍) 이후 DB 조회가 발생하지 않습니다. - -### 재생성 (무효화 = 재생성) - -정적 훅 매핑은 **확장 변경 또는 코어 리스너 코드 배포 시에만** 바뀝니다. 해당 시점에 자동/수동 재생성됩니다: - -| 사건 | 재생성 경로 | -|------|-----------| -| 확장 install / activate / deactivate / uninstall / update | `ExtensionManager::updateComposerAutoload()` 가 오토로드 캐시와 나란히 재생성 (자동) | -| 코어 업데이트 (`core:update`) | `clearAllCaches()` → `extension:update-autoload` 가 오토로드 + 훅 캐시 함께 재생성 (자동). 코어 리스너 추가/변경/삭제 반영 | -| 코어 리스너 코드 배포 | `php artisan hooks:cache` (배포 파이프라인 — `route:cache` 동형) | - -```bash -php artisan hooks:cache # 정적 훅 매핑 캐시 생성 (bootstrap/cache/hooks.php) -php artisan hooks:clear # 캐시 삭제 (삭제 후 스캔 폴백 — 항상 안전) -``` - -캐시 파일은 Git 미추적(`bootstrap/cache/*`) 이며 배포 환경마다 생성됩니다. 캐시가 없어도 스캔 폴백으로 정상 동작하므로 필수는 아니지만, 프로덕션 배포 시 부팅 비용 절감을 위해 `config:cache`/`route:cache` 와 함께 실행하는 것을 권장합니다. - -**핵심 파일**: `app/Extension/HookCacheManager.php`, `app/Extension/HookListenerRegistrar.php` (`registerFromCache`) - ### 동적 훅 리스너 (DB 기반) DB 설정에 따라 훅 구독 대상이 동적으로 변하는 경우, 리스너에 `registerDynamicHooks()` 메서드를 구현합니다. diff --git a/docs/extension/module-assets.md b/docs/extension/module-assets.md index ef51b35c..0bc197e1 100644 --- a/docs/extension/module-assets.md +++ b/docs/extension/module-assets.md @@ -381,72 +381,6 @@ GET /api/modules/assets/{identifier}/{path} --- -## 서버측 번들 병합 (Server-side Bundle) - -활성 모듈/플러그인이 늘어날수록 개별 IIFE JS/CSS 요청이 선형 증가한다. 이를 줄이기 위해 코어는 타입별(모듈/플러그인)로 활성 `global` 에셋을 서버에서 하나의 번들로 병합해 서빙한다. 각 확장 IIFE 는 자체 클로저에서 자가등록(레지스트리 + 핸들러/리스너)을 수행하므로, priority 순으로 이어붙여 단일 `'; exit; } @@ -42,7 +42,7 @@ if (file_exists($extensionAutoloadFile)) { $extensionAutoloads = require $extensionAutoloadFile; // PSR-4 네임스페이스 등록 - if (! empty($extensionAutoloads['psr4'])) { + if (!empty($extensionAutoloads['psr4'])) { foreach ($extensionAutoloads['psr4'] as $namespace => $paths) { // 경로가 배열인 경우와 문자열인 경우 모두 처리 $paths = (array) $paths; @@ -55,21 +55,8 @@ if (file_exists($extensionAutoloadFile)) { } } - // 확장 소스 classmap 등록 (FQCN → 절대경로). - // findFile 이 파일시스템 스캔(is_dir/file_exists stat) 없이 in-memory 맵에서 즉시 - // 경로를 반환하도록 한다. cold OPcache / 느린 파일시스템 환경의 findFile 비용 제거. - // 클래스 로딩은 여전히 lazy — 실제 사용 시점에만 include. classmap 미포함 클래스는 - // 위 PSR-4 등록으로 폴백(안전망). - if (! empty($extensionAutoloads['src_classmap'])) { - $absoluteClassmap = []; - foreach ($extensionAutoloads['src_classmap'] as $fqcn => $relPath) { - $absoluteClassmap[$fqcn] = __DIR__.'/../'.$relPath; - } - $loader->addClassMap($absoluteClassmap); - } - // Classmap 파일 로드 (module.php, plugin.php) - if (! empty($extensionAutoloads['classmap'])) { + if (!empty($extensionAutoloads['classmap'])) { foreach ($extensionAutoloads['classmap'] as $file) { $absolutePath = __DIR__.'/../'.$file; if (file_exists($absolutePath)) { @@ -79,7 +66,7 @@ if (file_exists($extensionAutoloadFile)) { } // Files 로드 (헬퍼 함수 등) - if (! empty($extensionAutoloads['files'])) { + if (!empty($extensionAutoloads['files'])) { foreach ($extensionAutoloads['files'] as $file) { $absolutePath = __DIR__.'/../'.$file; if (file_exists($absolutePath)) { @@ -89,7 +76,7 @@ if (file_exists($extensionAutoloadFile)) { } // Vendor autoloads 로드 (모듈/플러그인의 composer 의존성) - if (! empty($extensionAutoloads['vendor_autoloads'])) { + if (!empty($extensionAutoloads['vendor_autoloads'])) { foreach ($extensionAutoloads['vendor_autoloads'] as $vendorAutoload) { $absolutePath = __DIR__.'/../'.$vendorAutoload; if (file_exists($absolutePath)) { diff --git a/public/install/api/state-management.php b/public/install/api/state-management.php index b2115f57..23eac11a 100644 --- a/public/install/api/state-management.php +++ b/public/install/api/state-management.php @@ -138,28 +138,7 @@ class StateManagementApi ]; // JSON 응답 반환 - // - // 최종 안전망 (gnuboard/g7#62): $response['logs'] 에 invalid UTF-8 바이트가 - // 섞이면 json_encode 가 false 를 반환하고 echo false = 빈 본문(HTTP 200) 이 되어 - // 프론트 폴링(res.json())이 "Unexpected end of JSON input" 으로 폭주한다. - // 로그는 addLog / task-runner 에서 이미 scrub 되지만, 어떤 경로로도 응답이 - // 빈 본문이 되지 않도록 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 false 를 가드한다. - $encoded = json_encode( - $response, - JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE - ); - - if ($encoded === false) { - // substitute 플래그로도 인코딩 실패한 극단적 케이스 — 폴링이 파싱 가능한 - // 최소 유효 JSON 을 반환해 프론트가 다음 tick 에서 정상 복구되도록 한다. - $encoded = json_encode([ - 'status' => $status, - 'logs' => [], - 'log_total' => $logTotal, - ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); - } - - echo $encoded; + echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); } /** diff --git a/public/install/includes/functions.php b/public/install/includes/functions.php index 71c3a45c..7d705628 100644 --- a/public/install/includes/functions.php +++ b/public/install/includes/functions.php @@ -5,6 +5,8 @@ * * HTML/JavaScript 이스케이프, 데이터베이스 연결, 권한 검증, 에러 로깅 등의 * 공통 유틸리티 함수를 제공합니다. + * + * @package G7\Installer */ /** @@ -43,9 +45,8 @@ function js_escape(mixed $value): string /** * 데이터베이스 연결 생성 * - * @param array $config 데이터베이스 연결 정보 배열 - * @param bool $isReadDb Read DB 여부 (true: read, false: write) - * + * @param array $config 데이터베이스 연결 정보 배열 + * @param bool $isReadDb Read DB 여부 (true: read, false: write) * @throws PDOException 연결 실패 시 */ function getDatabaseConnection(array $config, bool $isReadDb = false): PDO @@ -96,9 +97,9 @@ function getDatabaseConnection(array $config, bool $isReadDb = false): PDO /** * 데이터베이스 권한 검증 * - * @param PDO $pdo PDO 연결 객체 - * @param string $database 데이터베이스명 - * @param bool $isReadDb Read DB 여부 (true: read, false: write) + * @param PDO $pdo PDO 연결 객체 + * @param string $database 데이터베이스명 + * @param bool $isReadDb Read DB 여부 (true: read, false: write) * @return array{has_all: bool, required: array, found: array, missing: array} */ function checkDatabasePrivileges(PDO $pdo, string $database, bool $isReadDb = false): array @@ -164,7 +165,7 @@ function checkDatabasePrivileges(PDO $pdo, string $database, bool $isReadDb = fa * * Synology DSM 등 PHP-FPM root 실행 환경 + 비대화형 컨텍스트 대응. */ -if (! function_exists('applyInstallerComposerEnvVars')) { +if (!function_exists('applyInstallerComposerEnvVars')) { function applyInstallerComposerEnvVars(): void { putenv('COMPOSER_ALLOW_SUPERUSER=1'); @@ -179,7 +180,7 @@ if (! function_exists('applyInstallerComposerEnvVars')) { * * @return array */ -if (! function_exists('buildInstallerComposerEnv')) { +if (!function_exists('buildInstallerComposerEnv')) { function buildInstallerComposerEnv(): array { return [ @@ -192,13 +193,13 @@ if (! function_exists('buildInstallerComposerEnv')) { /** * 인스톨러 에러 로깅 * - * @param string $message 에러 메시지 - * @param Throwable|null $exception 예외 객체 (선택) + * @param string $message 에러 메시지 + * @param Throwable|null $exception 예외 객체 (선택) */ function logInstallationError(string $message, ?Throwable $exception = null): void { - $logDir = BASE_PATH.'/storage/logs'; - $logFile = $logDir.'/installer.log'; + $logDir = BASE_PATH . '/storage/logs'; + $logFile = $logDir . '/installer.log'; // 로그 디렉토리 생성 if (! is_dir($logDir)) { @@ -211,13 +212,13 @@ function logInstallationError(string $message, ?Throwable $exception = null): vo if ($exception !== null) { $logMessage .= "\n"; - $logMessage .= 'Exception: '.get_class($exception)."\n"; + $logMessage .= "Exception: " . get_class($exception) . "\n"; $logMessage .= "Message: {$exception->getMessage()}\n"; $logMessage .= "File: {$exception->getFile()}:{$exception->getLine()}\n"; $logMessage .= "Trace:\n{$exception->getTraceAsString()}\n"; } - $logMessage .= "\n".str_repeat('-', 80)."\n\n"; + $logMessage .= "\n" . str_repeat('-', 80) . "\n\n"; // 로그 파일에 기록 @file_put_contents($logFile, $logMessage, FILE_APPEND | LOCK_EX); @@ -231,7 +232,7 @@ function logInstallationError(string $message, ?Throwable $exception = null): vo function detectBrowserLanguage(): ?string { // HTTP_ACCEPT_LANGUAGE 헤더가 없으면 null 반환 - if (! isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { + if (!isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { return null; } @@ -285,14 +286,14 @@ function getCurrentLanguage(): string /** * 지정된 언어의 번역 파일을 로드합니다. * - * @param string $lang 언어 코드 (예: 'ko', 'en') + * @param string $lang 언어 코드 (예: 'ko', 'en') * @return array 번역 배열, 파일이 없으면 빈 배열 */ function loadTranslations(string $lang): array { - $langFile = __DIR__."/../lang/{$lang}.php"; + $langFile = __DIR__ . "/../lang/{$lang}.php"; - if (! file_exists($langFile)) { + if (!file_exists($langFile)) { return []; } @@ -307,15 +308,15 @@ function loadTranslations(string $lang): array * global $translations 배열에서 키에 해당하는 값을 찾아 반환합니다. * 키가 존재하지 않으면 키 자체를 반환합니다. * - * @param string $key 번역 키 (예: 'welcome_title', 'next_step') - * @param array $replace 치환할 플레이스홀더 배열 (예: ['current' => '8.2', 'min' => '8.2']) + * @param string $key 번역 키 (예: 'welcome_title', 'next_step') + * @param array $replace 치환할 플레이스홀더 배열 (예: ['current' => '8.2', 'min' => '8.2']) */ if (! function_exists('lang')) { function lang(string $key, array $replace = []): string { global $translations; - if (! isset($translations) || ! is_array($translations)) { + if (!isset($translations) || !is_array($translations)) { return $key; } @@ -333,7 +334,7 @@ if (! function_exists('lang')) { /** * 설치 단계 이름을 반환합니다. * - * @param int $step 단계 번호 (0~5, 6은 예약됨) + * @param int $step 단계 번호 (0~5, 6은 예약됨) * @return string 단계명 번역 키 */ function getStepName(int $step): string @@ -354,7 +355,7 @@ function getStepName(int $step): string /** * 설치 작업 이름을 반환합니다. * - * @param string $task 작업 ID (예: 'composer_check', 'env_create') + * @param string $task 작업 ID (예: 'composer_check', 'env_create') * @return string 작업명 번역 키 */ function getTaskName(string $task): string @@ -393,12 +394,12 @@ function getTaskName(string $task): string /** * 지정한 Step으로 리다이렉트합니다. * - * @param int $step 이동할 단계 번호 + * @param int $step 이동할 단계 번호 */ function redirectToStep(int $step): void { $_SESSION['installer_current_step'] = $step; - header('Location: '.INSTALLER_BASE_URL.'/'); + header("Location: " . INSTALLER_BASE_URL . "/"); exit; } @@ -410,12 +411,12 @@ function redirectToStep(int $step): void * - state.json: 다른 브라우저에서 재개 시 사용, 설치 완료 후 Laravel 본체에 전달 * - localStorage: JavaScript에서 직접 관리 (0-welcome.php에서 처리) * - * @param string $lang 언어 코드 ('ko' 또는 'en') + * @param string $lang 언어 코드 ('ko' 또는 'en') */ function handleLanguageChange(string $lang): void { // 지원하는 언어만 허용 - if (! array_key_exists($lang, SUPPORTED_LANGUAGES)) { + if (!array_key_exists($lang, SUPPORTED_LANGUAGES)) { return; } @@ -431,9 +432,9 @@ function handleLanguageChange(string $lang): void /** * 현재 단계의 상태를 업데이트합니다. * - * @param int $step 완료할 단계 번호 - * @param int $nextStep 다음 단계 번호 - * @param array $additionalData 추가로 저장할 state 데이터 (예: ['config' => $data]) + * @param int $step 완료할 단계 번호 + * @param int $nextStep 다음 단계 번호 + * @param array $additionalData 추가로 저장할 state 데이터 (예: ['config' => $data]) */ function updateStepStatus(int $step, int $nextStep, array $additionalData = []): void { @@ -441,7 +442,7 @@ function updateStepStatus(int $step, int $nextStep, array $additionalData = []): $state['current_step'] = $nextStep; $state['step_status'][$step] = 'completed'; - if (! empty($additionalData)) { + if (!empty($additionalData)) { $state = array_merge($state, $additionalData); } @@ -454,7 +455,6 @@ function updateStepStatus(int $step, int $nextStep, array $additionalData = []): function isInstallationRunning(): bool { $state = getInstallationState(); - return isset($state['installation_status']) && $state['installation_status'] === 'running'; } @@ -466,7 +466,7 @@ function showInstallationRunningAlert(): void global $translations; // 번역이 로드되지 않은 경우 로드 - if (! isset($translations)) { + if (!isset($translations)) { $translations = loadTranslations(getCurrentLanguage()); } @@ -474,7 +474,7 @@ function showInstallationRunningAlert(): void showAlertAndRedirect( lang('installation_in_progress'), lang('installation_in_progress_message'), - INSTALLER_BASE_URL.'/' + INSTALLER_BASE_URL . '/' ); } @@ -486,7 +486,7 @@ function showInstallationCompletedAlert(): void global $translations; // 번역이 로드되지 않은 경우 로드 - if (! isset($translations)) { + if (!isset($translations)) { $translations = loadTranslations(getCurrentLanguage()); } @@ -500,15 +500,15 @@ function showInstallationCompletedAlert(): void /** * 알럿을 표시하고 지정된 URL로 리다이렉트합니다. * - * @param string $title 페이지 제목 - * @param string $message 알럿 메시지 - * @param string $redirectUrl 리다이렉트 URL + * @param string $title 페이지 제목 + * @param string $message 알럿 메시지 + * @param string $redirectUrl 리다이렉트 URL */ function showAlertAndRedirect(string $title, string $message, string $redirectUrl): void { global $translations; - if (! isset($translations)) { + if (!isset($translations)) { $translations = loadTranslations(getCurrentLanguage()); } @@ -518,16 +518,16 @@ function showAlertAndRedirect(string $title, string $message, string $redirectUr $redirectUrl = htmlspecialchars($redirectUrl); echo ''; - echo ''; + echo ''; echo ''; echo ''; echo ''; - echo ''.$title.''; + echo '' . $title . ''; echo ''; echo ''; echo ''; echo ''; echo ''; @@ -537,7 +537,7 @@ function showAlertAndRedirect(string $title, string $message, string $redirectUr /** * 단계 파일을 찾을 수 없을 때 에러 페이지를 표시합니다. * - * @param int $currentStep 현재 단계 번호 + * @param int $currentStep 현재 단계 번호 */ function showStepFileNotFoundError(int $currentStep): void { @@ -553,9 +553,9 @@ function showStepFileNotFoundError(int $currentStep): void echo ''; echo lang('error'); echo ''; - echo '

'.lang('error_step_file_not_found').'

'; + echo '

' . lang('error_step_file_not_found') . '

'; echo '
'; - echo '← '.lang('previous').''; + echo '← ' . lang('previous') . ''; echo '
'; echo ''; } @@ -565,8 +565,8 @@ function showStepFileNotFoundError(int $currentStep): void * * DB 연결 정보가 변경되었는지 감지하기 위해 사용됩니다. * - * @param array $config 폼 데이터 배열 - * @param string $prefix DB 접두사 ('db_write' 또는 'db_read') + * @param array $config 폼 데이터 배열 + * @param string $prefix DB 접두사 ('db_write' 또는 'db_read') * @return string MD5 해시값 */ function getDatabaseFieldHash(array $config, string $prefix): string @@ -575,14 +575,14 @@ function getDatabaseFieldHash(array $config, string $prefix): string $config["{$prefix}_host"] ?? '', $config["{$prefix}_port"] ?? '', $config["{$prefix}_database"] ?? '', - $config["{$prefix}_username"] ?? '', + $config["{$prefix}_username"] ?? '' ])); } /** * SVG 아이콘을 반환합니다. * - * @param string $type 아이콘 타입 ('success', 'warning', 'error') + * @param string $type 아이콘 타입 ('success', 'warning', 'error') * @return string SVG HTML */ function getSvgIcon(string $type): string @@ -609,12 +609,12 @@ function getSvgIcon(string $type): string /** * 설치 결과 섹션 HTML을 렌더링합니다. * - * @param string $sectionId 섹션 ID ('completion', 'aborted', 'failure') - * @param string $iconType 아이콘 타입 ('success', 'warning', 'error') - * @param string $titleKey 제목 번역 키 - * @param string $messageKey 메시지 번역 키 (failure인 경우 동적) - * @param string $buttonsHtml 버튼 HTML - * @param string $extraHtml 메시지 하단에 추가로 렌더링할 HTML (이미 escape 된 상태로 전달) + * @param string $sectionId 섹션 ID ('completion', 'aborted', 'failure') + * @param string $iconType 아이콘 타입 ('success', 'warning', 'error') + * @param string $titleKey 제목 번역 키 + * @param string $messageKey 메시지 번역 키 (failure인 경우 동적) + * @param string $buttonsHtml 버튼 HTML + * @param string $extraHtml 메시지 하단에 추가로 렌더링할 HTML (이미 escape 된 상태로 전달) * @return string 결과 섹션 HTML */ function renderInstallResultSection(string $sectionId, string $iconType, string $titleKey, string $messageKey, string $buttonsHtml, string $extraHtml = ''): string @@ -625,7 +625,7 @@ function renderInstallResultSection(string $sectionId, string $iconType, string // failure는 동적 메시지이므로 번역하지 않음 $message = $sectionId === 'failure' ? '
' - : '

'.htmlspecialchars(lang($messageKey)).'

'; + : '

' . htmlspecialchars(lang($messageKey)) . '

'; return << @@ -657,8 +657,8 @@ function renderInstallResultSection(string $sectionId, string $iconType, string function getCoreAppVersion(): string { foreach (['/.env', '/.env.example'] as $relative) { - $path = BASE_PATH.$relative; - if (! is_readable($path)) { + $path = BASE_PATH . $relative; + if (!is_readable($path)) { continue; } $content = @file_get_contents($path); @@ -676,8 +676,8 @@ function getCoreAppVersion(): string /** * Progress indicator HTML을 렌더링합니다. * - * @param int $currentStep 현재 단계 번호 - * @param int $totalSteps 전체 단계 수 (기본값: 7, Step 0~6) + * @param int $currentStep 현재 단계 번호 + * @param int $totalSteps 전체 단계 수 (기본값: 7, Step 0~6) * @return string Progress indicator HTML */ function renderProgressIndicator(int $currentStep, int $totalSteps = 7): string @@ -690,10 +690,9 @@ function renderProgressIndicator(int $currentStep, int $totalSteps = 7): string } elseif ($i === $currentStep) { $class .= ' active'; } - $html .= '
'; + $html .= '
'; } $html .= ''; - return $html; } @@ -710,7 +709,7 @@ function getWebServerUser(): ?string // 1. posix 확장 (가장 신뢰도 높음) if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) { $info = posix_getpwuid(posix_geteuid()); - if ($info && ! empty($info['name'])) { + if ($info && !empty($info['name'])) { return $info['name']; } } @@ -718,14 +717,14 @@ function getWebServerUser(): ?string // 2. exec('whoami') if (function_exists('exec')) { $user = @exec('whoami'); - if (! empty($user)) { + if (!empty($user)) { return trim($user); } } // 3. 환경변수 $envUser = getenv('USER') ?: getenv('APACHE_RUN_USER') ?: null; - if (! empty($envUser)) { + if (!empty($envUser)) { return $envUser; } @@ -741,13 +740,13 @@ function getWebServerGroup(): ?string { if (function_exists('posix_getegid') && function_exists('posix_getgrgid')) { $info = posix_getgrgid(posix_getegid()); - if ($info && ! empty($info['name'])) { + if ($info && !empty($info['name'])) { return $info['name']; } } $envGroup = getenv('APACHE_RUN_GROUP') ?: null; - if (! empty($envGroup)) { + if (!empty($envGroup)) { return $envGroup; } @@ -759,7 +758,7 @@ function getWebServerGroup(): ?string * * posix 확장이 없거나 확인할 수 없는 경우 null을 반환합니다. * - * @param string $path 파일/디렉토리 경로 + * @param string $path 파일/디렉토리 경로 * @return string|null 소유자 이름 또는 null */ function getFileOwnerName(string $path): ?string @@ -771,7 +770,7 @@ function getFileOwnerName(string $path): ?string if (function_exists('posix_getpwuid')) { $info = @posix_getpwuid($uid); - if ($info && ! empty($info['name'])) { + if ($info && !empty($info['name'])) { return $info['name']; } } @@ -785,7 +784,7 @@ function getFileOwnerName(string $path): ?string * * posix 확장이 없거나 확인할 수 없는 경우 null을 반환합니다. * - * @param string $path 파일/디렉토리 경로 + * @param string $path 파일/디렉토리 경로 * @return string|null 그룹 이름 또는 null */ function getFileGroupName(string $path): ?string @@ -797,7 +796,7 @@ function getFileGroupName(string $path): ?string if (function_exists('posix_getgrgid')) { $info = @posix_getgrgid($gid); - if ($info && ! empty($info['name'])) { + if ($info && !empty($info['name'])) { return $info['name']; } } @@ -818,7 +817,7 @@ function getChownTarget(): string $user = getWebServerUser() ?? 'www-data'; $group = getWebServerGroup() ?? $user; - return $user.':'.$group; + return $user . ':' . $group; } /** @@ -827,7 +826,7 @@ function getChownTarget(): string * Step 0과 Step 2에서 재사용 가능한 공통 함수입니다. * 디렉토리를 생성하지 않고 존재 여부와 권한만 체크합니다. * - * @param array $directories 체크할 디렉토리 경로 배열 (키: 경로, 값: 재귀 체크 여부) + * @param array $directories 체크할 디렉토리 경로 배열 (키: 경로, 값: 재귀 체크 여부) * @return array{all_passed: bool, results: array}>} */ function checkDirectoryPermissions(array $directories): array @@ -843,7 +842,7 @@ function checkDirectoryPermissions(array $directories): array $webServerUser = getWebServerUser(); foreach ($directories as $path => $checkRecursive) { - $fullPath = BASE_PATH.'/'.$path; + $fullPath = BASE_PATH . '/' . $path; // 디렉토리 존재 여부 확인 (생성하지 않음) $exists = is_dir($fullPath); @@ -884,22 +883,22 @@ function checkDirectoryPermissions(array $directories): array $parentPassed = $writable && $readable; // 최종 통과 여부: 상위 + 하위 모두 통과해야 함 - $passed = $parentPassed && ! $hasSubdirectoryIssues; + $passed = $parentPassed && !$hasSubdirectoryIssues; // 에러 타입 구분 // ownership_mismatch: 권한 비트는 0755 이상이지만 소유자가 웹서버 사용자와 달라 // 실제 쓰기 불가 (전통적 Apache: 파일 소유자 != www-data) $errorType = null; - if (! $exists) { + if (!$exists) { $errorType = 'not_exists'; - } elseif (! $writable) { + } elseif (!$writable) { // 권한 비트가 충분(0755+)한데도 쓰기 불가 → 소유권 불일치 가능성 if ($permsOctal >= 0755 && $webServerUser && $owner && $owner !== $webServerUser) { $errorType = 'ownership_mismatch'; } else { $errorType = 'not_writable'; } - } elseif (! $readable) { + } elseif (!$readable) { $errorType = 'not_readable'; } elseif ($hasSubdirectoryIssues) { // 상위는 OK, 하위 디렉토리만 문제 @@ -921,7 +920,7 @@ function checkDirectoryPermissions(array $directories): array 'failed_subdirectories' => $failedSubdirectories, ]; - if (! $passed) { + if (!$passed) { $allPassed = false; } } @@ -935,22 +934,14 @@ function checkDirectoryPermissions(array $directories): array /** * 디렉토리의 하위를 재귀적으로 체크하여 권한이 없는 경로와 권한 정보 반환 * - * 깊이 제한(maxDepth)을 둔다 (dev-g7#445 후속): storage 처럼 하위에 수만 개 - * 디렉토리가 누적되는 경로(예: storage/framework/testing/)를 무한 재귀로 순회하면 - * 인스톨러 2단계(요구사항 검증) 응답이 수 초 이상 지연된다. 쓰기 권한 문제는 트리 - * 상위 얕은 깊이에서 결정되므로(상위가 쓰기 가능하면 하위도 통상 상속), 제한 깊이까지만 - * 검사해 성능과 안내 유효성을 모두 확보한다. - * - * @param string $path 체크할 디렉토리 경로 - * @param int $maxDepth 재귀 최대 깊이 (0 = 현재 디렉토리의 직속 하위만). 기본 1 - * @param int $currentDepth 내부 재귀용 현재 깊이 (호출자는 지정하지 않음) + * @param string $path 체크할 디렉토리 경로 * @return array 권한이 없는 하위 디렉토리 정보 배열 */ -function checkSubdirectoriesRecursive(string $path, int $maxDepth = 1, int $currentDepth = 0): array +function checkSubdirectoriesRecursive(string $path): array { $failedPaths = []; - if (! is_dir($path)) { + if (!is_dir($path)) { return $failedPaths; } @@ -971,21 +962,21 @@ function checkSubdirectoriesRecursive(string $path, int $maxDepth = 1, int $curr continue; } - $itemPath = $path.DIRECTORY_SEPARATOR.$item; + $itemPath = $path . DIRECTORY_SEPARATOR . $item; // 디렉토리인지 확인 (심볼릭 링크 제외) - if (! is_dir($itemPath) || is_link($itemPath)) { + if (!is_dir($itemPath) || is_link($itemPath)) { continue; } // 절대 경로를 상대 경로로 변환 - $relativePath = str_replace(BASE_PATH.'/', '', $itemPath); + $relativePath = str_replace(BASE_PATH . '/', '', $itemPath); $relativePath = str_replace('\\', '/', $relativePath); // 쓰기 권한 체크 $isWritable = is_writable($itemPath); - if (! $isWritable) { + if (!$isWritable) { // 권한 코드 가져오기 $perms = fileperms($itemPath); $permissions = substr(sprintf('%o', $perms), -3); @@ -997,15 +988,11 @@ function checkSubdirectoriesRecursive(string $path, int $maxDepth = 1, int $curr } // 재귀적으로 하위 디렉토리 체크 (권한 없어도 계속 진행) - // 깊이 제한 도달 시 더 내려가지 않는다 — storage/framework/testing 등에 누적된 - // 수만 개 하위 디렉토리를 순회해 인스톨러 2단계가 지연되던 문제 차단 (dev-g7#445 후속). - if ($currentDepth < $maxDepth) { - try { - $subFailed = checkSubdirectoriesRecursive($itemPath, $maxDepth, $currentDepth + 1); - $failedPaths = array_merge($failedPaths, $subFailed); - } catch (Exception $e) { - // 하위 디렉토리 체크 실패해도 계속 진행 - } + try { + $subFailed = checkSubdirectoriesRecursive($itemPath); + $failedPaths = array_merge($failedPaths, $subFailed); + } catch (Exception $e) { + // 하위 디렉토리 체크 실패해도 계속 진행 } } @@ -1017,12 +1004,12 @@ function checkSubdirectoriesRecursive(string $path, int $maxDepth = 1, int $curr * * 루트 LICENSE 파일을 읽습니다. (한국어 번역 + 영문 원문 통합) * - * @param string $lang 언어 코드 (미사용, 하위 호환성 유지) + * @param string $lang 언어 코드 (미사용, 하위 호환성 유지) * @return string 라이선스 파일 내용 */ function loadLicenseFile(string $lang): string { - $licenseFile = __DIR__.'/../../../LICENSE'; + $licenseFile = __DIR__ . '/../../../LICENSE'; if (file_exists($licenseFile)) { return file_get_contents($licenseFile); @@ -1037,7 +1024,7 @@ function loadLicenseFile(string $lang): string * 특수 문자가 포함된 값을 .env 파일에 안전하게 기록할 수 있도록 큰따옴표로 감싸고 * 내부 큰따옴표와 백슬래시를 이스케이프합니다. * - * @param string $value 이스케이프할 값 + * @param string $value 이스케이프할 값 * @return string 큰따옴표로 감싸고 내부 큰따옴표와 백슬래시를 이스케이프한 값 */ function escapeEnvValue(string $value): string @@ -1057,7 +1044,7 @@ function escapeEnvValue(string $value): string $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value); // 큰따옴표로 감싸기 - return '"'.$escaped.'"'; + return '"' . $escaped . '"'; } /** @@ -1070,8 +1057,8 @@ function escapeEnvValue(string $value): string */ function generateEnvContent(): ?string { - $envExamplePath = BASE_PATH.'/.env.example'; - if (! file_exists($envExamplePath)) { + $envExamplePath = BASE_PATH . '/.env.example'; + if (!file_exists($envExamplePath)) { return null; } @@ -1082,41 +1069,39 @@ function generateEnvContent(): ?string // 데이터베이스 설정 치환 $replacements = [ 'DB_CONNECTION=mysql' => 'DB_CONNECTION=mysql', - 'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST='.($config['db_write_host'] ?? '127.0.0.1'), - 'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT='.($config['db_write_port'] ?? '3306'), - 'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE='.($config['db_write_database'] ?? 'g7'), - 'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME='.($config['db_write_username'] ?? 'root'), - 'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD='.escapeEnvValue($config['db_write_password'] ?? ''), - 'DB_PREFIX=g7_' => 'DB_PREFIX='.($config['db_prefix'] ?? 'g7_'), + 'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST=' . ($config['db_write_host'] ?? '127.0.0.1'), + 'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT=' . ($config['db_write_port'] ?? '3306'), + 'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE=' . ($config['db_write_database'] ?? 'g7'), + 'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME=' . ($config['db_write_username'] ?? 'root'), + 'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD=' . escapeEnvValue($config['db_write_password'] ?? ''), + 'DB_PREFIX=g7_' => 'DB_PREFIX=' . ($config['db_prefix'] ?? 'g7_'), ]; // Read DB 설정 - if (! empty($config['use_read_db']) && $config['use_read_db']) { - $replacements['DB_READ_HOST='] = 'DB_READ_HOST='.($config['db_read_host'] ?? '127.0.0.1'); - $replacements['DB_READ_PORT='] = 'DB_READ_PORT='.($config['db_read_port'] ?? '3306'); - $replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.($config['db_read_database'] ?? 'g7'); - $replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.($config['db_read_username'] ?? 'root'); - $replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.escapeEnvValue($config['db_read_password'] ?? ''); + if (!empty($config['use_read_db']) && $config['use_read_db']) { + $replacements['DB_READ_HOST='] = 'DB_READ_HOST=' . ($config['db_read_host'] ?? '127.0.0.1'); + $replacements['DB_READ_PORT='] = 'DB_READ_PORT=' . ($config['db_read_port'] ?? '3306'); + $replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE=' . ($config['db_read_database'] ?? 'g7'); + $replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME=' . ($config['db_read_username'] ?? 'root'); + $replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD=' . escapeEnvValue($config['db_read_password'] ?? ''); } else { - // Read DB를 사용하지 않는 경우 DB_READ_* 를 빈 값으로 둔다. - // config/database.php 의 write fallback(Elvis) 이 SELECT 를 write DB 로 넘기므로 - // .env 에 write 값을 중복 기록하지 않는다 (write 변경 시 read stale 방지). (이슈 #63) - $replacements['DB_READ_HOST='] = 'DB_READ_HOST='; - $replacements['DB_READ_PORT='] = 'DB_READ_PORT='; - $replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='; - $replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='; - $replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='; + // Read DB를 사용하지 않는 경우 Write DB 정보와 동일하게 설정 + $replacements['DB_READ_HOST='] = 'DB_READ_HOST=' . ($config['db_write_host'] ?? '127.0.0.1'); + $replacements['DB_READ_PORT='] = 'DB_READ_PORT=' . ($config['db_write_port'] ?? '3306'); + $replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE=' . ($config['db_write_database'] ?? 'g7'); + $replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME=' . ($config['db_write_username'] ?? 'root'); + $replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD=' . escapeEnvValue($config['db_write_password'] ?? ''); } // 앱 설정 치환 - $replacements['APP_NAME=그누보드7'] = 'APP_NAME="'.($config['app_name'] ?? '그누보드7').'"'; - $replacements['APP_ENV=production'] = 'APP_ENV='.($config['app_env'] ?? 'production'); - $replacements['APP_URL=http://localhost'] = 'APP_URL='.($config['app_url'] ?? 'http://localhost'); + $replacements['APP_NAME=그누보드7'] = 'APP_NAME="' . ($config['app_name'] ?? '그누보드7') . '"'; + $replacements['APP_ENV=production'] = 'APP_ENV=' . ($config['app_env'] ?? 'production'); + $replacements['APP_URL=http://localhost'] = 'APP_URL=' . ($config['app_url'] ?? 'http://localhost'); // 언어 설정 $currentLang = getCurrentLanguage(); - $replacements['APP_LOCALE=ko'] = 'APP_LOCALE='.$currentLang; - $replacements['APP_FAKER_LOCALE=ko_KR'] = 'APP_FAKER_LOCALE='.($currentLang === 'ko' ? 'ko_KR' : 'en_US'); + $replacements['APP_LOCALE=ko'] = 'APP_LOCALE=' . $currentLang; + $replacements['APP_FAKER_LOCALE=ko_KR'] = 'APP_FAKER_LOCALE=' . ($currentLang === 'ko' ? 'ko_KR' : 'en_US'); if (isset($config['app_env']) && $config['app_env'] !== 'production') { $replacements['APP_DEBUG=false'] = 'APP_DEBUG=true'; @@ -1127,15 +1112,15 @@ function generateEnvContent(): ?string $coreGithubUrl = $config['core_update_github_url'] ?? 'https://github.com/gnuboard/g7'; $coreGithubToken = $config['core_update_github_token'] ?? ''; - $replacements['G7_UPDATE_PENDING_PATH='] = 'G7_UPDATE_PENDING_PATH='.escapeEnvValue($corePendingPath); - $replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL='.$coreGithubUrl; - $replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN='.escapeEnvValue($coreGithubToken); + $replacements['G7_UPDATE_PENDING_PATH='] = 'G7_UPDATE_PENDING_PATH=' . escapeEnvValue($corePendingPath); + $replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL=' . $coreGithubUrl; + $replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN=' . escapeEnvValue($coreGithubToken); // PHP CLI / Composer 바이너리 경로 치환 $phpBinary = $config['php_binary'] ?? 'php'; $composerBinary = $config['composer_binary'] ?? ''; - $replacements['PHP_BINARY=php'] = 'PHP_BINARY='.escapeEnvValue($phpBinary); - $replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY='.escapeEnvValue($composerBinary); + $replacements['PHP_BINARY=php'] = 'PHP_BINARY=' . escapeEnvValue($phpBinary); + $replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY=' . escapeEnvValue($composerBinary); foreach ($replacements as $search => $replace) { $envContent = str_replace($search, $replace, $envContent); @@ -1157,22 +1142,22 @@ function isWindows(): bool /** * OS에 맞는 .env 파일 복사 명령어를 반환합니다. * - * @param string $basePath 프로젝트 루트 경로 (빈 문자열이면 상대 경로 사용) + * @param string $basePath 프로젝트 루트 경로 (빈 문자열이면 상대 경로 사용) * @return string 복사 명령어 */ function getEnvCopyCommand(string $basePath = ''): string { if (isWindows()) { - $source = $basePath ? $basePath.'\\.env.example' : '.env.example'; - $dest = $basePath ? $basePath.'\\.env' : '.env'; + $source = $basePath ? $basePath . '\\.env.example' : '.env.example'; + $dest = $basePath ? $basePath . '\\.env' : '.env'; - return 'copy '.str_replace('/', '\\', $source).' '.str_replace('/', '\\', $dest); + return 'copy ' . str_replace('/', '\\', $source) . ' ' . str_replace('/', '\\', $dest); } - $source = $basePath ? $basePath.'/.env.example' : '.env.example'; - $dest = $basePath ? $basePath.'/.env' : '.env'; + $source = $basePath ? $basePath . '/.env.example' : '.env.example'; + $dest = $basePath ? $basePath . '/.env' : '.env'; - return 'cp '.$source.' '.$dest; + return 'cp ' . $source . ' ' . $dest; } /** @@ -1182,8 +1167,8 @@ function getEnvCopyCommand(string $basePath = ''): string * chown/setgid 의존성을 제거하여 공유호스팅 호환성 확보. * 실제 통과 기준은 is_writable() && is_readable()이므로 비트 값은 참고용. * - * @param string $pathList 대상 경로 (공백 구분) - * @param string $mode 'ownership' (디렉토리) 또는 'file' (파일) + * @param string $pathList 대상 경로 (공백 구분) + * @param string $mode 'ownership' (디렉토리) 또는 'file' (파일) * @return string 권한 수정 명령어 */ function getPermissionFixCommand(string $pathList, string $mode = 'ownership'): string @@ -1191,19 +1176,20 @@ function getPermissionFixCommand(string $pathList, string $mode = 'ownership'): if (isWindows()) { $winPath = str_replace('/', '\\', $pathList); if ($mode === 'file') { - return 'icacls '.$winPath.' /grant Everyone:F'; + return 'icacls ' . $winPath . ' /grant Everyone:F'; } - return 'icacls '.$winPath.' /grant Everyone:(OI)(CI)F /T'; + return 'icacls ' . $winPath . ' /grant Everyone:(OI)(CI)F /T'; } if ($mode === 'file') { - return 'chmod 644 '.$pathList; + return 'chmod 644 ' . $pathList; } - return 'chmod -R 755 '.$pathList; + return 'chmod -R 755 ' . $pathList; } + /** * 기존 DB 테이블 감지. * @@ -1216,8 +1202,9 @@ function getPermissionFixCommand(string $pathList, string $mode = 'ownership'): * - 'mixed' : G7 일부 + 기타 혼재 * - 'foreign_data' : G7 시그니처 없음 + 기타 테이블 존재 * - * @param PDO $pdo 연결된 PDO 인스턴스 - * @param string $database 대상 데이터베이스명 + * @param PDO $pdo 연결된 PDO 인스턴스 + * @param string $database 대상 데이터베이스명 + * @return array */ function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = ''): array { @@ -1250,7 +1237,7 @@ function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = ' // G7 핵심 시그니처 테이블 — 정책 결정: 4개 모두 일치 시 "G7 설치됨"으로 판단 // 테이블 prefix를 적용하여 비교 (예: prefix='g7_'이면 'g7_users', 'g7_migrations' 등) $coreSignatures = ['users', 'migrations', 'roles', 'permissions']; - $g7Signatures = array_map(fn ($t) => $tablePrefix.$t, $coreSignatures); + $g7Signatures = array_map(fn ($t) => $tablePrefix . $t, $coreSignatures); $g7TablesFound = array_values(array_intersect($g7Signatures, $tables)); $severity = 'foreign_data'; @@ -1270,7 +1257,7 @@ function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = ' ]; } -if (! function_exists('filterTablesByPrefix')) { +if (!function_exists('filterTablesByPrefix')) { /** * 기존 테이블 목록을 db_prefix 로 필터링합니다. * @@ -1281,8 +1268,8 @@ if (! function_exists('filterTablesByPrefix')) { * 빈 prefix 는 전체 일치가 되어 모든 테이블이 삭제되는 위험이 있으므로, * 데이터 손실 방어를 위해 빈 목록(삭제 대상 없음)을 반환합니다. * - * @param array $tables SHOW TABLES 로 조회한 전체 테이블 목록 - * @param string $prefix 사용자가 입력한 테이블 prefix (예: 'g7_') + * @param array $tables SHOW TABLES 로 조회한 전체 테이블 목록 + * @param string $prefix 사용자가 입력한 테이블 prefix (예: 'g7_') * @return array prefix 로 시작하는 테이블만 담은 목록 */ function filterTablesByPrefix(array $tables, string $prefix): array diff --git a/public/install/includes/installer-runtime.php b/public/install/includes/installer-runtime.php index b2f80317..02134496 100644 --- a/public/install/includes/installer-runtime.php +++ b/public/install/includes/installer-runtime.php @@ -15,12 +15,13 @@ * * @see https://github.com/gnuboard/g7/issues/23 */ + if (! defined('BASE_PATH')) { throw new RuntimeException('installer-runtime.php requires BASE_PATH constant.'); } if (! defined('INSTALLER_RUNTIME_PATH')) { - define('INSTALLER_RUNTIME_PATH', BASE_PATH.'/storage/installer/runtime.php'); + define('INSTALLER_RUNTIME_PATH', BASE_PATH . '/storage/installer/runtime.php'); } if (! function_exists('readInstallerRuntime')) { @@ -58,9 +59,9 @@ if (! function_exists('writeInstallerRuntime')) { return false; } - $php = " date('c'), ]; - // Read 커넥션은 use_read_db 플래그가 켜진 경우에만 포함한다 (판정 SSoT). - // 플래그가 꺼져 있으면 db_read_host 에 잔존 값이 있어도 무시 → runtime 에 read 키 - // 미생성 → 하류(mergeRuntimeIntoEnv / InstallerRuntimeServiceProvider)가 write 로 - // 자동 동기화. (이슈 #63: use_read_db=false 인데 db_read_host 를 참조하던 회귀 차단) - if (! empty($stateConfig['use_read_db']) - && ! empty($stateConfig['db_read_host']) - && $stateConfig['db_read_host'] !== ($stateConfig['db_write_host'] ?? null)) { + // Read 커넥션이 별도로 지정된 경우만 포함 (그렇지 않으면 Laravel 이 write 사용) + if (! empty($stateConfig['db_read_host']) && $stateConfig['db_read_host'] !== ($stateConfig['db_write_host'] ?? null)) { $runtime['db']['read'] = [ 'host' => $stateConfig['db_read_host'], 'port' => $stateConfig['db_read_port'] ?? $runtime['db']['write']['port'], diff --git a/public/install/includes/installer-state.php b/public/install/includes/installer-state.php index 1984b4e7..20d8a6f0 100644 --- a/public/install/includes/installer-state.php +++ b/public/install/includes/installer-state.php @@ -5,15 +5,16 @@ * * 설치 진행 상태를 storage/installer-state.json에 저장하고 조회합니다. */ -if (! defined('BASE_PATH')) { + +if (!defined('BASE_PATH')) { define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); // public/install/includes에서 프로젝트 루트로 } if (! defined('STATE_PATH')) { - define('STATE_PATH', BASE_PATH.'/storage/installer-state.json'); + define('STATE_PATH', BASE_PATH . '/storage/installer-state.json'); } if (! defined('INSTALLER_DIR')) { - define('INSTALLER_DIR', BASE_PATH.'/storage/installer'); + define('INSTALLER_DIR', BASE_PATH . '/storage/installer'); } /** @@ -35,16 +36,15 @@ function getInstallationState(): array // state.json 파일이 없으면 기본 상태 반환 // (설치 완료 후 워커가 DELETE_INSTALLER_AFTER_COMPLETE로 정상 삭제하는 경우가 있으므로 // 부재 자체는 에러가 아님. 호출자가 g7_installed 플래그 등으로 완료 여부 판정) - if (! file_exists(STATE_PATH)) { + if (!file_exists(STATE_PATH)) { return $defaultState; } // 파일 읽기 권한 체크 - if (! is_readable(STATE_PATH)) { - $msg = '[installer-state] State file is not readable: '.STATE_PATH; + if (!is_readable(STATE_PATH)) { + $msg = "[installer-state] State file is not readable: " . STATE_PATH; error_log($msg); addLog($msg); - return $defaultState; } @@ -53,10 +53,9 @@ function getInstallationState(): array // 파일 읽기 실패 시 기본 상태 반환 if ($content === false) { - $msg = '[installer-state] Failed to read state file: '.STATE_PATH; + $msg = "[installer-state] Failed to read state file: " . STATE_PATH; error_log($msg); addLog($msg); - return $defaultState; } @@ -66,10 +65,9 @@ function getInstallationState(): array if (json_last_error() !== JSON_ERROR_NONE) { $contentLen = strlen($content); $preview = substr($content, 0, 200); - $msg = "[installer-state] Failed to parse state file JSON (length={$contentLen}): ".json_last_error_msg().' / preview: '.$preview; + $msg = "[installer-state] Failed to parse state file JSON (length={$contentLen}): " . json_last_error_msg() . " / preview: " . $preview; error_log($msg); addLog($msg); - return $defaultState; } @@ -79,27 +77,25 @@ function getInstallationState(): array /** * 설치 상태 저장 * - * @param array $state 저장할 상태 배열 + * @param array $state 저장할 상태 배열 * @return bool 저장 성공 여부 */ function saveInstallationState(array $state): bool { // storage 디렉토리 존재 여부 확인 (생성하지 않음) - $storageDir = BASE_PATH.'/storage'; - if (! is_dir($storageDir)) { + $storageDir = BASE_PATH . '/storage'; + if (!is_dir($storageDir)) { $msg = "[installer-state] Storage directory does not exist: {$storageDir}"; error_log($msg); addLog($msg); - return false; } // 디렉토리 쓰기 권한 확인 - if (! is_writable($storageDir)) { + if (!is_writable($storageDir)) { $msg = "[installer-state] Storage directory is not writable: {$storageDir}"; error_log($msg); addLog($msg); - return false; } @@ -110,36 +106,33 @@ function saveInstallationState(array $state): bool $content = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($content === false) { - $msg = '[installer-state] Failed to encode state as JSON: '.json_last_error_msg(); + $msg = "[installer-state] Failed to encode state as JSON: " . json_last_error_msg(); error_log($msg); addLog($msg); - return false; } // 원자적 쓰기: tmp 파일에 쓰고 rename으로 교체 // 동시 쓰기 경쟁 상황에서 부분 쓰기(half-written file)로 인한 JSON 손상 방지. // Linux에서 rename()은 atomic syscall이므로 다른 리더가 항상 완전한 파일만 읽는다. - $tmpPath = STATE_PATH.'.tmp.'.getmypid().'.'.uniqid(); + $tmpPath = STATE_PATH . '.tmp.' . getmypid() . '.' . uniqid(); $result = @file_put_contents($tmpPath, $content, LOCK_EX); if ($result === false || $result !== strlen($content)) { - $msg = '[installer-state] Failed to write state tmp file: '.$tmpPath; + $msg = "[installer-state] Failed to write state tmp file: " . $tmpPath; error_log($msg); addLog($msg); @unlink($tmpPath); - return false; } @chmod($tmpPath, 0664); - if (! @rename($tmpPath, STATE_PATH)) { - $msg = '[installer-state] Failed to rename state tmp file: '.$tmpPath.' → '.STATE_PATH; + if (!@rename($tmpPath, STATE_PATH)) { + $msg = "[installer-state] Failed to rename state tmp file: " . $tmpPath . ' → ' . STATE_PATH; error_log($msg); addLog($msg); @unlink($tmpPath); - return false; } @@ -168,13 +161,13 @@ function isInstallationCompleted(): bool } // g7_installed 파일 존재 여부 확인 (추가 안전장치) - $installedFlagPath = BASE_PATH.'/storage/app/g7_installed'; + $installedFlagPath = BASE_PATH . '/storage/app/g7_installed'; if (file_exists($installedFlagPath)) { return true; } // .env 파일의 INSTALLER_COMPLETED 플래그 확인 - $envPath = BASE_PATH.'/.env'; + $envPath = BASE_PATH . '/.env'; if (file_exists($envPath)) { $envContent = file_get_contents($envPath); if (strpos($envContent, 'INSTALLER_COMPLETED=true') !== false) { @@ -188,7 +181,7 @@ function isInstallationCompleted(): bool /** * 특정 작업을 완료로 표시 * - * @param string $task 작업 식별자 + * @param string $task 작업 식별자 * @return bool 업데이트 성공 여부 */ function markTaskCompleted(string $task): bool @@ -196,7 +189,7 @@ function markTaskCompleted(string $task): bool $state = getInstallationState(); // completed_tasks 배열에 추가 (중복 방지) - if (! in_array($task, $state['completed_tasks'])) { + if (!in_array($task, $state['completed_tasks'])) { $state['completed_tasks'][] = $task; } @@ -212,7 +205,7 @@ function markTaskCompleted(string $task): bool /** * 특정 작업을 완료 목록에서 제거 * - * @param string $task 작업 식별자 + * @param string $task 작업 식별자 * @return bool 업데이트 성공 여부 */ function removeTaskCompleted(string $task): bool @@ -220,7 +213,7 @@ function removeTaskCompleted(string $task): bool $state = getInstallationState(); $state['completed_tasks'] = array_values( - array_filter($state['completed_tasks'], fn ($t) => $t !== $task) + array_filter($state['completed_tasks'], fn($t) => $t !== $task) ); return saveInstallationState($state); @@ -258,7 +251,7 @@ function removeTaskCompleted(string $task): bool * * @param int $staleSeconds heartbeat 가 이 시간 이상 갱신 안 되면 stale 판정 (default 15초) * @return array{acquired: bool, worker_id: string|null, reason: string} - * reason: 'available' | 'takeover_stale' | 'busy' + * reason: 'available' | 'takeover_stale' | 'busy' */ function acquireWorkerLock(int $staleSeconds = 15): array { @@ -344,7 +337,7 @@ function applyExistingDbActionStateGuard(array $state, string $newAction): array $resetTasks = ['db_cleanup', 'db_migrate', 'db_seed']; $state['completed_tasks'] = array_values(array_filter( $state['completed_tasks'] ?? [], - fn ($t) => ! in_array($t, $resetTasks, true) + fn($t) => !in_array($t, $resetTasks, true) )); } else { // 키 정규화 — completed_tasks 가 부재인 경우 빈 배열 보장 @@ -357,7 +350,7 @@ function applyExistingDbActionStateGuard(array $state, string $newAction): array /** * 현재 진행 중인 작업 업데이트 * - * @param string $task 작업 식별자 + * @param string $task 작업 식별자 * @return bool 업데이트 성공 여부 */ function updateCurrentTask(string $task): bool @@ -376,7 +369,7 @@ function updateCurrentTask(string $task): bool * 페이지 새로고침/재시작 시 로그가 즉시 표시되도록 * fflush() + clearstatcache()를 적용합니다. * - * @param string $message 로그 메시지 + * @param string $message 로그 메시지 * @return bool 저장 성공 여부 */ /** @@ -396,25 +389,23 @@ function addLogBatch(array $messages): bool return true; } - $logDir = BASE_PATH.'/storage/logs'; - $logFile = $logDir.'/installation.log'; + $logDir = BASE_PATH . '/storage/logs'; + $logFile = $logDir . '/installation.log'; - if (! is_dir($logDir)) { + if (!is_dir($logDir)) { $created = @mkdir($logDir, 0775, true); - if (! $created) { + if (!$created) { $msg = "[addLogBatch] Failed to create log directory: {$logDir}"; error_log($msg); addLog($msg); // 재귀 가드가 차단하므로 안전 — error_log fallback 만 수행 - return false; } } - if (! is_writable($logDir)) { + if (!is_writable($logDir)) { $msg = "[addLogBatch] Log directory is not writable: {$logDir}"; error_log($msg); addLog($msg); // 재귀 가드가 차단 - return false; } @@ -434,9 +425,9 @@ function addLogBatch(array $messages): bool } clearstatcache(true, $logFile); - $isNewFile = ! file_exists($logFile) || filesize($logFile) === 0; + $isNewFile = !file_exists($logFile) || filesize($logFile) === 0; if ($isNewFile) { - $entries = "\xEF\xBB\xBF".$entries; + $entries = "\xEF\xBB\xBF" . $entries; } $handle = @fopen($logFile, 'a'); @@ -444,7 +435,6 @@ function addLogBatch(array $messages): bool $msg = "[addLogBatch] Failed to open log file: {$logFile}"; error_log($msg); addLog($msg); // 재귀 가드가 차단 - return false; } flock($handle, LOCK_EX); @@ -465,8 +455,7 @@ function addLog(string $message): bool static $inAddLog = false; if ($inAddLog) { // 재진입: installation.log 시도 skip, PHP error_log 만 호출하고 즉시 종료 - error_log('[addLog reentrant] '.$message); - + error_log("[addLog reentrant] " . $message); return false; } $inAddLog = true; @@ -489,27 +478,25 @@ function addLog(string $message): bool */ function _addLogInternal(string $message): bool { - $logDir = BASE_PATH.'/storage/logs'; - $logFile = $logDir.'/installation.log'; + $logDir = BASE_PATH . '/storage/logs'; + $logFile = $logDir . '/installation.log'; // 로그 디렉토리 확인 및 생성 시도 - if (! is_dir($logDir)) { + if (!is_dir($logDir)) { $created = @mkdir($logDir, 0775, true); - if (! $created) { + if (!$created) { $msg = "[addLog] Failed to create log directory: {$logDir} (storage 권한 확인 필요)"; error_log($msg); addLog($msg); // 재귀 가드가 차단 (본 함수는 이미 가드 안) - return false; } } // 로그 디렉토리 쓰기 권한 확인 - if (! is_writable($logDir)) { + if (!is_writable($logDir)) { $msg = "[addLog] Log directory is not writable: {$logDir}"; error_log($msg); addLog($msg); // 재귀 가드가 차단 - return false; } @@ -521,17 +508,6 @@ function _addLogInternal(string $message): bool } } - // OS 무관 최종 방어 — 항상 유효 UTF-8 만 로그에 저장한다 (gnuboard/g7#62). - // composer install 등 외부 프로세스 stdout 이 시스템 코드페이지로 출력되거나 - // 진행바(\r 갱신)가 임의 바이트 경계에서 잘려 mb_detect_encoding 이 확정 감지에 - // 실패한 경우(위 분기 미적용), invalid 바이트가 그대로 로그에 남으면 - // 폴링 응답 state-management.php 의 json_encode 가 false 를 반환해 빈 본문(HTTP 200) - // → 프론트 res.json() 이 "Unexpected end of JSON input" 으로 폭주한다. - // mb_scrub 은 invalid 바이트를 U+FFFD 로 치환해 응답 무력화를 원천 차단한다. - if (! mb_check_encoding($message, 'UTF-8')) { - $message = mb_scrub($message, 'UTF-8'); - } - // 타임스탬프와 함께 로그 작성 — millisecond 정밀도 (hang 진단 시 정확한 timing 필요) $microtime = microtime(true); $ms = sprintf('%03d', (int) (($microtime - floor($microtime)) * 1000)); @@ -542,10 +518,10 @@ function _addLogInternal(string $message): bool clearstatcache(true, $logFile); // 파일이 없거나 빈 파일이면 UTF-8 BOM 추가 (Windows 텍스트 편집기 호환성) - $isNewFile = ! file_exists($logFile) || filesize($logFile) === 0; + $isNewFile = !file_exists($logFile) || filesize($logFile) === 0; if ($isNewFile) { $utf8Bom = "\xEF\xBB\xBF"; - $logEntry = $utf8Bom.$logEntry; + $logEntry = $utf8Bom . $logEntry; } // 파일 핸들 열기 (append 모드) @@ -554,7 +530,6 @@ function _addLogInternal(string $message): bool $msg = "[addLog] Failed to open log file: {$logFile}"; error_log($msg); addLog($msg); // 재귀 가드가 차단 - return false; } @@ -578,7 +553,6 @@ function _addLogInternal(string $message): bool $msg = "[addLog] Failed to write log file: {$logFile}"; error_log($msg); addLog($msg); // 재귀 가드가 차단 - return false; } @@ -591,16 +565,16 @@ function _addLogInternal(string $message): bool * 페이지 새로고침 시 최신 로그를 즉시 표시하기 위해 * clearstatcache()를 적용합니다. * - * @param int $offset 건너뛸 로그 줄 수 (폴링 모드 증분 조회용, 기본 0) + * @param int $offset 건너뛸 로그 줄 수 (폴링 모드 증분 조회용, 기본 0) * @return array 로그 배열 [{timestamp, message}, ...] */ function getInstallationLogs(int $offset = 0): array { - $logFile = BASE_PATH.'/storage/logs/installation.log'; + $logFile = BASE_PATH . '/storage/logs/installation.log'; clearstatcache(true, $logFile); - if (! file_exists($logFile)) { + if (!file_exists($logFile)) { return []; } @@ -647,11 +621,11 @@ function getInstallationLogs(int $offset = 0): array */ function getInstallationLogCount(): int { - $logFile = BASE_PATH.'/storage/logs/installation.log'; + $logFile = BASE_PATH . '/storage/logs/installation.log'; clearstatcache(true, $logFile); - if (! file_exists($logFile)) { + if (!file_exists($logFile)) { return 0; } @@ -663,11 +637,10 @@ function getInstallationLogCount(): int $lines = explode("\n", trim($content)); $count = 0; foreach ($lines as $line) { - if (! empty($line)) { + if (!empty($line)) { $count++; } } - return $count; } @@ -682,7 +655,7 @@ function getLastCompletedStep(): int // step_status를 역순으로 확인 for ($step = 5; $step >= 0; $step--) { - $stepKey = (string) $step; + $stepKey = (string)$step; if (isset($state['step_status'][$stepKey]) && $state['step_status'][$stepKey] === 'completed') { return $step; } diff --git a/public/install/includes/progress-emitter.php b/public/install/includes/progress-emitter.php index aff29289..c1dc5a4d 100644 --- a/public/install/includes/progress-emitter.php +++ b/public/install/includes/progress-emitter.php @@ -6,8 +6,11 @@ * SSE/폴링 듀얼 모드 지원을 위한 진행 상황 보고 인터페이스. * - SseEmitter: 실시간 SSE 스트리밍 + addLog * - NullEmitter: addLog만 수행 (폴링 모드, state.json 기반 조회) + * + * @package G7\Installer */ -if (! defined('BASE_PATH')) { + +if (!defined('BASE_PATH')) { define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); } @@ -16,8 +19,8 @@ interface ProgressEmitter /** * 임의 이벤트 송출 (SSE 이벤트 타입과 데이터 배열). * - * @param string $event 이벤트 타입 (connected, task_start, task_complete, log, completed, error, aborted, rollback_failed) - * @param array $data 이벤트 데이터 + * @param string $event 이벤트 타입 (connected, task_start, task_complete, log, completed, error, aborted, rollback_failed) + * @param array $data 이벤트 데이터 */ public function emit(string $event, array $data): void; @@ -54,22 +57,8 @@ class SseEmitter implements ProgressEmitter public function emit(string $event, array $data): void { - // SSE data 라인은 항상 유효 JSON 이어야 한다 (gnuboard/g7#62). - // composer install 등 외부 프로세스 로그에 invalid UTF-8 바이트가 섞이면 - // json_encode 가 false 를 반환해 "data: \n\n" (빈 값) 이 송출되고, - // 프론트 EventSource 리스너의 JSON.parse(e.data) 가 throw 되어 로그 이벤트가 유실된다. - // 폴링 응답(state-management)과 동일하게 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 - // false 를 가드한다. (task-runner 의 스트림 scrub 과 이중 안전망) - $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); - if ($payload === false) { - $payload = json_encode( - ['message' => '(log line dropped: encoding error)'], - JSON_UNESCAPED_UNICODE - ); - } - echo "event: {$event}\n"; - echo 'data: '.$payload."\n\n"; + echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n"; if (ob_get_level()) { @ob_flush(); } @@ -206,10 +195,9 @@ class NullEmitter implements ProgressEmitter */ function getProgressEmitter(): ProgressEmitter { - if (! isset($GLOBALS['g7_progress_emitter']) || ! ($GLOBALS['g7_progress_emitter'] instanceof ProgressEmitter)) { - $GLOBALS['g7_progress_emitter'] = new SseEmitter; + if (!isset($GLOBALS['g7_progress_emitter']) || !($GLOBALS['g7_progress_emitter'] instanceof ProgressEmitter)) { + $GLOBALS['g7_progress_emitter'] = new SseEmitter(); } - return $GLOBALS['g7_progress_emitter']; } diff --git a/public/install/includes/request-handler.php b/public/install/includes/request-handler.php index 2e7394d0..c2395a10 100644 --- a/public/install/includes/request-handler.php +++ b/public/install/includes/request-handler.php @@ -4,13 +4,15 @@ * 그누보드7 웹 인스톨러 요청 처리 핸들러 * * 모든 POST 요청 및 비즈니스 로직을 처리합니다. + * + * @package G7\Installer */ /** * 설치 흐름 검증 및 리다이렉트 * - * @param int $currentStep 현재 단계 - * @param array $state 설치 상태 + * @param int $currentStep 현재 단계 + * @param array $state 설치 상태 */ function validateInstallationFlow(int $currentStep, array $state): void { @@ -40,14 +42,14 @@ function validateInstallationFlow(int $currentStep, array $state): void /** * 잘못된 단계 접근 시 알림 표시 * - * @param int $correctStep 올바른 단계 번호 + * @param int $correctStep 올바른 단계 번호 */ function showInvalidStepAccessAlert(int $correctStep): void { global $translations; // 번역이 로드되지 않은 경우 로드 - if (! isset($translations)) { + if (!isset($translations)) { $translations = loadTranslations(getCurrentLanguage()); } @@ -57,7 +59,7 @@ function showInvalidStepAccessAlert(int $correctStep): void showAlertAndRedirect( lang('error_invalid_step_access'), lang('error_invalid_step_access_message'), - INSTALLER_BASE_URL.'/' + INSTALLER_BASE_URL . '/' ); } @@ -71,7 +73,7 @@ function handleStep0Post(): void } // 다음 버튼 클릭 시 (언어 선택 변경이 아닌 경우) - if (! isset($_POST['language_change_only'])) { + if (!isset($_POST['language_change_only'])) { unset($_SESSION['license_agreed']); updateStepStatus(0, 1); redirectToStep(1); @@ -83,14 +85,14 @@ function handleStep0Post(): void /** * Step 1 POST 처리 (라이선스 동의) * - * @param string $currentLang 현재 언어 - * @param string|null &$error 에러 메시지 (참조) + * @param string $currentLang 현재 언어 + * @param string|null &$error 에러 메시지 (참조) */ function handleStep1Post(string $currentLang, ?string &$error = null): void { $translations = loadTranslations($currentLang); - if (! isset($_POST['agree']) || $_POST['agree'] !== '1') { + if (!isset($_POST['agree']) || $_POST['agree'] !== '1') { $error = lang('must_agree'); } else { $_SESSION['license_agreed'] = true; @@ -113,9 +115,9 @@ function handleStep2Post(): void /** * Step 3 POST 처리 (데이터베이스 및 사이트 설정) * - * @param string $currentLang 현재 언어 - * @param array &$formData 폼 데이터 (참조) - * @param array &$errors 에러 배열 (참조) + * @param string $currentLang 현재 언어 + * @param array &$formData 폼 데이터 (참조) + * @param array &$errors 에러 배열 (참조) */ function handleStep3Post(string $currentLang, array &$formData, array &$errors): void { @@ -133,7 +135,7 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors): } // Read DB 검증 (사용하는 경우) - if (! empty($formData['use_read_db'])) { + if (!empty($formData['use_read_db'])) { if (empty($formData['db_read_host'])) { $errors['db_read_host'] = lang('error_db_host_required'); } @@ -143,10 +145,10 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors): } // 관리자 정보 검증 - if (empty($formData['admin_email']) || ! filter_var($formData['admin_email'], FILTER_VALIDATE_EMAIL)) { + if (empty($formData['admin_email']) || !filter_var($formData['admin_email'], FILTER_VALIDATE_EMAIL)) { $errors['admin_email'] = lang('error_admin_email_invalid'); } - if (! array_key_exists($formData['admin_language'] ?? '', SUPPORTED_LANGUAGES)) { + if (!array_key_exists($formData['admin_language'] ?? '', SUPPORTED_LANGUAGES)) { $errors['admin_language'] = lang('error_admin_language_invalid'); } if (empty($formData['admin_password']) || strlen($formData['admin_password']) < 8) { @@ -174,19 +176,19 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors): // 절대 경로 변환 $absolutePath = str_starts_with($corePendingPath, '/') ? $corePendingPath - : BASE_PATH.'/'.$corePendingPath; + : BASE_PATH . '/' . $corePendingPath; - if (file_exists($absolutePath) && ! is_dir($absolutePath)) { + if (file_exists($absolutePath) && !is_dir($absolutePath)) { $errors['core_update_pending_path'] = lang('error_core_pending_not_directory'); } } // DB 테스트 완료 확인 - if (empty($errors) && ! isset($_SESSION['db_write_tested'])) { + if (empty($errors) && !isset($_SESSION['db_write_tested'])) { $errors['db_test'] = lang('error_db_not_tested'); } - if (empty($errors) && ! empty($formData['use_read_db']) && ! isset($_SESSION['db_read_tested'])) { + if (empty($errors) && !empty($formData['use_read_db']) && !isset($_SESSION['db_read_tested'])) { $errors['db_test'] = lang('error_db_not_tested'); } @@ -198,21 +200,9 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors): $safeFormData = $formData; unset($safeFormData['db_write_password'], $safeFormData['db_read_password']); - // Read DB 미사용 시 read 필드 잔존값을 제거한다 (이슈 #63 2단계 방어). - // use_read_db=false 인데 db_read_host 등이 남아 있으면 하류(installer-runtime.php) - // 로 유입되어 잘못된 read 커넥션을 만들 수 있으므로 원천 차단한다. - if (empty($safeFormData['use_read_db'])) { - unset( - $safeFormData['db_read_host'], - $safeFormData['db_read_port'], - $safeFormData['db_read_database'], - $safeFormData['db_read_username'] - ); - } - updateStepStatus(3, 4, [ 'config' => $safeFormData, - 'installation_status' => 'ready', + 'installation_status' => 'ready' ]); redirectToStep(4); @@ -222,21 +212,21 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors): /** * POST 요청 라우팅 * - * @param int $currentStep 현재 단계 - * @param string $currentLang 현재 언어 - * @param array &$formData 폼 데이터 (참조) - * @param array &$errors 에러 배열 (참조) - * @param string|null &$error 에러 메시지 (참조) + * @param int $currentStep 현재 단계 + * @param string $currentLang 현재 언어 + * @param array &$formData 폼 데이터 (참조) + * @param array &$errors 에러 배열 (참조) + * @param string|null &$error 에러 메시지 (참조) */ function handlePostRequest(int $currentStep, string $currentLang, array &$formData = [], array &$errors = [], ?string &$error = null): void { // 단계 이동 처리 (go_to_step POST 파라미터) if (isset($_POST['go_to_step'])) { - $step = (int) $_POST['go_to_step']; + $step = (int)$_POST['go_to_step']; if ($step >= 0 && $step <= 5) { $_SESSION['installer_current_step'] = $step; } - header('Location: '.INSTALLER_BASE_URL.'/'); + header('Location: ' . INSTALLER_BASE_URL . '/'); exit; } @@ -248,7 +238,7 @@ function handlePostRequest(int $currentStep, string $currentLang, array &$formDa } // Step별 POST 처리 - match ($currentStep) { + match($currentStep) { 0 => handleStep0Post(), 1 => handleStep1Post($currentLang, $error), 2 => handleStep2Post(), diff --git a/public/install/includes/task-runner.php b/public/install/includes/task-runner.php index 2caa6948..75a67366 100644 --- a/public/install/includes/task-runner.php +++ b/public/install/includes/task-runner.php @@ -6,26 +6,28 @@ * 설치 task 정의와 실행 로직을 모드와 무관하게 제공합니다. * 진입점(install-worker.php SSE 모드, install-process.php 폴링 모드)이 * ProgressEmitter를 등록한 뒤 runInstallationTasks()를 호출합니다. + * + * @package G7\Installer */ -require_once __DIR__.'/config.php'; -require_once __DIR__.'/functions.php'; -require_once __DIR__.'/installer-state.php'; -require_once __DIR__.'/installer-runtime.php'; -require_once __DIR__.'/progress-emitter.php'; -require_once __DIR__.'/../api/rollback-functions.php'; +require_once __DIR__ . '/config.php'; +require_once __DIR__ . '/functions.php'; +require_once __DIR__ . '/installer-state.php'; +require_once __DIR__ . '/installer-runtime.php'; +require_once __DIR__ . '/progress-emitter.php'; +require_once __DIR__ . '/../api/rollback-functions.php'; // ============================================================================ // Emitter 호환 래퍼 — 기존 task 함수의 sendSSEEvent() 호출을 그대로 유지하면서 // 내부적으로 현재 등록된 ProgressEmitter로 delegate합니다. // ============================================================================ -if (! function_exists('sendSSEEvent')) { +if (!function_exists('sendSSEEvent')) { /** * 이벤트 송출 (SSE 모드/폴링 모드 공통). * - * @param string $event 이벤트 타입 - * @param array $data 이벤트 데이터 + * @param string $event 이벤트 타입 + * @param array $data 이벤트 데이터 */ function sendSSEEvent(string $event, array $data): void { @@ -33,17 +35,17 @@ if (! function_exists('sendSSEEvent')) { } } -if (! function_exists('sendRollbackOutputSSE')) { +if (!function_exists('sendRollbackOutputSSE')) { /** * 롤백 실행 결과를 로그 이벤트로 출력합니다. * - * @param array $rollbackResult rollbackDbMigrate() 등의 반환값 + * @param array $rollbackResult rollbackDbMigrate() 등의 반환값 */ function sendRollbackOutputSSE(array $rollbackResult): void { - if (! empty($rollbackResult['output']) && is_array($rollbackResult['output'])) { + if (!empty($rollbackResult['output']) && is_array($rollbackResult['output'])) { foreach ($rollbackResult['output'] as $line) { - if (! empty(trim($line))) { + if (!empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } @@ -51,7 +53,7 @@ if (! function_exists('sendRollbackOutputSSE')) { } } -if (! function_exists('checkAbortStatusSSE')) { +if (!function_exists('checkAbortStatusSSE')) { /** * 설치 중단 여부 확인 (SSE/폴링 공용). * @@ -66,12 +68,12 @@ if (! function_exists('checkAbortStatusSSE')) { $state = getInstallationState(); $currentTask = $state['current_task'] ?? null; - if ($currentTask && ! in_array($currentTask, $state['completed_tasks'] ?? [])) { + if ($currentTask && !in_array($currentTask, $state['completed_tasks'] ?? [])) { addLog(lang('abort_rollback_start', ['task' => $currentTask])); $rollbackResult = rollbackTask($currentTask, $state); - if (! empty($rollbackResult['output']) && is_array($rollbackResult['output'])) { + if (!empty($rollbackResult['output']) && is_array($rollbackResult['output'])) { $outputStr = implode("\n", $rollbackResult['output']); addLog($outputStr); } @@ -99,7 +101,7 @@ if (! function_exists('checkAbortStatusSSE')) { $state['abort_reason'] = 'Connection aborted unexpectedly'; $state['aborted_at'] = date('Y-m-d H:i:s'); - if (isset($rollbackResult) && ! $rollbackResult['success']) { + if (isset($rollbackResult) && !$rollbackResult['success']) { $state['rollback_failure'] = [ 'task' => $currentTask, 'message' => $rollbackResult['message'] ?? null, @@ -109,7 +111,6 @@ if (! function_exists('checkAbortStatusSSE')) { } saveInstallationState($state); - return true; } @@ -118,7 +119,6 @@ if (! function_exists('checkAbortStatusSSE')) { if (isset($state['installation_status']) && $state['installation_status'] === 'aborted') { $currentTask = $state['current_task'] ?? 'unknown'; addLog(lang('abort_by_user', ['task' => $currentTask])); - return true; } @@ -130,16 +130,15 @@ if (! function_exists('checkAbortStatusSSE')) { // Task 함수 정의 (install-worker.php에서 이관됨 — 동작 동일) // ============================================================================ -if (! function_exists('getPhpBinary')) { +if (!function_exists('getPhpBinary')) { function getPhpBinary(): string { $state = getInstallationState(); - return $state['config']['php_binary'] ?? 'php' ?: 'php'; } } -if (! function_exists('isInstallerExecutablePath')) { +if (!function_exists('isInstallerExecutablePath')) { /** * 인스톨러가 exec 에 전달하기 안전한 단일 토큰 경로인지 검증한다. * @@ -158,12 +157,11 @@ if (! function_exists('isInstallerExecutablePath')) { if (preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path)) { return false; } - return true; } } -if (! function_exists('splitInstallerPhpComposerTokens')) { +if (!function_exists('splitInstallerPhpComposerTokens')) { /** * 공백 분리 입력을 "PHP 인터프리터 절대경로 + Composer 바이너리 절대경로" 두 토큰으로 분해. * @@ -171,16 +169,16 @@ if (! function_exists('splitInstallerPhpComposerTokens')) { * 운영 의도를 지원한다. 두 토큰 모두 isInstallerExecutablePath 통과해야 * 정상 입력으로 간주. * - * @return array{php: string, composer: string}|null 분해 실패 시 null + * @return array{php: string, composer: string}|null 분해 실패 시 null */ function splitInstallerPhpComposerTokens(string $path): ?array { - if (! str_contains($path, ' ')) { + if (!str_contains($path, ' ')) { return null; } $tokens = preg_split('/\s+/', trim($path), 2); - if (! is_array($tokens) || count($tokens) !== 2) { + if (!is_array($tokens) || count($tokens) !== 2) { return null; } @@ -193,7 +191,7 @@ if (! function_exists('splitInstallerPhpComposerTokens')) { } } -if (! function_exists('getComposerCommand')) { +if (!function_exists('getComposerCommand')) { function getComposerCommand(): string { $state = getInstallationState(); @@ -208,18 +206,17 @@ if (! function_exists('getComposerCommand')) { if (str_contains($composerBinary, ' ')) { $tokens = splitInstallerPhpComposerTokens($composerBinary); if ($tokens === null - || ! isInstallerExecutablePath($tokens['php']) - || ! isInstallerExecutablePath($tokens['composer']) + || !isInstallerExecutablePath($tokens['php']) + || !isInstallerExecutablePath($tokens['composer']) ) { return 'composer'; } - - return escapeshellarg($tokens['php']).' '.escapeshellarg($tokens['composer']); + return escapeshellarg($tokens['php']) . ' ' . escapeshellarg($tokens['composer']); } // 검증 실패 시 시스템 기본 'composer' 로 폴백 — 설치 흐름은 유지하되 // 사용자 입력이 셸 명령으로 흘러가지 않도록 차단. - if (! isInstallerExecutablePath($composerBinary)) { + if (!isInstallerExecutablePath($composerBinary)) { return 'composer'; } @@ -228,15 +225,14 @@ if (! function_exists('getComposerCommand')) { $phpArg = ($phpBinary !== 'php' && isInstallerExecutablePath($phpBinary)) ? escapeshellarg($phpBinary) : escapeshellarg('php'); - - return $phpArg.' '.escapeshellarg($composerBinary); + return $phpArg . ' ' . escapeshellarg($composerBinary); } return escapeshellarg($composerBinary); } } -if (! function_exists('getComposerCommandForDisplay')) { +if (!function_exists('getComposerCommandForDisplay')) { function getComposerCommandForDisplay(): string { $state = getInstallationState(); @@ -250,35 +246,34 @@ if (! function_exists('getComposerCommandForDisplay')) { if (str_contains($composerBinary, ' ')) { $tokens = splitInstallerPhpComposerTokens($composerBinary); if ($tokens === null - || ! isInstallerExecutablePath($tokens['php']) - || ! isInstallerExecutablePath($tokens['composer']) + || !isInstallerExecutablePath($tokens['php']) + || !isInstallerExecutablePath($tokens['composer']) ) { return 'composer'; } - - return $tokens['php'].' '.$tokens['composer']; + return $tokens['php'] . ' ' . $tokens['composer']; } - if (! isInstallerExecutablePath($composerBinary)) { + if (!isInstallerExecutablePath($composerBinary)) { return 'composer'; } if (str_ends_with($composerBinary, '.phar')) { - return getPhpBinary().' '.$composerBinary; + return getPhpBinary() . ' ' . $composerBinary; } return $composerBinary; } } -if (! function_exists('vendorModeOptionFromState')) { +if (!function_exists('vendorModeOptionFromState')) { /** * 인스톨러 state 의 vendor_mode 를 artisan 옵션 문자열로 변환합니다. * * 사용자가 Step 3 에서 선택한 vendor_mode 를 module:install/plugin:install * 등 확장 설치 커맨드로 일관되게 전파하기 위함. * - * @return string ' --vendor-mode=bundled' 같은 문자열 (선행 공백 포함). 미설정 시 빈 문자열. + * @return string ' --vendor-mode=bundled' 같은 문자열 (선행 공백 포함). 미설정 시 빈 문자열. */ function vendorModeOptionFromState(): string { @@ -293,7 +288,7 @@ if (! function_exists('vendorModeOptionFromState')) { } } -if (! function_exists('checkComposerSSE')) { +if (!function_exists('checkComposerSSE')) { /** * Composer 가용성 확인. * @@ -328,24 +323,24 @@ if (! function_exists('checkComposerSSE')) { sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); - $composerHome = BASE_PATH.'/storage/composer'; - if (! is_dir($composerHome)) { + $composerHome = BASE_PATH . '/storage/composer'; + if (!is_dir($composerHome)) { @mkdir($composerHome, 0755, true); } - putenv('COMPOSER_HOME='.$composerHome); - putenv('HOME='.$composerHome); + putenv('COMPOSER_HOME=' . $composerHome); + putenv('HOME=' . $composerHome); applyInstallerComposerEnvVars(); $output = []; $returnCode = 0; $composerCmd = getComposerCommand(); - exec($composerCmd.' --version 2>&1', $output, $returnCode); + exec($composerCmd . ' --version 2>&1', $output, $returnCode); if ($returnCode !== 0) { $errorMessage = implode("\n", $output); // auto 모드: composer 미설치 시 번들 폴백 가능하면 비치명적 처리 - if ($vendorMode === 'auto' && file_exists(BASE_PATH.'/vendor-bundle.zip')) { + if ($vendorMode === 'auto' && file_exists(BASE_PATH . '/vendor-bundle.zip')) { sendSSEEvent('log', ['message' => lang('log_composer_check_auto_fallback')]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('composer_check'); @@ -356,7 +351,6 @@ if (! function_exists('checkComposerSSE')) { sendSSEEvent('log', ['message' => lang('log_error_occurred', ['error' => $errorMessage])]); logInstallationError(lang('error_composer_not_installed')); - return [ 'success' => false, 'message' => lang('error_composer_not_installed'), @@ -379,13 +373,13 @@ if (! function_exists('checkComposerSSE')) { } } -if (! function_exists('installVendorBundleSSE')) { +if (!function_exists('installVendorBundleSSE')) { /** * vendor-bundle.zip 을 추출하여 vendor/ 를 구성합니다 (bundled 모드용). */ function installVendorBundleSSE(): array { - require_once __DIR__.'/vendor-bundle-installer.php'; + require_once __DIR__ . '/vendor-bundle-installer.php'; if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; @@ -397,11 +391,10 @@ if (! function_exists('installVendorBundleSSE')) { sendSSEEvent('log', ['message' => 'Vendor 번들 추출을 시작합니다...']); $integrity = verifyVendorBundle(BASE_PATH); - if (! $integrity['valid']) { + if (!$integrity['valid']) { $errorList = implode(', ', $integrity['errors']); sendSSEEvent('log', ['message' => "번들 무결성 검증 실패: {$errorList}"]); logInstallationError("vendor-bundle.zip 무결성 검증 실패: {$errorList}"); - return [ 'success' => false, 'message' => "vendor-bundle.zip 무결성 검증 실패: {$errorList}", @@ -414,11 +407,10 @@ if (! function_exists('installVendorBundleSSE')) { $result = extractVendorBundle(BASE_PATH, BASE_PATH); - if (! $result['success']) { + if (!$result['success']) { $error = $result['error'] ?? 'unknown'; sendSSEEvent('log', ['message' => "vendor 번들 추출 실패: {$error}"]); logInstallationError("vendor 번들 추출 실패: {$error}"); - return [ 'success' => false, 'message' => "vendor 번들 추출 실패: {$error}", @@ -443,11 +435,11 @@ if (! function_exists('installVendorBundleSSE')) { } } -if (! function_exists('installComposerDependenciesSSE')) { +if (!function_exists('installComposerDependenciesSSE')) { function installComposerDependenciesSSE(): array { // Laravel compiled cache 정리 헬퍼(clearLaravelCompiledCache)가 정의된 shim - require_once __DIR__.'/vendor-bundle-installer.php'; + require_once __DIR__ . '/vendor-bundle-installer.php'; // Vendor 모드에 따라 분기: bundled → vendor-bundle.zip 추출 $state = getInstallationState(); @@ -462,11 +454,10 @@ if (! function_exists('installComposerDependenciesSSE')) { $composerBinary = $state['config']['composer_binary'] ?? ''; $phpBinary = $state['config']['php_binary'] ?? 'php'; - if (! canExecuteComposerForInstall($composerBinary, $phpBinary)) { + if (!canExecuteComposerForInstall($composerBinary, $phpBinary)) { // composer 사용 불가 → 번들 zip 존재 여부 확인 후 폴백 - if (file_exists(BASE_PATH.'/vendor-bundle.zip')) { + if (file_exists(BASE_PATH . '/vendor-bundle.zip')) { sendSSEEvent('log', ['message' => 'Composer 실행 불가 환경 감지 → 번들 vendor 모드로 자동 전환']); - return installVendorBundleSSE(); } } @@ -482,8 +473,8 @@ if (! function_exists('installComposerDependenciesSSE')) { sendSSEEvent('task_start', ['task' => 'composer_install', 'name' => $taskName]); sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); - $vendorExists = is_dir(BASE_PATH.'/vendor') && file_exists(BASE_PATH.'/vendor/autoload.php'); - $lockExists = file_exists(BASE_PATH.'/composer.lock'); + $vendorExists = is_dir(BASE_PATH . '/vendor') && file_exists(BASE_PATH . '/vendor/autoload.php'); + $lockExists = file_exists(BASE_PATH . '/composer.lock'); if ($vendorExists && $lockExists) { sendSSEEvent('log', ['message' => lang('log_composer_already_installed')]); @@ -492,27 +483,26 @@ if (! function_exists('installComposerDependenciesSSE')) { markTaskCompleted('composer_install'); sendSSEEvent('task_complete', ['task' => 'composer_install', 'message' => lang('log_composer_already_installed')]); - return ['success' => true]; } - if ($vendorExists && ! $lockExists) { + if ($vendorExists && !$lockExists) { sendSSEEvent('log', ['message' => lang('log_composer_vendor_without_lock')]); sendSSEEvent('log', ['message' => lang('log_composer_removing_vendor')]); - $deleted = deleteDirectory(BASE_PATH.'/vendor'); - if (! $deleted) { + $deleted = deleteDirectory(BASE_PATH . '/vendor'); + if (!$deleted) { sendSSEEvent('log', ['message' => lang('log_composer_vendor_delete_failed')]); } else { sendSSEEvent('log', ['message' => lang('log_composer_vendor_deleted')]); } } - if (! $vendorExists && $lockExists) { + if (!$vendorExists && $lockExists) { sendSSEEvent('log', ['message' => lang('log_composer_installing_from_lock')]); } - if (! $vendorExists && ! $lockExists) { + if (!$vendorExists && !$lockExists) { sendSSEEvent('log', ['message' => lang('log_composer_fresh_install')]); } @@ -526,8 +516,8 @@ if (! function_exists('installComposerDependenciesSSE')) { chdir(BASE_PATH); - $composerHome = BASE_PATH.'/storage/composer'; - if (! is_dir($composerHome)) { + $composerHome = BASE_PATH . '/storage/composer'; + if (!is_dir($composerHome)) { @mkdir($composerHome, 0755, true); } @@ -545,9 +535,9 @@ if (! function_exists('installComposerDependenciesSSE')) { $env = array_merge($env, buildInstallerComposerEnv()); if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { - if (! isset($env['TEMP']) || ! is_dir($env['TEMP']) || ! is_writable($env['TEMP'])) { - $tempDir = BASE_PATH.'/storage/temp'; - if (! is_dir($tempDir)) { + if (!isset($env['TEMP']) || !is_dir($env['TEMP']) || !is_writable($env['TEMP'])) { + $tempDir = BASE_PATH . '/storage/temp'; + if (!is_dir($tempDir)) { @mkdir($tempDir, 0755, true); } $env['TEMP'] = $tempDir; @@ -563,16 +553,15 @@ if (! function_exists('installComposerDependenciesSSE')) { $composerCmd = getComposerCommand(); $process = proc_open( - $composerCmd.' install --no-interaction --no-dev --optimize-autoloader --no-ansi 2>&1', + $composerCmd . ' install --no-interaction --no-dev --optimize-autoloader --no-ansi 2>&1', $descriptorspec, $pipes, BASE_PATH, $env ); - if (! is_resource($process)) { + if (!is_resource($process)) { logInstallationError(lang('error_composer_install_failed')); - return [ 'success' => false, 'message' => lang('error_composer_install_failed'), @@ -584,26 +573,17 @@ if (! function_exists('installComposerDependenciesSSE')) { fclose($pipes[0]); stream_set_blocking($pipes[1], false); - while (! feof($pipes[1])) { + while (!feof($pipes[1])) { if (checkAbortStatusSSE()) { proc_terminate($process); proc_close($process); - return ['success' => false, 'aborted' => true]; } $line = fgets($pipes[1]); if ($line !== false) { $line = trim($line); - // composer stdout 이 시스템 코드페이지(Windows CP949 등)로 출력하거나 - // 진행바(\r 갱신)가 non-blocking 읽기와 겹쳐 임의 바이트 경계에서 잘리면 - // invalid UTF-8 바이트가 섞인다. SSE 응답(progress-emitter 의 json_encode)과 - // 폴링 응답(state-management) 양쪽이 무력화되지 않도록 로그 전송 전 정규화한다. - // (gnuboard/g7#62 — addLog 최종 방어와 이중 안전망) - if ($line !== '' && ! mb_check_encoding($line, 'UTF-8')) { - $line = mb_scrub($line, 'UTF-8'); - } - if (! empty($line)) { + if (!empty($line)) { sendSSEEvent('log', ['message' => $line]); } } @@ -616,7 +596,6 @@ if (! function_exists('installComposerDependenciesSSE')) { if ($returnCode !== 0) { logInstallationError(lang('error_composer_install_failed')); - return [ 'success' => false, 'message' => lang('error_composer_install_failed'), @@ -635,7 +614,7 @@ if (! function_exists('installComposerDependenciesSSE')) { } } -if (! function_exists('updateEnvFileSSE')) { +if (!function_exists('updateEnvFileSSE')) { /** * 동적 설정(DB 자격증명) 을 storage/installer/runtime.php 에 작성한다. * @@ -661,10 +640,9 @@ if (! function_exists('updateEnvFileSSE')) { sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); // .env.example 존재 여부만 확인 — finalize 단계에서 generateEnvContent() 가 사용 - $envExamplePath = BASE_PATH.'/.env.example'; - if (! file_exists($envExamplePath)) { + $envExamplePath = BASE_PATH . '/.env.example'; + if (!file_exists($envExamplePath)) { logInstallationError(lang('error_env_example_not_found')); - return [ 'success' => false, 'message' => lang('error_env_example_not_found'), @@ -677,9 +655,8 @@ if (! function_exists('updateEnvFileSSE')) { $runtime = buildInstallerRuntimeFromState($stateConfig); - if (! writeInstallerRuntime($runtime)) { + if (!writeInstallerRuntime($runtime)) { logInstallationError(lang('error_env_write_failed', ['path' => INSTALLER_RUNTIME_PATH])); - return [ 'success' => false, 'message' => lang('error_env_write_failed', ['path' => INSTALLER_RUNTIME_PATH]), @@ -698,7 +675,7 @@ if (! function_exists('updateEnvFileSSE')) { } } -if (! function_exists('generateApplicationKeySSE')) { +if (!function_exists('generateApplicationKeySSE')) { /** * APP_KEY 를 pure PHP 로 생성하여 runtime.php 에 기록한다. * @@ -717,7 +694,7 @@ if (! function_exists('generateApplicationKeySSE')) { } // 재시도 경로 방어 — composer_install 재진입 시 stale bootstrap/cache 정리 - require_once __DIR__.'/vendor-bundle-installer.php'; + require_once __DIR__ . '/vendor-bundle-installer.php'; clearLaravelCompiledCache(BASE_PATH); $taskName = lang('task_key_generate'); @@ -748,9 +725,8 @@ if (! function_exists('generateApplicationKeySSE')) { $runtime['app']['key'] = $key; $runtime['created_at'] = $runtime['created_at'] ?? date('c'); - if (! writeInstallerRuntime($runtime)) { + if (!writeInstallerRuntime($runtime)) { logInstallationError(lang('error_key_generate_failed')); - return [ 'success' => false, 'message' => lang('error_key_generate_failed'), @@ -769,7 +745,7 @@ if (! function_exists('generateApplicationKeySSE')) { } } -if (! function_exists('dependencyPrecheckSSE')) { +if (!function_exists('dependencyPrecheckSSE')) { /** * 의존성 사전 검증 (백엔드 안전망). * @@ -801,13 +777,13 @@ if (! function_exists('dependencyPrecheckSSE')) { // 확장 매니페스트 읽기 (모듈/플러그인 — 템플릿은 dependencies 사용 빈도 낮음) $scanTargets = [ - 'modules' => BASE_PATH.'/modules/_bundled', - 'plugins' => BASE_PATH.'/plugins/_bundled', - 'templates' => BASE_PATH.'/templates/_bundled', + 'modules' => BASE_PATH . '/modules/_bundled', + 'plugins' => BASE_PATH . '/plugins/_bundled', + 'templates' => BASE_PATH . '/templates/_bundled', ]; foreach ($scanTargets as $type => $baseDir) { - if (! is_dir($baseDir)) { + if (!is_dir($baseDir)) { continue; } $dirs = scandir($baseDir) ?: []; @@ -823,23 +799,23 @@ if (! function_exists('dependencyPrecheckSSE')) { } elseif ($type === 'templates' && file_exists("{$baseDir}/{$dir}/template.json")) { $manifestFile = "{$baseDir}/{$dir}/template.json"; } - if (! $manifestFile) { + if (!$manifestFile) { continue; } $data = json_decode((string) @file_get_contents($manifestFile), true); - if (! is_array($data)) { + if (!is_array($data)) { continue; } $identifier = $data['identifier'] ?? $dir; // 선택된 확장만 검증 대상 - if (! isset($selectedIdSet[$identifier])) { + if (!isset($selectedIdSet[$identifier])) { continue; } $deps = $data['dependencies'] ?? []; - if (! is_array($deps)) { + if (!is_array($deps)) { continue; } @@ -855,7 +831,7 @@ if (! function_exists('dependencyPrecheckSSE')) { $check[] = is_int($depId) ? $_ : $depId; } } - if (! isset($deps['modules']) && ! isset($deps['plugins'])) { + if (!isset($deps['modules']) && !isset($deps['plugins'])) { foreach ($deps as $depId) { if (is_string($depId)) { $check[] = $depId; @@ -864,20 +840,19 @@ if (! function_exists('dependencyPrecheckSSE')) { } foreach ($check as $depId) { - if (! isset($selectedIdSet[$depId])) { + if (!isset($selectedIdSet[$depId])) { $missing[] = "{$identifier} → {$depId}"; } } } } - if (! empty($missing)) { + if (!empty($missing)) { $errorMessage = lang('dependency_precheck_failed'); foreach ($missing as $line) { - sendSSEEvent('log', ['message' => ' - '.$line]); + sendSSEEvent('log', ['message' => ' - ' . $line]); } logInstallationError($errorMessage); - return [ 'success' => false, 'message' => $errorMessage, @@ -896,7 +871,7 @@ if (! function_exists('dependencyPrecheckSSE')) { } } -if (! function_exists('cleanupExistingTablesSSE')) { +if (!function_exists('cleanupExistingTablesSSE')) { /** * 기존 DB 테이블 정리. * @@ -927,7 +902,6 @@ if (! function_exists('cleanupExistingTablesSSE')) { sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('db_cleanup'); sendSSEEvent('task_complete', ['task' => 'db_cleanup', 'message' => lang('log_db_cleanup_skipped')]); - return ['success' => true]; } @@ -952,7 +926,7 @@ if (! function_exists('cleanupExistingTablesSSE')) { $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); foreach ($tables as $table) { - $quoted = '`'.str_replace('`', '``', $table).'`'; + $quoted = '`' . str_replace('`', '``', $table) . '`'; $pdo->exec("DROP TABLE IF EXISTS {$quoted}"); sendSSEEvent('log', ['message' => " - DROP TABLE {$table}"]); } @@ -970,7 +944,6 @@ if (! function_exists('cleanupExistingTablesSSE')) { return ['success' => true]; } catch (Exception $e) { logInstallationError(lang('error_db_cleanup_failed'), $e); - return [ 'success' => false, 'message' => lang('error_db_cleanup_failed'), @@ -981,7 +954,7 @@ if (! function_exists('cleanupExistingTablesSSE')) { } } -if (! function_exists('runDatabaseMigrationSSE')) { +if (!function_exists('runDatabaseMigrationSSE')) { function runDatabaseMigrationSSE(): array { return executeDbCommandSSE( @@ -994,7 +967,7 @@ if (! function_exists('runDatabaseMigrationSSE')) { } } -if (! function_exists('runDatabaseSeedingSSE')) { +if (!function_exists('runDatabaseSeedingSSE')) { function runDatabaseSeedingSSE(): array { $state = getInstallationState(); @@ -1004,10 +977,10 @@ if (! function_exists('runDatabaseSeedingSSE')) { return ['success' => false, 'error' => '관리자 이메일과 비밀번호가 설정되지 않았습니다.']; } - putenv('INSTALLER_ADMIN_NAME='.($config['admin_name'] ?? 'Administrator')); - putenv('INSTALLER_ADMIN_EMAIL='.$config['admin_email']); - putenv('INSTALLER_ADMIN_PASSWORD='.$config['admin_password']); - putenv('INSTALLER_ADMIN_LANGUAGE='.($config['admin_language'] ?? $state['g7_locale'] ?? 'ko')); + putenv('INSTALLER_ADMIN_NAME=' . ($config['admin_name'] ?? 'Administrator')); + putenv('INSTALLER_ADMIN_EMAIL=' . $config['admin_email']); + putenv('INSTALLER_ADMIN_PASSWORD=' . $config['admin_password']); + putenv('INSTALLER_ADMIN_LANGUAGE=' . ($config['admin_language'] ?? $state['g7_locale'] ?? 'ko')); return executeDbCommandSSE( artisanCommand: 'db:seed --force', @@ -1019,11 +992,10 @@ if (! function_exists('runDatabaseSeedingSSE')) { } } -if (! function_exists('getSelectedExtensions')) { +if (!function_exists('getSelectedExtensions')) { function getSelectedExtensions(): array { $state = getInstallationState(); - return $state['selected_extensions'] ?? [ 'admin_templates' => [], 'user_templates' => [], @@ -1034,7 +1006,7 @@ if (! function_exists('getSelectedExtensions')) { } } -if (! function_exists('reserveCommandOutputFile')) { +if (!function_exists('reserveCommandOutputFile')) { /** * artisan 명령 stdout/stderr redirect 용 임시 로그 파일 경로 예약. * @@ -1055,7 +1027,7 @@ if (! function_exists('reserveCommandOutputFile')) { ]; foreach ($candidates as $dir) { - if (! is_dir($dir)) { + if (!is_dir($dir)) { @mkdir($dir, 0755, true); } if (is_dir($dir) && is_writable($dir)) { @@ -1069,7 +1041,7 @@ if (! function_exists('reserveCommandOutputFile')) { } } -if (! function_exists('executeArtisanCommandSSE')) { +if (!function_exists('executeArtisanCommandSSE')) { function executeArtisanCommandSSE( string $artisanCommand, string $taskId, @@ -1094,7 +1066,7 @@ if (! function_exists('executeArtisanCommandSSE')) { $cmdLogFile = reserveCommandOutputFile(); $fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} > " - .escapeshellarg($cmdLogFile).' 2>&1'; + . escapeshellarg($cmdLogFile) . " 2>&1"; exec($fullCommand, $_ignored, $returnCode); if (file_exists($cmdLogFile)) { @@ -1103,7 +1075,7 @@ if (! function_exists('executeArtisanCommandSSE')) { } foreach ($output as $line) { - if (! empty(trim($line))) { + if (!empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } @@ -1111,7 +1083,6 @@ if (! function_exists('executeArtisanCommandSSE')) { if ($returnCode !== 0) { $errorMessage = implode("\n", $output); logInstallationError(lang($errorMsgKey), new Exception($errorMessage)); - return [ 'success' => false, 'message' => lang($errorMsgKey), @@ -1130,7 +1101,7 @@ if (! function_exists('executeArtisanCommandSSE')) { } } -if (! function_exists('executeDbCommandSSE')) { +if (!function_exists('executeDbCommandSSE')) { function executeDbCommandSSE( string $artisanCommand, string $taskId, @@ -1143,7 +1114,6 @@ if (! function_exists('executeDbCommandSSE')) { if ($wasAborted) { addLog(lang('db_task_abort_detected_before_start')); - return ['success' => false, 'aborted' => true]; } @@ -1160,7 +1130,7 @@ if (! function_exists('executeDbCommandSSE')) { $cmdLogFile = reserveCommandOutputFile(); $fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} > " - .escapeshellarg($cmdLogFile).' 2>&1'; + . escapeshellarg($cmdLogFile) . " 2>&1"; exec($fullCommand, $_ignored, $returnCode); @@ -1170,7 +1140,7 @@ if (! function_exists('executeDbCommandSSE')) { } foreach ($output as $line) { - if (! empty(trim($line))) { + if (!empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } @@ -1250,7 +1220,7 @@ if (! function_exists('executeDbCommandSSE')) { } } -if (! function_exists('executeExtensionCommandSSE')) { +if (!function_exists('executeExtensionCommandSSE')) { function executeExtensionCommandSSE( string $artisanCommand, string $taskId, @@ -1293,7 +1263,7 @@ if (! function_exists('executeExtensionCommandSSE')) { $cmdLogFile = reserveCommandOutputFile(); $fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} > " - .escapeshellarg($cmdLogFile).' 2>&1'; + . escapeshellarg($cmdLogFile) . " 2>&1"; exec($fullCommand, $_ignored, $returnCode); @@ -1319,7 +1289,7 @@ if (! function_exists('executeExtensionCommandSSE')) { $emitter->emit('heartbeat', []); } else { foreach ($output as $line) { - if (! empty(trim($line))) { + if (!empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } @@ -1336,10 +1306,9 @@ if (! function_exists('executeExtensionCommandSSE')) { } } - if ($returnCode !== 0 && ! $isAlreadyExists) { + if ($returnCode !== 0 && !$isAlreadyExists) { $errorMessage = $outputText; logInstallationError(lang($errorMsgKey), new Exception($errorMessage)); - return [ 'success' => false, 'message' => lang($errorMsgKey), @@ -1369,7 +1338,7 @@ if (! function_exists('executeExtensionCommandSSE')) { } } -if (! function_exists('installAdminTemplateSSE')) { +if (!function_exists('installAdminTemplateSSE')) { function installAdminTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( @@ -1383,7 +1352,7 @@ if (! function_exists('installAdminTemplateSSE')) { } } -if (! function_exists('activateAdminTemplateSSE')) { +if (!function_exists('activateAdminTemplateSSE')) { function activateAdminTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( @@ -1397,7 +1366,7 @@ if (! function_exists('activateAdminTemplateSSE')) { } } -if (! function_exists('installModuleSSE')) { +if (!function_exists('installModuleSSE')) { function installModuleSSE(string $moduleId): array { $vendorModeOpt = vendorModeOptionFromState(); @@ -1413,7 +1382,7 @@ if (! function_exists('installModuleSSE')) { } } -if (! function_exists('activateModuleSSE')) { +if (!function_exists('activateModuleSSE')) { function activateModuleSSE(string $moduleId): array { return executeExtensionCommandSSE( @@ -1427,7 +1396,7 @@ if (! function_exists('activateModuleSSE')) { } } -if (! function_exists('installPluginSSE')) { +if (!function_exists('installPluginSSE')) { function installPluginSSE(string $pluginId): array { $vendorModeOpt = vendorModeOptionFromState(); @@ -1443,7 +1412,7 @@ if (! function_exists('installPluginSSE')) { } } -if (! function_exists('activatePluginSSE')) { +if (!function_exists('activatePluginSSE')) { function activatePluginSSE(string $pluginId): array { return executeExtensionCommandSSE( @@ -1457,7 +1426,7 @@ if (! function_exists('activatePluginSSE')) { } } -if (! function_exists('installUserTemplateSSE')) { +if (!function_exists('installUserTemplateSSE')) { function installUserTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( @@ -1471,7 +1440,7 @@ if (! function_exists('installUserTemplateSSE')) { } } -if (! function_exists('activateUserTemplateSSE')) { +if (!function_exists('activateUserTemplateSSE')) { function activateUserTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( @@ -1485,7 +1454,7 @@ if (! function_exists('activateUserTemplateSSE')) { } } -if (! function_exists('installLanguagePackSSE')) { +if (!function_exists('installLanguagePackSSE')) { /** * 번들 언어팩 설치 (best-effort). * @@ -1509,7 +1478,7 @@ if (! function_exists('installLanguagePackSSE')) { } } -if (! function_exists('clearCacheSSE')) { +if (!function_exists('clearCacheSSE')) { function clearCacheSSE(): array { return executeArtisanCommandSSE( @@ -1522,29 +1491,7 @@ if (! function_exists('clearCacheSSE')) { } } -if (! function_exists('optimizeConfigCacheSSE')) { - /** - * 설치 완료 직후 config 캐시를 최초 생성한다. - * - * complete_flag 이후에 실행되어 installer_completed=true 상태에서 캐시를 만들며, - * 이 지점이 없으면 설치는 config:cache 가 꺼진(매 요청 config 재파싱) 상태로 종료된다. - * best_effort — 실패해도 설치 자체는 완료 처리한다(비캐시 부팅은 정상 동작). - * - * @return array 태스크 실행 결과 - */ - function optimizeConfigCacheSSE(): array - { - return executeArtisanCommandSSE( - artisanCommand: 'config:cache', - taskId: 'config_cache', - taskNameKey: 'task_config_cache', - successMsgKey: 'log_config_cache_success', - errorMsgKey: 'error_config_cache_failed' - ); - } -} - -if (! function_exists('createSettingsJsonSSE')) { +if (!function_exists('createSettingsJsonSSE')) { function createSettingsJsonSSE(): array { if (checkAbortStatusSSE()) { @@ -1559,22 +1506,22 @@ if (! function_exists('createSettingsJsonSSE')) { sendSSEEvent('log', ['message' => lang('log_creating_settings')]); try { - $settingsDir = BASE_PATH.'/storage/app/settings'; + $settingsDir = BASE_PATH . '/storage/app/settings'; - if (! is_dir($settingsDir)) { + if (!is_dir($settingsDir)) { mkdir($settingsDir, 0755, true); } - $defaultsFile = BASE_PATH.'/config/settings/defaults.json'; - if (! file_exists($defaultsFile)) { - throw new Exception('defaults.json file not found: '.$defaultsFile); + $defaultsFile = BASE_PATH . '/config/settings/defaults.json'; + if (!file_exists($defaultsFile)) { + throw new Exception('defaults.json file not found: ' . $defaultsFile); } $defaultsContent = file_get_contents($defaultsFile); $defaultsData = json_decode($defaultsContent, true); if (json_last_error() !== JSON_ERROR_NONE) { - throw new Exception('defaults.json JSON parsing failed: '.json_last_error_msg()); + throw new Exception('defaults.json JSON parsing failed: ' . json_last_error_msg()); } $defaults = $defaultsData['defaults'] ?? []; @@ -1587,31 +1534,30 @@ if (! function_exists('createSettingsJsonSSE')) { $state = getInstallationState(); $config = $state['config'] ?? []; - if (! empty($config['app_name'])) { + if (!empty($config['app_name'])) { $defaults['general']['site_name'] = $config['app_name']; $defaults['mail']['from_name'] = $config['app_name']; } - if (! empty($config['app_url'])) { + if (!empty($config['app_url'])) { $defaults['general']['site_url'] = $config['app_url']; } - if (! empty($config['admin_email'])) { + if (!empty($config['admin_email'])) { $defaults['general']['admin_email'] = $config['admin_email']; $defaults['mail']['from_address'] = $config['admin_email']; } - if (! empty($config['core_update_github_url'])) { + if (!empty($config['core_update_github_url'])) { $defaults['core_update']['github_url'] = $config['core_update_github_url']; } - if (! empty($config['core_update_github_token'])) { + if (!empty($config['core_update_github_token'])) { $defaults['core_update']['github_token'] = $config['core_update_github_token']; } $defaults['general']['language'] = getCurrentLanguage(); foreach ($categories as $category) { - if (! isset($defaults[$category])) { + if (!isset($defaults[$category])) { sendSSEEvent('log', ['message' => " - {$category}.json skipped (no defaults)"]); - continue; } @@ -1625,7 +1571,7 @@ if (! function_exists('createSettingsJsonSSE')) { $data = array_merge($data, $settings); $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - $filePath = $settingsDir.'/'.$category.'.json'; + $filePath = $settingsDir . '/' . $category . '.json'; file_put_contents($filePath, $json, LOCK_EX); sendSSEEvent('log', ['message' => " - {$category}.json created"]); @@ -1641,7 +1587,6 @@ if (! function_exists('createSettingsJsonSSE')) { return ['success' => true]; } catch (Exception $e) { logInstallationError(lang('error_settings_json_failed'), $e); - return [ 'success' => false, 'message' => lang('error_settings_json_failed'), @@ -1652,7 +1597,7 @@ if (! function_exists('createSettingsJsonSSE')) { } } -if (! function_exists('setInstallationCompleteSSE')) { +if (!function_exists('setInstallationCompleteSSE')) { function setInstallationCompleteSSE(): array { if (checkAbortStatusSSE()) { @@ -1671,10 +1616,10 @@ if (! function_exists('setInstallationCompleteSSE')) { // 진행 중 재시작을 일으키지 않도록 한다. g7_installed 파일과 state.json // 의 completed 마커만으로 "Step 5 완료" 시점을 보장. - $installedFlagPath = BASE_PATH.'/storage/app/g7_installed'; + $installedFlagPath = BASE_PATH . '/storage/app/g7_installed'; $installedFlagDir = dirname($installedFlagPath); - if (! is_dir($installedFlagDir)) { + if (!is_dir($installedFlagDir)) { @mkdir($installedFlagDir, 0775, true); } @@ -1684,7 +1629,7 @@ if (! function_exists('setInstallationCompleteSSE')) { // 인스톨러 작업 중 composer install 등이 storage/temp 에 남긴 Symfony Process // sf_proc_*.{out,err,lock} 잔여 파일 정리. 디렉토리 자체는 보존 (다음 사용 대비). - $tempDir = BASE_PATH.'/storage/temp'; + $tempDir = BASE_PATH . '/storage/temp'; if (is_dir($tempDir)) { $entries = @scandir($tempDir); if (is_array($entries)) { @@ -1692,7 +1637,7 @@ if (! function_exists('setInstallationCompleteSSE')) { if ($entry === '.' || $entry === '..') { continue; } - $entryPath = $tempDir.'/'.$entry; + $entryPath = $tempDir . '/' . $entry; if (is_file($entryPath)) { @unlink($entryPath); } @@ -1722,7 +1667,6 @@ if (! function_exists('setInstallationCompleteSSE')) { return ['success' => true]; } catch (Exception $e) { logInstallationError(lang('error_complete_flag_failed'), $e); - return [ 'success' => false, 'message' => lang('error_complete_flag_failed'), @@ -1737,7 +1681,7 @@ if (! function_exists('setInstallationCompleteSSE')) { // 메인 실행 로직 // ============================================================================ -if (! function_exists('runInstallationTasks')) { +if (!function_exists('runInstallationTasks')) { /** * 설치 작업 전체 실행. * @@ -1813,9 +1757,6 @@ if (! function_exists('runInstallationTasks')) { $tasks[] = ['id' => 'create_settings_json', 'function' => 'createSettingsJsonSSE']; $tasks[] = ['id' => 'cache_clear', 'function' => 'clearCacheSSE']; $tasks[] = ['id' => 'complete_flag', 'function' => 'setInstallationCompleteSSE']; - // complete_flag(installer_completed=true) 이후에 config 캐시를 최초 생성해야 - // 설치가 config:cache 켜진 상태로 시작한다. best_effort — 실패해도 설치는 완료. - $tasks[] = ['id' => 'config_cache', 'function' => 'optimizeConfigCacheSSE', 'best_effort' => true]; foreach ($tasks as $task) { if (checkAbortStatusSSE()) { @@ -1825,7 +1766,6 @@ if (! function_exists('runInstallationTasks')) { 'target' => $task['target'] ?? null, ]); addLog(lang('abort_installation_stopped')); - return; } @@ -1847,7 +1787,6 @@ if (! function_exists('runInstallationTasks')) { 'target' => $target, 'message' => lang('log_already_completed', ['task' => $displayName]), ]); - continue; } @@ -1860,17 +1799,15 @@ if (! function_exists('runInstallationTasks')) { 'target' => $target, ]); addLog(lang('abort_installation_stopped')); - return; } - if (! $result['success']) { + if (!$result['success']) { // best-effort task (예: 번들 언어팩 설치) — 실패 시 경고 로그만 남기고 계속 진행 if ($bestEffort) { $warnMsg = lang('warning_language_pack_install_partial', ['identifier' => (string) $target]); sendSSEEvent('log', ['message' => $warnMsg]); addLog($warnMsg); - // task_complete 이벤트는 보내지 않고 다음 task 로 진행 (재시도 시 다시 시도 가능) continue; } @@ -1929,7 +1866,7 @@ if (! function_exists('runInstallationTasks')) { $state['error_message_key'] = $result['message_key'] ?? null; $state['error_detail'] = $result['detail'] ?? null; - if (! empty($result['rollback_done'])) { + if (!empty($result['rollback_done'])) { $state['rollback_failure'] = $result['rollback_failure'] ?? null; } else { $state['rollback_failure'] = $rollbackFailure ?? null; @@ -1945,7 +1882,7 @@ if (! function_exists('runInstallationTasks')) { 'message' => $result['message'] ?? lang('log_installation_failed'), ])); - if (! empty($manualCommands)) { + if (!empty($manualCommands)) { sendSSEEvent('log', ['message' => lang('log_separator')]); sendSSEEvent('log', ['message' => lang('manual_commands_guide')]); foreach ($manualCommands as $cmd) { @@ -1962,7 +1899,6 @@ if (! function_exists('runInstallationTasks')) { 'target' => $target, 'manual_commands' => $manualCommands, ]); - return; } } diff --git a/public/install/lang/en.php b/public/install/lang/en.php index e6c16889..094652e4 100644 --- a/public/install/lang/en.php +++ b/public/install/lang/en.php @@ -270,7 +270,6 @@ return [ 'task_cache_clear' => 'Cleaning Up Temporary Files', 'task_create_settings_json' => 'Creating Settings Files', 'task_complete_flag' => 'Finalizing Installation', - 'task_config_cache' => 'Building Configuration Cache', 'task_unknown' => 'Unknown Task', // Task Group Names @@ -372,11 +371,9 @@ return [ // Error Messages - Worker (Cache) 'error_cache_clear_failed' => 'Cache clearing failed', - 'error_config_cache_failed' => 'Configuration cache build failed', // Log Messages - Worker (Cache) 'log_cache_clear_success' => 'Cache clearing completed', - 'log_config_cache_success' => 'Configuration cache built', // Error Messages - Worker (Settings JSON) 'error_settings_json_failed' => 'Settings file creation failed', diff --git a/public/install/lang/ko.php b/public/install/lang/ko.php index 1823e000..b657478f 100644 --- a/public/install/lang/ko.php +++ b/public/install/lang/ko.php @@ -270,7 +270,6 @@ return [ 'task_cache_clear' => '임시 파일 정리', 'task_create_settings_json' => '설정 파일 생성', 'task_complete_flag' => '설치 완료 처리', - 'task_config_cache' => '설정 캐시 생성', 'task_unknown' => '알 수 없는 작업', // 작업 그룹명 @@ -372,11 +371,9 @@ return [ // 에러 메시지 - Worker (Cache) 'error_cache_clear_failed' => '캐시 클리어에 실패했습니다', - 'error_config_cache_failed' => '설정 캐시 생성에 실패했습니다', // 로그 메시지 - Worker (Cache) 'log_cache_clear_success' => '캐시 클리어 완료', - 'log_config_cache_success' => '설정 캐시 생성 완료', // 에러 메시지 - Worker (Settings JSON) 'error_settings_json_failed' => '설정 파일 생성에 실패했습니다', diff --git a/resources/js/core/TemplateApp.ts b/resources/js/core/TemplateApp.ts index e2b5729e..0e5099e0 100644 --- a/resources/js/core/TemplateApp.ts +++ b/resources/js/core/TemplateApp.ts @@ -26,7 +26,7 @@ import { getErrorHandlingResolver } from './error'; import type { ErrorHandlingMap } from './types/ErrorHandling'; import { createLogger, Logger } from './utils/Logger'; import { webSocketManager } from './websocket/WebSocketManager'; -import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig, parseBundleUrlsFromConfig } from './modules'; +import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig } from './modules'; import { SystemBannerManager } from './template-engine/SystemBannerManager'; /** * DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다. @@ -722,64 +722,41 @@ export class TemplateApp { } /** - * 모듈/플러그인 에셋 로드 (서버측 병합 번들) + * 모듈/플러그인 에셋 로드 * - * window.G7Config.bundleUrls 에서 병합 번들 URL 을 읽어 모듈 번들 → - * 플러그인 번들 순으로 로드한다. 각 번들은 활성 확장 IIFE 를 priority 순으로 - * 이어붙인 단일 파일이며, 로드 즉시 각 IIFE 가 자가등록(핸들러/리스너)을 - * 실행한다. 개별 로딩(loadActiveExtensionAssets)과 실행 계약은 동일하다. + * window.G7Config.moduleAssets 및 pluginAssets에서 에셋 정보를 읽어 + * 동적으로 JS/CSS를 로드합니다. * - * 모듈 → 플러그인 순서를 유지하는 이유: gdpr preblocker 등 인터셉터가 - * 플러그인 번들 내 priority 최상단으로 오되 모듈보다는 뒤에 실행된다 - * (2번들 구조). bundleUrls 가 없으면(구버전 blade) 개별 로딩으로 폴백한다. - * - * @since engine-v1.52.0 (서버측 번들 로딩으로 전환) + * 모듈 JS는 IIFE 형태로 빌드되어 로드 즉시 initModule()이 실행되고, + * ActionDispatcher에 핸들러가 등록됩니다. */ private async loadExtensionAssets(): Promise { try { const moduleAssetLoader = getModuleAssetLoader(); - const bundleUrls = parseBundleUrlsFromConfig(); - // bundleUrls 부재 시 개별 로딩 폴백 (회귀 안전) - if (!bundleUrls) { - await this.loadExtensionAssetsIndividually(); - return; + // 모듈 에셋 로드 + const moduleAssets = parseModuleAssetsFromConfig(); + if (moduleAssets.length > 0) { + logger.log('Loading module assets:', moduleAssets.map(m => m.identifier)); + await moduleAssetLoader.loadActiveExtensionAssets(moduleAssets); } - // 모듈 번들 → 플러그인 번들 순서 (gdpr 는 플러그인 번들 내 최상단) - await moduleAssetLoader.loadBundle('module', bundleUrls.moduleJs, bundleUrls.moduleCss); - await moduleAssetLoader.loadBundle('plugin', bundleUrls.pluginJs, bundleUrls.pluginCss); + // 플러그인 에셋 로드 + const pluginAssets = parsePluginAssetsFromConfig(); + if (pluginAssets.length > 0) { + logger.log('Loading plugin assets:', pluginAssets.map(p => p.identifier)); + await moduleAssetLoader.loadActiveExtensionAssets(pluginAssets); + } - logger.log('Extension bundle assets loaded successfully'); + if (moduleAssets.length > 0 || pluginAssets.length > 0) { + logger.log('Extension assets loaded successfully'); + } } catch (error) { // 에셋 로드 실패는 경고만 출력하고 앱 계속 진행 logger.warn('Failed to load extension assets:', error); } } - /** - * 개별 확장 에셋 로드 (bundleUrls 부재 시 폴백) - * - * window.G7Config.moduleAssets/pluginAssets 에서 확장별 개별 URL 을 읽어 - * priority 순으로 각각 로드한다. 서버측 번들이 준비되지 않은 구버전 blade - * 환경 회귀 안전용. - */ - private async loadExtensionAssetsIndividually(): Promise { - const moduleAssetLoader = getModuleAssetLoader(); - - const moduleAssets = parseModuleAssetsFromConfig(); - if (moduleAssets.length > 0) { - logger.log('Loading module assets (individual fallback):', moduleAssets.map(m => m.identifier)); - await moduleAssetLoader.loadActiveExtensionAssets(moduleAssets); - } - - const pluginAssets = parsePluginAssetsFromConfig(); - if (pluginAssets.length > 0) { - logger.log('Loading plugin assets (individual fallback):', pluginAssets.map(p => p.identifier)); - await moduleAssetLoader.loadActiveExtensionAssets(pluginAssets); - } - } - /** * 라우트 변경 핸들러 */ diff --git a/resources/js/core/__tests__/loadDevToolsBundle.test.ts b/resources/js/core/__tests__/loadDevToolsBundle.test.ts index 63b24d43..2991d67c 100644 --- a/resources/js/core/__tests__/loadDevToolsBundle.test.ts +++ b/resources/js/core/__tests__/loadDevToolsBundle.test.ts @@ -1,5 +1,5 @@ /** - * loadDevToolsBundle 테스트 — DevTools lazy 번들 로더 (engine-v1.51.0) + * loadDevToolsBundle 테스트 — DevTools lazy 번들 로더 (engine-v1.52.0) * * DevTools 디버그 전용 모듈(패널/진단엔진/서버커넥터/스타일추적기)은 별도 번들 * (devtools.min.js)로 분리되어 디버그 모드에서만