From cfc2caba1b87bc97b50986f09110e97ff947a694 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sun, 16 Aug 2026 22:40:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(ckeditor5,board,ecommerce,page,core,admin?= =?UTF-8?q?=5Fbasic):=20=EB=AF=B8=EC=B0=B8=EC=A1=B0=20=EC=97=90=EB=94=94?= =?UTF-8?q?=ED=84=B0=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A0=95=EB=A6=AC=20=EC=8B=A0=EC=84=A4=20+=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B3=A0=EC=95=84=20=EB=8F=99?= =?UTF-8?q?=EC=A2=85=20=EA=B2=B0=ED=95=A8=207=EA=B1=B4=20=EC=A0=84?= =?UTF-8?q?=EC=88=98=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개 (@Tuwasduliebst 제보) 대응. - ckeditor5: 미참조 이미지 정리 엔진 신설 — 참조 판정(hash + 파일명 2토큰, filter_reference_sources 훅으로 확장 콘텐츠 등록) + 정리 커맨드(기본 꺼짐 옵트인, 3중 게이트) + 업로드 관리 화면(권한 분리·참조 배지·개별/선택 삭제) + 기설치본 설정 백필 upgrade step - board: 임시 첨부 GC(상시) + 소프트삭제 첨부 영구 정리(옵트인) + 설정 UI, 저장 payload 누락(F4)·빈 temp 디렉토리 잔존(F5) 수정 - ecommerce: 임시 상품 이미지 GC + 카테고리 삭제 시 이미지 파일 미삭제(S4) 수정 - page: 임시 첨부 GC - 코어: 고아 첨부 정리 커맨드(옵트인, site_logo 보호) + 사이트 로고 교체 시 구 로고 재편입 차단(S6) + DB 백업 미구현 501 정직화(B3) + 플러그인 관리자 메뉴 설치·업데이트 자동 동기화(F1·F7) - 동반: 플러그인 설정 검증 오류 필드명 라벨화(F3), Toggle 노드 type 정정 수평 전개(marketing 1.0.2 bump·gdpr·ecommerce), ja 언어팩 4종, API 문서, hooks.md, 시나리오 매니페스트 8종, Playwright E2E 4종 --- CHANGELOG.md | 4 + .../PruneOrphanAttachmentsCommand.php | 123 ++ .../AttachmentRepositoryInterface.php | 28 + app/Extension/AbstractPlugin.php | 3 +- app/Extension/PluginManager.php | 62 +- .../Api/Admin/SettingsController.php | 24 +- .../Admin/UpdatePluginSettingsRequest.php | 36 + .../Requests/Settings/SaveSettingsRequest.php | 9 + app/Repositories/AttachmentRepository.php | 40 + app/Services/AttachmentService.php | 83 +- app/Services/SettingsService.php | 127 +- config/core.php | 2 + config/settings/defaults.json | 8 +- docs/backend/api/settings.md | 15 +- docs/extension/hooks.md | 42 + lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 2 + .../g7-core-ja/backend/ja/settings.php | 1 + .../g7-core-ja/backend/ja/validation.php | 8 +- .../g7-module-sirsoft-board-ja/CHANGELOG.md | 1 + .../backend/ja/validation.php | 2 + .../frontend/partial/admin/settings.json | 10 +- .../CHANGELOG.md | 4 + .../backend/ja/messages.php | 11 + .../frontend/ja.json | 57 +- .../seed/permissions.json | 18 + .../CHANGELOG.md | 1 + .../frontend/partial/admin.json | 8 +- lang/en/settings.php | 1 + lang/en/validation.php | 6 + lang/ko/settings.php | 1 + lang/ko/validation.php | 6 + modules/_bundled/sirsoft-board/CHANGELOG.md | 5 + .../_bundled/sirsoft-board/config/board.php | 4 + .../config/settings/defaults.json | 9 +- .../sirsoft-board/docs/api/settings.md | 3 + modules/_bundled/sirsoft-board/module.php | 11 + ...-board-attachment-cleanup-settings.test.ts | 187 +++ .../layouts/admin-board-settings.test.tsx | 8 +- .../lang/partial/en/admin/settings.json | 8 +- .../lang/partial/ko/admin/settings.json | 8 +- .../layouts/admin/admin_board_settings.json | 2 +- .../admin_board_settings/_tab_general.json | 162 ++- .../Commands/PruneAttachmentsCommand.php | 180 +++ .../Admin/StoreBoardSettingsRequest.php | 9 +- .../Requests/Concerns/ReadsBoardLimits.php | 3 + .../Ckeditor5ReferenceSourcesListener.php | 57 + .../src/Providers/BoardServiceProvider.php | 2 + .../src/Repositories/AttachmentRepository.php | 48 + .../AttachmentRepositoryInterface.php | 33 + .../src/Services/AttachmentService.php | 151 ++- .../sirsoft-board/src/lang/en/validation.php | 4 + .../sirsoft-board/src/lang/ko/validation.php | 4 + .../Admin/BoardSettingsControllerTest.php | 77 +- .../Console/PruneAttachmentsCommandTest.php | 339 ++++++ .../board-settings-attachment-cleanup.spec.ts | 129 ++ .../Ckeditor5ReferenceSourcesListenerTest.php | 60 + .../tests/scenarios/attachment-cleanup.yaml | 74 ++ .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 9 + modules/_bundled/sirsoft-ecommerce/module.php | 10 + ...adminCategoryImageRemoveExpression.test.ts | 135 +++ .../shippingPolicySettingsTab.test.tsx | 52 +- .../layouts/shippingTypeSection.test.tsx | 2 +- .../admin_ecommerce_product_review_index.json | 2 +- .../_panel_form.json | 8 +- .../_refund_reason_cards.json | 4 +- .../_refund_reason_section.json | 2 +- .../_shipping_carrier_cards.json | 4 +- .../_shipping_carrier_section.json | 2 +- .../_shipping_country_cards.json | 4 +- .../_shipping_country_table.json | 4 +- .../_shipping_type_cards.json | 4 +- .../_shipping_type_section.json | 2 +- .../_tab_shipping.json | 2 +- .../_partial_country_basic_fields.json | 2 +- .../_partial_extra_fee.json | 6 +- .../PruneTempProductImagesCommand.php | 92 ++ .../Ckeditor5ReferenceSourcesListener.php | 57 + .../Providers/EcommerceServiceProvider.php | 2 + .../Repositories/CategoryImageRepository.php | 8 + .../CategoryImageRepositoryInterface.php | 44 +- .../ProductImageRepositoryInterface.php | 13 + .../Repositories/ProductImageRepository.php | 16 + .../src/Services/CategoryImageService.php | 43 + .../src/Services/CategoryService.php | 7 +- .../src/Services/ProductImageService.php | 87 ++ .../PruneTempProductImagesCommandTest.php | 186 +++ .../Services/CategoryDeleteImageFileTest.php | 119 ++ .../Ckeditor5ReferenceSourcesListenerTest.php | 59 + .../Unit/Services/CategoryServiceTest.php | 23 +- .../tests/scenarios/temp-image-cleanup.yaml | 66 + modules/_bundled/sirsoft-page/CHANGELOG.md | 4 + modules/_bundled/sirsoft-page/module.php | 23 + .../Commands/PruneTempAttachmentsCommand.php | 92 ++ .../Ckeditor5ReferenceSourcesListener.php | 57 + .../src/Providers/PageServiceProvider.php | 2 + .../PageAttachmentRepositoryInterface.php | 16 +- .../Repositories/PageAttachmentRepository.php | 19 +- .../src/Services/PageAttachmentService.php | 110 ++ .../PruneTempAttachmentsCommandTest.php | 191 +++ .../Ckeditor5ReferenceSourcesListenerTest.php | 61 + .../scenarios/temp-attachment-cleanup.yaml | 50 + .../_bundled/sirsoft-ckeditor5/CHANGELOG.md | 13 +- .../config/settings/defaults.json | 10 +- ...ed_at_index_to_ckeditor5_image_uploads.php | 39 + .../sirsoft-ckeditor5/docs/api/README.md | 3 +- .../sirsoft-ckeditor5/docs/api/uploads.md | 253 ++++ .../sirsoft-ckeditor5/lang/en/messages.php | 21 +- .../sirsoft-ckeditor5/lang/ko/messages.php | 21 +- .../_bundled/sirsoft-ckeditor5/package.json | 4 +- plugins/_bundled/sirsoft-ckeditor5/plugin.php | 231 ++++ .../layouts/ckeditor5Uploads.test.tsx | 358 ++++++ .../layouts/pluginSettingsCleanup.test.tsx | 249 ++++ .../layouts/pluginSettingsSticky.test.tsx | 45 +- .../sirsoft-ckeditor5/resources/lang/en.json | 57 +- .../sirsoft-ckeditor5/resources/lang/ko.json | 57 +- .../layouts/admin/ckeditor5_uploads.json | 1068 +++++++++++++++++ .../layouts/admin/plugin_settings.json | 158 ++- .../sirsoft-ckeditor5/resources/routes.json | 12 +- .../Commands/PruneUnusedImagesCommand.php | 165 +++ .../Admin/ImageUploadAdminController.php | 99 ++ .../Requests/BulkDeleteUploadsRequest.php | 67 ++ .../src/Http/Requests/IndexUploadsRequest.php | 123 ++ .../Http/Resources/ImageUploadResource.php | 37 + .../Providers/Ckeditor5ServiceProvider.php | 20 + ...mageReferenceSourceRepositoryInterface.php | 32 + .../ImageUploadRepositoryInterface.php | 67 ++ .../ImageReferenceSourceRepository.php | 66 + .../Repositories/ImageUploadRepository.php | 162 +++ .../src/Services/ImageCleanupService.php | 164 +++ .../Services/ImageReferenceScanService.php | 217 ++++ .../src/Services/ImageUploadAdminService.php | 171 +++ .../sirsoft-ckeditor5/src/routes/api.php | 21 + .../Console/PruneUnusedImagesCommandTest.php | 225 ++++ .../Admin/ImageUploadAdminControllerTest.php | 335 ++++++ .../Playwright/fixtures/ckeditor5-auth.ts | 34 + .../tests/Playwright/playwright.config.ts | 90 ++ .../specs/admin/uploads-admin.spec.ts | 279 +++++ .../tests/PluginTestCase.php | 35 +- .../Unit/Services/ImageCleanupServiceTest.php | 293 +++++ .../ImageReferenceScanServiceTest.php | 214 ++++ .../upgrades/Upgrade_1_0_2.php | 17 + .../01_SeedUnusedImageCleanupSettings.php | 105 ++ plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 1 + .../layouts/admin/plugin_settings.json | 2 +- .../_bundled/sirsoft-marketing/CHANGELOG.md | 8 + .../_bundled/sirsoft-marketing/composer.json | 2 +- .../sirsoft-marketing/package-lock.json | 4 +- .../_bundled/sirsoft-marketing/package.json | 2 +- .../_bundled/sirsoft-marketing/plugin.json | 2 +- .../layouts/pluginSettingsSticky.test.tsx | 45 +- .../layouts/admin/plugin_settings.json | 14 +- resources/views/dev-dashboard.blade.php | 4 + routes/console.php | 2 + .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 2 + ...in-settings-logo-remove-expression.test.ts | 124 ++ .../admin-settings-logo-remove.test.tsx | 4 +- .../admin-settings-orphan-cleanup.test.ts | 149 +++ .../lang/partial/en/admin.json | 8 +- .../lang/partial/ko/admin.json | 8 +- .../partials/admin_settings/_tab_general.json | 2 +- .../partials/admin_settings/_tab_upload.json | 98 ++ .../Api/Admin/SettingsControllerTest.php | 4 + .../Api/Admin/SettingsDatabaseBackupTest.php | 154 +++ .../PruneOrphanAttachmentsCommandTest.php | 282 +++++ .../Services/SettingsServiceSiteLogoTest.php | 189 +++ .../settings-upload-orphan-cleanup.spec.ts | 99 ++ tests/Unit/Extension/PluginMenuSyncTest.php | 193 +++ .../scenarios/attachment-orphan-cleanup.yaml | 79 ++ .../ckeditor5-unused-image-cleanup.yaml | 167 +++ tests/scenarios/plugin-admin-menu-sync.yaml | 45 + 170 files changed, 11142 insertions(+), 209 deletions(-) create mode 100644 app/Console/Commands/PruneOrphanAttachmentsCommand.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/seed/permissions.json create mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-attachment-cleanup-settings.test.ts create mode 100644 modules/_bundled/sirsoft-board/src/Console/Commands/PruneAttachmentsCommand.php create mode 100644 modules/_bundled/sirsoft-board/src/Listeners/Ckeditor5ReferenceSourcesListener.php create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Console/PruneAttachmentsCommandTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-settings-attachment-cleanup.spec.ts create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php create mode 100644 modules/_bundled/sirsoft-board/tests/scenarios/attachment-cleanup.yaml create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminCategoryImageRemoveExpression.test.ts create mode 100644 modules/_bundled/sirsoft-ecommerce/src/Console/Commands/PruneTempProductImagesCommand.php create mode 100644 modules/_bundled/sirsoft-ecommerce/src/Listeners/Ckeditor5ReferenceSourcesListener.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Console/PruneTempProductImagesCommandTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/CategoryDeleteImageFileTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/scenarios/temp-image-cleanup.yaml create mode 100644 modules/_bundled/sirsoft-page/src/Console/Commands/PruneTempAttachmentsCommand.php create mode 100644 modules/_bundled/sirsoft-page/src/Listeners/Ckeditor5ReferenceSourcesListener.php create mode 100644 modules/_bundled/sirsoft-page/tests/Feature/Console/PruneTempAttachmentsCommandTest.php create mode 100644 modules/_bundled/sirsoft-page/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php create mode 100644 modules/_bundled/sirsoft-page/tests/scenarios/temp-attachment-cleanup.yaml create mode 100644 plugins/_bundled/sirsoft-ckeditor5/database/migrations/2026_08_14_000001_add_created_at_index_to_ckeditor5_image_uploads.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/docs/api/uploads.md create mode 100644 plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/ckeditor5Uploads.test.tsx create mode 100644 plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsCleanup.test.tsx create mode 100644 plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/ckeditor5_uploads.json create mode 100644 plugins/_bundled/sirsoft-ckeditor5/src/Console/Commands/PruneUnusedImagesCommand.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/src/Http/Controllers/Admin/ImageUploadAdminController.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/src/Http/Requests/BulkDeleteUploadsRequest.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/src/Http/Requests/IndexUploadsRequest.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/src/Http/Resources/ImageUploadResource.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/src/Repositories/Contracts/ImageReferenceSourceRepositoryInterface.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/src/Repositories/ImageReferenceSourceRepository.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageCleanupService.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageReferenceScanService.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageUploadAdminService.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Console/PruneUnusedImagesCommandTest.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Http/Controllers/Admin/ImageUploadAdminControllerTest.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/fixtures/ckeditor5-auth.ts create mode 100644 plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/playwright.config.ts create mode 100644 plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/admin/uploads-admin.spec.ts create mode 100644 plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Services/ImageCleanupServiceTest.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Services/ImageReferenceScanServiceTest.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/upgrades/Upgrade_1_0_2.php create mode 100644 plugins/_bundled/sirsoft-ckeditor5/upgrades/data/1.0.2/migrations/01_SeedUnusedImageCleanupSettings.php create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-logo-remove-expression.test.ts create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-orphan-cleanup.test.ts create mode 100644 tests/Feature/Api/Admin/SettingsDatabaseBackupTest.php create mode 100644 tests/Feature/Console/PruneOrphanAttachmentsCommandTest.php create mode 100644 tests/Feature/Services/SettingsServiceSiteLogoTest.php create mode 100644 tests/Playwright/specs/admin/settings-upload-orphan-cleanup.spec.ts create mode 100644 tests/Unit/Extension/PluginMenuSyncTest.php create mode 100644 tests/scenarios/attachment-orphan-cleanup.yaml create mode 100644 tests/scenarios/ckeditor5-unused-image-cleanup.yaml create mode 100644 tests/scenarios/plugin-admin-menu-sync.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 345455ac..6a800287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)을 S3+CDN 등 원격 디스크의 직접 URL 로 서빙할 수 있습니다. 환경설정 > 드라이버의 "공개 자산 스토리지"에서 디스크를 고르면 새로 올리는 이미지가 그 디스크에 저장되고 화면에는 CDN 주소가 실립니다 — 서버를 거치지 않아 트래픽 부담이 줄어듭니다. 사용 안 함(기본)이면 종전 방식 그대로이며, 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시됩니다. 확장(쇼핑몰·에디터)별로 다른 디스크를 쓰도록 개별 설정할 수도 있습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.) - 확장 개발자용: 스토리지 URL 계약이 public 외에도 URL 이 설정된 디스크(S3+CDN 등)에서 직접 주소를 반환하며, 생성 결과를 공급·수정·차단할 수 있는 `core.storage.filter_url` 필터 훅이 추가되었습니다. 공개 자산 디스크 선택지는 `core.settings.available_public_asset_drivers` 훅으로 플러그인이 확장할 수 있고, 확장이 이미지 같은 카테고리 단위로 다른 스토리지 디스크를 쓰도록 분리할 수 있습니다. 공개 자산 디스크 오버라이드 설정을 선언한 플러그인은 설정 조회·저장 응답에 디스크 선택지 목록이 함께 실리고 저장 시 선택지 검증도 함께 걸리므로, 설정 화면이 별도 권한의 코어 환경설정 API 를 조회하거나 플러그인이 검증을 따로 구현할 필요가 없습니다. - 보존 기간이 지난 데이터의 자동 정리에 확장이 개입할 수 있는 훅을 제공합니다 — 활동 로그·알림 발송 이력·예약 작업 이력의 정리 전후에 발생하므로, 확장이 정리 대상을 외부에 따로 보관하는 등의 처리를 붙일 수 있습니다. +- 폼을 저장하지 않고 나가서 소유자 없이 남은 첨부파일을 자동으로 정리할 수 있습니다. 환경설정 > 업로드에서 자동 정리를 켜면(기본 꺼짐) 설정한 보존기간(기본 30일)이 지난 고아 첨부의 파일과 기록을 매일 삭제합니다. 사이트 로고처럼 현재 사용 중인 첨부와 확장이 관리하는 첨부는 대상에서 제외됩니다. - 관리자가 회원 정보 수정 화면에서 상태를 '탈퇴'로 바꿔 저장하면 확인 창을 거치도록 했습니다. 이 처리는 회원의 이메일·이름·닉네임을 익명화하고 되돌릴 수 없으므로, 무엇이 처리되는지 먼저 알려 드립니다. 함께, 관리자가 상태만 바꿔 탈퇴시킨 경우에도 회원이 직접 탈퇴한 것과 동일한 처리(익명화·연계 데이터 정리)가 이뤄지도록 통일했습니다. ### Changed @@ -51,6 +52,9 @@ - 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.) - 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst 님께서 제보해주셨습니다.) - 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.) +- 사이트 로고를 교체해도 이전 로고가 설정에 다시 실려, 교체할 때마다 파일과 기록이 함께 쌓이던 문제를 수정했습니다. 화면에서 뺀 로고는 저장이 완료된 뒤 실제로 삭제되며, 저장에 실패하면 파일도 그대로 남습니다. +- 관리자 API 의 데이터베이스 백업 요청이 항상 서버 오류로 끝나고, 그 응답에 내부 구현 정보가 그대로 실려 나가던 문제를 수정했습니다. 이 기능은 아직 제공하지 않으므로 "제공하지 않는 기능" 임을 알리는 응답으로 답합니다. 설정 파일 백업은 종전대로 사용할 수 있습니다. +- 플러그인이 선언한 관리자 메뉴가 설치·업데이트 시 자동으로 만들어지도록 수정했습니다. 종전에는 플러그인이 활성화 처리에서 직접 만든 경우에만 메뉴가 생겨서, 이미 사용 중인 사이트가 플러그인을 업데이트해 새 화면을 받아도 메뉴가 나타나지 않고 주소를 직접 입력해야만 닿을 수 있었습니다. 이제 모듈과 동일하게 처리됩니다. - 회원가입 등 일반 화면의 비밀번호 오류 문구가 'SMTP 비밀번호'로 표시되던 문제를 수정했습니다. 같은 이유로 어긋나 있던 '기본 언어'·'기본 통화'·'사용자 ID 목록'·'알림 채널' 등의 문구도 화면에 맞게 정리했습니다. (#113 @Tuwasduliebst 님께서 제보해주셨습니다.) - 회원 삭제·회원 정보 수정·회원가입·역할 삭제·비밀번호 재설정·첨부 파일 삭제·메뉴 삭제·예약 작업 삭제가 중간에 실패하면 일부만 처리된 상태로 남던 문제를 수정했습니다. 회원 정보 수정은 프로필 변경·로그인 세션 정리·역할 변경이 함께 처리되므로, 역할 변경에서 실패하면 이름만 바뀌고 로그인이 끊긴 상태로 남았습니다. 이제 각 작업은 전부 성공하거나 전부 취소되며, 파일 삭제는 데이터 정리가 확정된 뒤에 수행됩니다. - 페이지 모듈의 첨부 파일 설정(개수·용량·형식)이 저장해도 반영되지 않던 문제를 수정했습니다. diff --git a/app/Console/Commands/PruneOrphanAttachmentsCommand.php b/app/Console/Commands/PruneOrphanAttachmentsCommand.php new file mode 100644 index 00000000..a4ef255e --- /dev/null +++ b/app/Console/Commands/PruneOrphanAttachmentsCommand.php @@ -0,0 +1,123 @@ +option('scheduled') && ! $this->isCleanupEnabled()) { + $this->info('고아 첨부 자동 정리가 꺼져 있어 실행하지 않았습니다. (upload.orphan_cleanup_enabled = false)'); + + return Command::SUCCESS; + } + + $days = $this->resolveRetentionDays(); + + if ($days < 1) { + $this->info('고아 첨부 보존기간이 1일 미만이어서 정리를 수행하지 않았습니다.'); + + return Command::SUCCESS; + } + + $limit = max(1, (int) $this->option('limit')); + $isDryRun = (bool) $this->option('dry-run'); + + $result = $this->attachmentService->pruneOrphans($days, $limit, $isDryRun); + + if ($isDryRun) { + $this->info("[DRY RUN] 보존기간({$days}일) 경과 고아 첨부: {$result['scanned']}건"); + + return Command::SUCCESS; + } + + $this->info(sprintf( + '보존기간(%d일) 경과 고아 첨부 %d건 중 %d건을 삭제했습니다. (실패 %d건)', + $days, + $result['scanned'], + $result['deleted'], + $result['failed'], + )); + + Log::info('PruneOrphanAttachmentsCommand: 고아 첨부 정리 완료', [ + 'days' => $days, + 'limit' => $limit, + ] + $result); + + return Command::SUCCESS; + } + + /** + * 자동 정리 토글을 false 폴백으로 조회합니다. + * + * @return bool 자동 정리 활성 여부 + */ + private function isCleanupEnabled(): bool + { + return (bool) g7_core_settings('upload.orphan_cleanup_enabled', false); + } + + /** + * 보존기간을 해석합니다 (옵션 > 환경설정 > 기본 30일). + * + * @return int 보존기간(일) + */ + private function resolveRetentionDays(): int + { + $option = $this->option('days'); + + if ($option !== null && $option !== '') { + return (int) $option; + } + + return (int) g7_core_settings('upload.orphan_retention_days', 30); + } +} diff --git a/app/Contracts/Repositories/AttachmentRepositoryInterface.php b/app/Contracts/Repositories/AttachmentRepositoryInterface.php index f62684cb..471c653e 100644 --- a/app/Contracts/Repositories/AttachmentRepositoryInterface.php +++ b/app/Contracts/Repositories/AttachmentRepositoryInterface.php @@ -4,6 +4,7 @@ namespace App\Contracts\Repositories; use App\Models\Attachment; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Carbon; /** * 첨부파일 Repository 인터페이스 @@ -18,6 +19,17 @@ interface AttachmentRepositoryInterface */ public function findById(int $id): ?Attachment; + /** + * 소프트삭제된 행까지 포함해 ID로 첨부파일 조회 + * + * 영구 삭제(파일+forceDelete) 경로 전용이다. 소프트삭제된 행은 기본 조회에서 빠지므로, + * 이 메서드 없이 영구 삭제를 시도하면 대상을 찾지 못해 매 회차 실패만 누적된다. + * + * @param int $id 첨부파일 ID + * @return Attachment|null 첨부파일 또는 null + */ + public function findByIdWithTrashed(int $id): ?Attachment; + /** * 여러 ID로 첨부파일 조회 (order 정렬) * @@ -145,4 +157,20 @@ interface AttachmentRepositoryInterface * @param string $collection 컬렉션명 */ public function reorderAfterDelete(string $attachmentableType, int $attachmentableId, string $collection): void; + + /** + * 소유자 없이 방치된 고아 첨부 후보를 오래된 순으로 조회합니다. + * + * 폼 저장 전에 즉시 업로드되는 첨부는 소유자(attachmentable) 없이 먼저 생성되므로, + * 폼을 저장하지 않고 이탈하면 그대로 남습니다. 그 회수 대상을 찾습니다. + * + * 확장이 소유한 첨부(source_identifier 보유)는 그 확장의 라이프사이클 소관이므로 제외하고, + * 현역으로 쓰이는 첨부 ID 는 호출자가 넘겨 보호합니다. + * + * @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상) + * @param int $limit 최대 조회 건수 + * @param array $protectedIds 보호할 첨부 ID 목록 (현역 사이트 로고 등) + * @return Collection 고아 첨부 후보 (created_at 오름차순, 소프트 삭제분 포함) + */ + public function findOrphanCandidates(Carbon $threshold, int $limit, array $protectedIds = []): Collection; } diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php index 0a7498bb..380ef150 100644 --- a/app/Extension/AbstractPlugin.php +++ b/app/Extension/AbstractPlugin.php @@ -19,7 +19,8 @@ use ReflectionClass; * getIdentifier(), getVendor()는 디렉토리명에서 자동 추론됩니다. * getName(), getVersion(), getDescription()은 plugin.json에서 자동 파싱됩니다. * - * 참고: 플러그인은 모듈과 달리 관리자 메뉴(getAdminMenus)를 추가할 수 없습니다. + * 참고: 플러그인도 관리자 메뉴(getAdminMenus)를 선언할 수 있습니다. 설치·업데이트·활성화가 + * 공통으로 지나는 선언형 산출물 동기화에서 PluginManager 가 자동으로 반영합니다. */ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInterface { diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php index 192185ff..3c53cf25 100644 --- a/app/Extension/PluginManager.php +++ b/app/Extension/PluginManager.php @@ -21,6 +21,7 @@ use App\Exceptions\LayoutIncludeException; use App\Extension\Concerns\ResolvesExtensionSharedRecords; use App\Extension\Helpers\DependencyEnricher; use App\Extension\Helpers\ExtensionBackupHelper; +use App\Extension\Helpers\ExtensionMenuSyncHelper; use App\Extension\Helpers\ExtensionPendingHelper; use App\Extension\Helpers\ExtensionRoleSyncHelper; use App\Extension\Helpers\ExtensionStatusGuard; @@ -449,6 +450,9 @@ class PluginManager implements PluginManagerInterface // 권한-Role 연결 $this->assignPermissionsToRoles($plugin); + // 관리자 메뉴 자동 생성 (모듈 installModule 과 동일 순서) + $this->createPluginMenus($plugin); + // IDV 정책 자동 동기화 (identity_policies 테이블) $this->syncPluginIdentityPolicies($plugin); @@ -2003,10 +2007,54 @@ class PluginManager implements PluginManagerInterface ); } + /** + * 플러그인이 선언한 관리자 메뉴를 동기화합니다. + * + * 플러그인도 자기 화면을 가질 수 있으므로 `getAdminMenus()` 를 선언할 수 있습니다. + * 이 동기화가 설치·활성화 시점에만 일어나면, 이미 활성 상태인 사이트가 플러그인을 + * 업데이트해 새 화면을 받아도 메뉴가 만들어지지 않아 그 화면은 주소를 직접 입력해야만 + * 닿을 수 있습니다. 그래서 선언형 산출물 동기화(설치·업데이트 공통 경로)에 포함합니다. + * + * helper 의 upsert 패턴을 그대로 쓰므로 재호출은 무해하며(멱등) 운영자 커스터마이징 + * (user_overrides) 도 보존됩니다. + * + * @param PluginInterface $plugin 메뉴를 동기화할 플러그인 인스턴스 + */ + protected function createPluginMenus(PluginInterface $plugin): void + { + if (! method_exists($plugin, 'getAdminMenus')) { + return; + } + + $menus = $plugin->getAdminMenus(); + + if (empty($menus)) { + return; + } + + // 활성 언어팩이 admin_menus 다국어 필드(name 등)에 추가 locale 을 주입할 수 있도록 + // 모듈과 동일한 필터 훅 규약을 적용한다 (LanguagePackSeedInjector 가 결선). + $menus = HookManager::applyFilters( + "plugin.{$plugin->getIdentifier()}.admin_menus.translations", + $menus, + ); + + $helper = app(ExtensionMenuSyncHelper::class); + + foreach ($menus as $menuData) { + $helper->syncMenuRecursive( + $menuData, + ExtensionOwnerType::Plugin, + $plugin->getIdentifier(), + ); + } + } + /** * 플러그인 정의 기준으로 stale 권한·역할을 정리합니다 (완전 동기화 원칙). * - * 플러그인은 메뉴(getAdminMenus) 를 지원하지 않으므로 권한·역할만 대상. + * 메뉴 stale 정리는 비활성화·삭제 시 플러그인 자신이 `cleanupStaleMenus` 로 수행하므로 + * 여기서는 권한·역할만 대상으로 한다. * user_overrides 보존 및 `users.role_id` 참조 역할 삭제 차단은 helper 가 담당. */ protected function cleanupStalePluginEntries(PluginInterface $plugin): void @@ -2065,14 +2113,15 @@ class PluginManager implements PluginManagerInterface * 를 정합 상태로 유지한다. 외부 진입점으로도 노출되어 코어 업그레이드 사후 보정 * (`Upgrade_7_0_0_beta_4` 등) 이나 운영자 수동 재시드 도구가 사용 가능. * - * 동기화 대상 (플러그인은 메뉴 미지원 — getAdminMenus 부재): + * 동기화 대상: * 1. 역할 (`getRoles`) * 2. 권한 (`getPermissions`) * 3. 역할-권한 매핑 (`getRolePermissions`) - * 4. stale cleanup (현재 선언에 없는 기존 레코드 제거) - * 5. IDV 정책 (`getIdentityPolicies`) - * 6. IDV 메시지 정의/템플릿 (`getIdentityMessages`) - * 7. 알림 정의/템플릿 (`getNotificationDefinitions`) + * 4. 관리자 메뉴 (`getAdminMenus`) + * 5. stale cleanup (현재 선언에 없는 기존 레코드 제거) + * 6. IDV 정책 (`getIdentityPolicies`) + * 7. IDV 메시지 정의/템플릿 (`getIdentityMessages`) + * 8. 알림 정의/템플릿 (`getNotificationDefinitions`) * * 각 sync 메서드는 helper 내부의 user_overrides 보존 패턴을 따르므로 정상 환경 재호출 * 무해 (멱등). @@ -2084,6 +2133,7 @@ class PluginManager implements PluginManagerInterface $this->createPluginRoles($plugin); $this->createPluginPermissions($plugin); $this->assignPermissionsToRoles($plugin); + $this->createPluginMenus($plugin); $this->cleanupStalePluginEntries($plugin); $this->syncPluginIdentityPolicies($plugin); $this->syncPluginIdentityMessages($plugin); diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php index c3e6d14f..3dc58098 100644 --- a/app/Http/Controllers/Api/Admin/SettingsController.php +++ b/app/Http/Controllers/Api/Admin/SettingsController.php @@ -188,23 +188,21 @@ class SettingsController extends AdminBaseController } /** - * 데이터베이스를 백업합니다. + * 데이터베이스 백업 — 아직 제공하지 않는 기능임을 알립니다. * - * @return JsonResponse 백업 결과 JSON 응답 + * 코어에는 DB 덤프 수단이 없어 이 엔드포인트는 구현된 적이 없다. + * 종전에는 존재하지 않는 `SettingsService::backupDatabase()` 를 호출했고, + * PHP 가 던지는 `Error` 는 `catch (\Exception)` 에 걸리지 않아 그대로 500 이 되면서 + * 내부 메서드 이름까지 응답에 실려 나갔다 (공개 #115 부록 B3). + * + * 기능 부재는 서버 고장이 아니므로 501(Not Implemented)로 답한다. + * 설정 파일 백업은 `POST /api/admin/settings/backup` 이 담당한다. + * + * @return JsonResponse 미제공 안내 JSON 응답 (501) */ public function backupDatabase(): JsonResponse { - try { - $result = $this->settingsService->backupDatabase(); - - if ($result) { - return $this->success('settings.backup_success'); - } else { - return $this->error('settings.backup_failed'); - } - } catch (\Exception $e) { - return $this->error('settings.backup_error', 500, $e->getMessage()); - } + return $this->error('settings.database_backup_unavailable', 501); } /** diff --git a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php index cbe8f2ec..38df8ae9 100644 --- a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php +++ b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php @@ -162,4 +162,40 @@ class UpdatePluginSettingsRequest extends FormRequest 'min' => __('validation.min'), ]; } + + /** + * 검증 오류에 쓸 필드 표시명을 반환합니다. + * + * 설정 스키마의 `label` 을 그대로 씁니다. 이 값이 없으면 Laravel 이 원시 키를 풀어쓴 + * 이름("unused image retention days")을 그대로 노출하는데, 같은 화면의 입력 라벨은 + * 한국어인 상태라 운영자에게는 어느 항목을 고치라는 말인지 닿지 않습니다. + * + * @return array 필드 => 표시명 + */ + public function attributes(): array + { + $plugin = app(PluginManager::class)->getPlugin($this->route('identifier')); + + if (! $plugin) { + return []; + } + + $locale = app()->getLocale(); + $fallback = config('app.fallback_locale', 'en'); + $attributes = []; + + foreach ($plugin->getSettingsSchema() as $field => $config) { + $label = $config['label'] ?? null; + + if (is_array($label)) { + $label = $label[$locale] ?? $label[$fallback] ?? reset($label); + } + + if (is_string($label) && $label !== '') { + $attributes[$field] = $label; + } + } + + return $attributes; + } } diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php index ff65b033..a7da06c5 100644 --- a/app/Http/Requests/Settings/SaveSettingsRequest.php +++ b/app/Http/Requests/Settings/SaveSettingsRequest.php @@ -226,6 +226,9 @@ class SaveSettingsRequest extends FormRequest 'upload.image_max_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_width_min', 100), 'max:'.config('core.settings_limits.upload_image_max_width_max', 10000)], 'upload.image_max_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_height_min', 100), 'max:'.config('core.settings_limits.upload_image_max_height_max', 10000)], 'upload.image_quality' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_quality_min', 1), 'max:'.config('core.settings_limits.upload_image_quality_max', 100)], + // 고아 첨부 정리 — 사용자 파일을 파기하므로 보존기간 하한을 서버가 강제한다. + 'upload.orphan_cleanup_enabled' => ['nullable', 'boolean'], + 'upload.orphan_retention_days' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_orphan_retention_days_min', 1), 'max:'.config('core.settings_limits.upload_orphan_retention_days_max', 3650)], // SEO 설정 'seo.meta_title_suffix' => ['nullable', 'string', 'max:100'], @@ -678,6 +681,10 @@ class SaveSettingsRequest extends FormRequest 'upload.image_quality.integer' => __('validation.settings.image_quality_integer'), 'upload.image_quality.min' => __('validation.settings.image_quality_min'), 'upload.image_quality.max' => __('validation.settings.image_quality_max'), + 'upload.orphan_cleanup_enabled.boolean' => __('validation.settings.orphan_cleanup_enabled_boolean'), + 'upload.orphan_retention_days.integer' => __('validation.settings.orphan_retention_days_integer'), + 'upload.orphan_retention_days.min' => __('validation.settings.orphan_retention_days_min'), + 'upload.orphan_retention_days.max' => __('validation.settings.orphan_retention_days_max'), // SEO 설정 'seo.meta_title_suffix.max' => __('validation.settings.meta_title_suffix_max'), @@ -914,6 +921,8 @@ class SaveSettingsRequest extends FormRequest 'upload.image_max_width' => __('validation.attributes.image_max_width'), 'upload.image_max_height' => __('validation.attributes.image_max_height'), 'upload.image_quality' => __('validation.attributes.image_quality'), + 'upload.orphan_cleanup_enabled' => __('validation.attributes.orphan_cleanup_enabled'), + 'upload.orphan_retention_days' => __('validation.attributes.orphan_retention_days'), // seo 'seo.meta_title_suffix' => __('validation.attributes.meta_title_suffix'), 'seo.meta_description' => __('validation.attributes.meta_description'), diff --git a/app/Repositories/AttachmentRepository.php b/app/Repositories/AttachmentRepository.php index bc9b2669..c53ed6b6 100644 --- a/app/Repositories/AttachmentRepository.php +++ b/app/Repositories/AttachmentRepository.php @@ -5,6 +5,7 @@ namespace App\Repositories; use App\Contracts\Repositories\AttachmentRepositoryInterface; use App\Models\Attachment; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; /** @@ -23,6 +24,17 @@ class AttachmentRepository implements AttachmentRepositoryInterface return Attachment::find($id); } + /** + * 소프트삭제된 행까지 포함해 ID로 첨부파일 조회 + * + * @param int $id 첨부파일 ID + * @return Attachment|null 첨부파일 또는 null + */ + public function findByIdWithTrashed(int $id): ?Attachment + { + return Attachment::withTrashed()->find($id); + } + /** * 여러 ID로 첨부파일 조회 (order 정렬) * @@ -283,4 +295,32 @@ class AttachmentRepository implements AttachmentRepositoryInterface } }); } + + /** + * 소유자 없이 방치된 고아 첨부 후보를 오래된 순으로 조회합니다. + * + * @param Carbon $threshold 기준 시각 + * @param int $limit 최대 조회 건수 + * @param array $protectedIds 보호할 첨부 ID 목록 + * @return Collection 고아 첨부 후보 + */ + public function findOrphanCandidates(Carbon $threshold, int $limit, array $protectedIds = []): Collection + { + $query = Attachment::withTrashed() + ->whereNull('attachmentable_type') + ->whereNull('attachmentable_id') + // 확장이 소유한 첨부는 그 확장의 라이프사이클 소관이므로 코어 GC 대상이 아니다. + ->whereNull('source_identifier') + ->where('created_at', '<', $threshold); + + if ($protectedIds !== []) { + $query->whereNotIn('id', $protectedIds); + } + + return $query + ->orderBy('created_at') + ->orderBy('id') + ->limit($limit) + ->get(['id', 'disk', 'path', 'collection', 'created_at']); + } } diff --git a/app/Services/AttachmentService.php b/app/Services/AttachmentService.php index e9d5864a..b0f48620 100644 --- a/app/Services/AttachmentService.php +++ b/app/Services/AttachmentService.php @@ -165,14 +165,93 @@ class AttachmentService } /** - * 첨부파일 삭제 + * 소유자 없이 방치된 고아 첨부를 정리합니다. + * + * 폼 저장 전에 즉시 업로드되는 첨부(FileUploader 등)는 소유자 없이 먼저 만들어지므로, + * 폼을 저장하지 않고 이탈하면 파일과 기록이 그대로 남습니다. + * + * 오탐을 막기 위해 아래 조건을 모두 만족하는 행만 대상으로 삼습니다. + * + * 1. 소유자(attachmentable_type/id)가 없다 + * 2. 업로드 후 보존기간이 지났다 (폼 작성 중 유예) + * 3. 확장 소유가 아니다 (source_identifier 없음 — 확장 라이프사이클 소관) + * 4. 현역으로 쓰이는 첨부가 아니다 (현재 사이트 로고 등) + * + * 4번 보호 조건은 이 메서드가 직접 해석합니다. 호출자가 넘기는 값으로 두면 그 인자를 + * 빠뜨린 호출 하나가 운영 중인 로고를 파기하는데, 나머지 세 조건은 조회의 불변식이라 + * 보호 조건만 규약으로 남는 비대칭이 생깁니다. + * + * 삭제는 단건 삭제 경로(delete)를 그대로 재사용하므로 파일 삭제·훅·로그가 동일합니다. + * + * @param int $days 보존기간(일) + * @param int $limit 한 회차에 처리할 최대 건수 + * @param bool $dryRun true 면 대상만 세고 삭제하지 않음 + * @return array{scanned: int, deleted: int, failed: int} 처리 결과 + */ + public function pruneOrphans(int $days, int $limit, bool $dryRun = false): array + { + $threshold = now()->subDays($days); + $candidates = $this->repository->findOrphanCandidates( + $threshold, + $limit, + $this->protectedAttachmentIds(), + ); + + $result = ['scanned' => $candidates->count(), 'deleted' => 0, 'failed' => 0]; + + if ($dryRun) { + return $result; + } + + foreach ($candidates as $candidate) { + $this->delete($candidate->id) ? $result['deleted']++ : $result['failed']++; + } + + return $result; + } + + /** + * 고아 정리에서 보호할 첨부 ID 목록을 반환합니다. + * + * 사이트 로고는 소유자(attachmentable) 없이 설정값이 직접 참조하는 첨부라 판정식만으로는 + * 고아와 구분되지 않습니다. 현역으로 설정에 실린 ID 를 명시적으로 제외합니다. + * + * @return array 보호할 첨부 ID 목록 + */ + private function protectedAttachmentIds(): array + { + $siteLogo = g7_core_settings('general.site_logo', []); + + if (! is_array($siteLogo)) { + return []; + } + + $ids = []; + + foreach ($siteLogo as $item) { + $id = is_array($item) ? ($item['id'] ?? null) : $item; + + if (is_numeric($id)) { + $ids[] = (int) $id; + } + } + + return array_values(array_unique($ids)); + } + + /** + * 첨부파일 삭제 (파일 + 기록 영구 삭제) + * + * 대상 조회는 소프트삭제된 행까지 포함한다. 이 메서드가 수행하는 것은 forceDelete 이므로 + * 이미 소프트삭제된 행도 정당한 대상이고, 기본 조회로 찾으면 고아 정리가 그 행을 영원히 + * 회수하지 못한 채 실패만 반복한다. * * @param int $id 첨부파일 ID * @return bool 삭제 성공 여부 */ public function delete(int $id): bool { - $attachment = $this->repository->findById($id); + $attachment = $this->repository->findByIdWithTrashed($id); if (! $attachment) { return false; diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 76d6ad26..d80d2d3d 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -29,7 +29,8 @@ class SettingsService public function __construct( private ConfigRepositoryInterface $configRepository, private AttachmentRepositoryInterface $attachmentRepository, - private CacheInterface $cache + private CacheInterface $cache, + private AttachmentService $attachmentService ) {} /** @@ -515,9 +516,18 @@ class SettingsService return $result; } - // general 탭인 경우 site_logo 첨부파일 연결 - if ($tab === 'general') { - $tabSettings['site_logo'] = $this->collectSiteLogoIds(); + // general 탭인 경우 site_logo 첨부파일 연결. + // site_logo 를 제출하지 않은 저장(다른 필드만 변경)은 기존 저장값을 그대로 둔다 — + // 이때 컬렉션을 다시 훑으면 미참조 첨부가 설정으로 딸려 들어온다. + $removedSiteLogoIds = []; + + if ($tab === 'general' && is_array($tabSettings['site_logo'] ?? null)) { + // 파기 대상 판정은 저장 **전에** 한다 — 저장 후에는 직전 저장값을 알 수 없다. + // 실제 파기는 저장이 성공한 뒤에 수행한다: 저장이 실패했는데 파일만 사라지면 + // 설정에는 이미 없는 첨부 id 가 남아 로고가 깨진다. + $removedSiteLogoIds = $this->resolveRemovedSiteLogoIds($tabSettings['site_logo']); + + $tabSettings['site_logo'] = $this->resolveSiteLogoIds($tabSettings['site_logo']); } // 기존 설정과 병합 (탭별로 일부 필드만 전송되어도 기존 설정 유지) @@ -536,6 +546,9 @@ class SettingsService if ($result) { $this->invalidateSettingsCache(); + // 저장이 확정된 뒤에야 파일을 파기한다 (위 판정 시점 주석 참조). + $this->purgeSiteLogoAttachments($removedSiteLogoIds); + // SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다. // 모드가 바뀌면 그 URL 들이 전부 어긋나는데, 봇은 JavaScript 를 실행하지 // 않아 브라우저 자가 복구가 닿지 않는다 → 캐시를 비워 재생성시킨다. @@ -684,15 +697,111 @@ class SettingsService } /** - * site_logo 컬렉션의 첨부파일 ID 목록을 수집합니다. + * 저장할 사이트 로고 첨부 ID 목록을 결정합니다. * - * @return array 첨부파일 ID 배열 + * 기준은 **이번 저장 요청이 제출한 목록**입니다. 컬렉션 전체를 다시 훑으면, 저장에 실패했거나 + * 작성 중 이탈해 남은 미참조 첨부까지 설정에 다시 편입되어(운영자가 올린 적 없는 로고가 + * 되살아나는) 누적이 발생합니다. + * + * 제출값에 있더라도 실제로 존재하지 않는 첨부(다른 경로로 이미 삭제된 id)는 걸러냅니다. + * + * @param array $submitted 제출된 site_logo 값 (첨부 객체 배열 또는 ID 배열) + * @return array 저장할 첨부파일 ID 배열 */ - private function collectSiteLogoIds(): array + private function resolveSiteLogoIds(array $submitted): array { - $attachments = $this->attachmentRepository->getByCollection('site_logo'); + $submittedIds = $this->extractAttachmentIds($submitted); - return $attachments->pluck('id')->toArray(); + if ($submittedIds === []) { + return []; + } + + $existingIds = $this->attachmentRepository->getByCollection('site_logo') + ->pluck('id') + ->all(); + + return array_values(array_intersect($submittedIds, $existingIds)); + } + + /** + * 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 사이트 로고 첨부 ID 를 가려냅니다. + * + * 판정 기준은 **직전 저장값**입니다. 직전에 저장돼 있었는데 이번 제출에서 빠진 id 만 + * 운영자가 명시적으로 뺀 것이고, 직전 저장값에도 없던 id 는 이번에 새로 올라온 첨부입니다. + * 그래서 이 판정은 저장으로 값이 덮이기 **전에** 수행해야 합니다. + * + * 저장할 목록 자체는 제출값이 정합니다(resolveSiteLogoIds) — 컬렉션 전체를 훑으면 저장에 + * 실패했거나 이탈로 남은 미참조 첨부가 설정에 되살아납니다. + * + * @param mixed $submitted 제출된 site_logo 값 (첨부 객체 배열 또는 ID 배열, 미제출이면 null) + * @return array 파기 대상 첨부 ID 목록 + */ + private function resolveRemovedSiteLogoIds(mixed $submitted): array + { + // site_logo 를 아예 제출하지 않은 저장(다른 필드만 변경)은 판정 대상이 아니다. + if (! is_array($submitted)) { + return []; + } + + $previousIds = $this->extractAttachmentIds( + $this->configRepository->getCategory('general')['site_logo'] ?? [] + ); + + if ($previousIds === []) { + return []; + } + + $keptIds = $this->extractAttachmentIds($submitted); + + return array_values(array_diff($previousIds, $keptIds)); + } + + /** + * 제거가 확정된 사이트 로고 첨부를 파일까지 파기합니다. + * + * 설정 저장이 성공한 뒤에만 호출합니다 — 저장이 실패했는데 파일이 먼저 사라지면 설정에는 + * 이미 없는 첨부 id 가 남아 로고가 깨집니다. + * + * @param array $removedIds 파기 대상 첨부 ID 목록 + */ + private function purgeSiteLogoAttachments(array $removedIds): void + { + if ($removedIds === []) { + return; + } + + foreach ($removedIds as $removedId) { + $this->attachmentService->delete($removedId); + } + + Log::info('사이트 로고 첨부 제거', ['attachment_ids' => $removedIds]); + } + + /** + * 첨부 목록 값에서 첨부 ID 만 추출합니다. + * + * 저장값은 ID 배열이지만 화면 제출값은 첨부 객체 배열이라 두 형태를 모두 받습니다. + * + * @param mixed $value 첨부 목록 값 + * @return array 첨부 ID 목록 + */ + private function extractAttachmentIds(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $ids = []; + + foreach ($value as $item) { + $id = is_array($item) ? ($item['id'] ?? null) : $item; + + if (is_numeric($id)) { + $ids[] = (int) $id; + } + } + + return array_values(array_unique($ids)); } /** diff --git a/config/core.php b/config/core.php index 0202098e..3609bbef 100644 --- a/config/core.php +++ b/config/core.php @@ -25,6 +25,8 @@ return [ 'upload_image_max_height_max' => 10000, 'upload_image_quality_min' => 1, 'upload_image_quality_max' => 100, + 'upload_orphan_retention_days_min' => 1, + 'upload_orphan_retention_days_max' => 3650, // SEO 'seo_og_image_default_width_min' => 0, diff --git a/config/settings/defaults.json b/config/settings/defaults.json index ebc2847c..30a388b7 100644 --- a/config/settings/defaults.json +++ b/config/settings/defaults.json @@ -49,7 +49,9 @@ "allowed_extensions": ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "xls", "xlsx", "zip"], "image_max_width": 2000, "image_max_height": 2000, - "image_quality": 85 + "image_quality": 85, + "orphan_cleanup_enabled": false, + "orphan_retention_days": 30 }, "seo": { "meta_title_suffix": "", @@ -202,7 +204,9 @@ "allowed_extensions_formatted": { "type": "string", "sensitive": false, "computed": true, "source": "allowed_extensions", "transform": "format_extensions" }, "image_max_width": { "type": "integer", "sensitive": false }, "image_max_height": { "type": "integer", "sensitive": false }, - "image_quality": { "type": "integer", "sensitive": false, "expose": false } + "image_quality": { "type": "integer", "sensitive": false, "expose": false }, + "orphan_cleanup_enabled": { "type": "boolean", "sensitive": false, "expose": false }, + "orphan_retention_days": { "type": "integer", "sensitive": false, "expose": false } } }, "seo": { diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md index a7157a6b..88cec79c 100644 --- a/docs/backend/api/settings.md +++ b/docs/backend/api/settings.md @@ -202,6 +202,8 @@ HTTP/1.1 200 | upload.image_max_width | body | integer | 아니오 | min 100, max 10000 | 이미지 리사이즈 최대 너비 (px) | | upload.image_max_height | body | integer | 아니오 | min 100, max 10000 | 이미지 리사이즈 최대 높이 (px) | | upload.image_quality | body | integer | 아니오 | min 1, max 100 | 이미지 리사이즈 시 압축 품질 (1~100) | +| upload.orphan_cleanup_enabled | body | boolean | 아니오 | — | 고아 첨부(소유자 없이 남은 첨부) 자동 정리 활성화 여부. **기본 false** — 사용자 파일을 실제로 파기하므로 운영자가 직접 켜야 동작하며, 예약 작업(`attachments:prune-orphans --scheduled`)이 이 값을 false 폴백으로 재확인한다 | +| upload.orphan_retention_days | body | integer | 아니오 | min 1, max 3650 | 고아 첨부 보존기간(일, 기본 30). 폼 작성 중 이탈을 감안한 유예 기간이다. 하한·상한은 `config('core.settings_limits.upload_orphan_retention_days_*')` 가 정하며 화면 입력도 같은 값을 읽는다 | | seo.meta_title_suffix | body | string | 아니오 | max 100 | 모든 페이지 SEO 제목 뒤에 붙는 접미 문구 | | seo.meta_description | body | string | 아니오 | max 160 | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약) | | seo.meta_keywords | body | string | 아니오 | max 255 | SEO 메타 키워드 (검색엔진 노출 키워드, 쉼표 구분) | @@ -654,14 +656,14 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('settings.backup_success')` 를 데이터 없이 호출하므로 `data` 는 `null` 입니다._ +_이 엔드포인트는 성공 응답을 반환하지 않습니다. 데이터베이스 백업 기능이 아직 제공되지 않아 항상 501 로 응답합니다._ **응답 예시** ```json { - "success": true, - "message": "데이터베이스 백업이 성공적으로 시작되었습니다.", + "success": false, + "message": "데이터베이스 백업 기능은 아직 제공하지 않습니다. 설정 백업은 설정 백업 기능을 이용해 주세요.", "data": null } ``` @@ -670,16 +672,17 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 400 | Bad Request | 백업이 수행되지 않은 경우 (`settings.backup_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | -| 500 | Internal Server Error | 백업 처리 중 예외가 발생한 경우 (`settings.backup_error`) | +| 501 | Not Implemented | 항상 (데이터베이스 백업 미제공, `settings.database_backup_unavailable`) | **설명** -데이터베이스를 백업합니다. SettingsService 에 위임하며, 성공/실패를 메시지로 반환합니다. 설정 백업(`POST /backup`)이 설정 파일만 다루는 것과 달리, 이 엔드포인트는 DB 데이터를 백업 대상으로 합니다. +**데이터베이스 백업은 아직 제공하지 않습니다.** 이 엔드포인트는 라우트만 존재하며 호출하면 항상 501(Not Implemented)로 응답합니다. 코어에는 데이터베이스 덤프 수단이 없어 구현된 적이 없으며, 관리자 화면에도 이 기능을 호출하는 지점이 없습니다. + +설정 파일 백업이 필요하면 `POST /api/admin/settings/backup` 을 사용하십시오 — 그쪽은 정상 동작하며 생성된 백업 경로를 `data.backup_path` 로 돌려줍니다. ### POST /api/admin/settings/clear-cache diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md index 0150b6b2..3274ced7 100644 --- a/docs/extension/hooks.md +++ b/docs/extension/hooks.md @@ -148,7 +148,14 @@ sirsoft-page.attachment.filter_upload_file # 페이지 첨부 sirsoft-ecommerce.product-image.filter_upload_file # 상품 이미지 sirsoft-ecommerce.category-image.filter_upload_file # 카테고리 이미지 sirsoft-ecommerce.review-image.filter_upload_file # 리뷰 이미지 (공개 #96) +sirsoft-ckeditor5.image.before_upload # 에디터 이미지 sirsoft-ckeditor5.image.filter_upload_file # 에디터 이미지 +sirsoft-ckeditor5.image.after_upload # 에디터 이미지 + +# 업로드 잔존물 회수 — 어떤 콘텐츠에서도 쓰이지 않는 에디터 이미지의 참조 판정 대상 선언 (필터) +# 자기 콘텐츠를 가진 확장이 이 훅으로 테이블/컬럼을 등록하지 않으면, 그 확장에서만 쓰이는 +# 이미지가 "미참조" 로 판정돼 자동 정리 대상이 된다. +sirsoft-ckeditor5.image.filter_reference_sources # 에디터 이미지 참조 소스 # FormRequest Validation Rules 훅 (Filter) core.user.create_validation_rules @@ -238,6 +245,41 @@ core.seo.sitemap.after_regenerate_failed # 재생성 실패 시 (['status'=>'fai `core.settings.available_public_asset_drivers` 로 등록하는 디스크는 플러그인 ServiceProvider 에서 `filesystems.disks.{id}` 정의가 함께 있어야 하며, 그 정의에 `url` 키가 있어야 직접 URL 이 생성됩니다(없으면 스트리밍 폴백). 플러그인이 비활성화되어 디스크 정의가 사라지면 저장값은 보존된 채 스트리밍으로 자동 폴백합니다. +#### `sirsoft-ckeditor5.image.filter_reference_sources` + +에디터 업로드 이미지가 "어디선가 쓰이고 있는지" 판정할 때 훑을 테이블/컬럼 목록을 확장합니다. 첫 인자인 소스 배열에 다음 구조의 항목을 append 합니다. + +| 키 | 타입 | 값 | +| --- | --- | --- | +| `table` | string | 테이블명 — **프리픽스 제외 원시 이름** (`board_posts`) | +| `columns` | list\ | 본문이 담긴 컬럼명 목록 (`['content']`) | + +자기 콘텐츠에 에디터를 노출하는 확장은 이 훅을 반드시 구독해야 합니다. 등록하지 않으면 그 확장에서만 쓰이는 이미지가 "미참조" 로 판정돼 자동 정리 대상이 됩니다. + +리스너는 테이블명 문자열만 덧붙이고 DB 에 접근하지 않습니다 — 실재 검증(테이블·컬럼 존재)과 조회는 플러그인이 수행하며, 존재하지 않는 선언은 경고만 남기고 건너뜁니다(한 확장의 잘못된 선언이 판정 전체를 멈추지 않습니다). + +로그 사본 테이블(발송 이력·신고 스냅샷 등)은 등록하지 않습니다. 이들은 자체 보존기간으로 삭제되므로 참조 소스로 삼으면 "로그가 지워지는 순간 이미지가 고아가 되는" 역전이 생깁니다. + +```php +public static function getSubscribedHooks(): array +{ + return [ + 'sirsoft-ckeditor5.image.filter_reference_sources' => [ + 'method' => 'addSources', + 'priority' => 10, + 'type' => 'filter', + ], + ]; +} + +public function addSources(array $sources): array +{ + $sources[] = ['table' => 'my_documents', 'columns' => ['body']]; + + return $sources; +} +``` + --- ## 리스너 구현 diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 99917d75..23a32aa3 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -16,6 +16,8 @@ - 설정 항목 단위 저장의 값 형식 안내 일본어 번역을 추가했습니다 (`validation.setting.value.*`) — 켜기/끄기·숫자 설정에 맞지 않는 값을 저장할 때의 안내가 일본어 로케일에서 표시됩니다. - 슈퍼 관리자 계정·역할 수정 상한 및 권한 부여 상한 위반 시 표시되는 예외 메시지(`cannot_modify_super_admin`, `cannot_grant_unheld_permission`, `cannot_modify_protected_role`)의 일본어 번역을 추가했습니다. - 레이아웃 저장 시 위험 표현식·외부 리소스 URL 거부 안내(`layout.dangerous_expression`, `layout.external_resource_url`)의 일본어 번역을 추가했습니다. +- 고아 첨부 정리 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`validation.settings.orphan_*`) — 환경설정 > 업로드의 자동 정리·보존기간을 잘못 저장할 때의 안내가 일본어 로케일에서 항목 이름과 함께 표시됩니다. +- 데이터베이스 백업 미제공 안내 문구의 일본어 번역을 추가했습니다 (`settings.database_backup_unavailable`). ### Changed diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php index 5aa02a6b..d6cc8a53 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php @@ -20,6 +20,7 @@ return [ 'backup_success' => 'データベースバックアップが正常に開始されました。', 'backup_failed' => 'データベースバックアップに失敗しました。', 'backup_error' => 'データベースバックアップ中にエラーが発生しました。', + 'database_backup_unavailable' => 'データベースバックアップ機能はまだ提供されていません。設定のバックアップは設定バックアップ機能をご利用ください。', 'backup_path_required' => 'バックアップパスを入力してください。', 'restore_success' => '設定が正常に復元されました。', 'restore_failed' => '設定の復元に失敗しました。', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index ba6553f3..840c90bd 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -912,7 +912,7 @@ return [ 's3_endpoint_invalid' => '正しいS3エンドポイントURL形式ではありません。', 's3_endpoint_max' => 'S3エンドポイントURLは500字を超えることはできません。', 's3_use_path_style_boolean' => 'Path-styleアドレス使用の設定はtrueまたはfalseである必要があります。', - 'driver_unusable' => "':driver' ドライバーはこのサーバーでは使用できません。:reason", + 'driver_unusable' => '\':driver\' ドライバーはこのサーバーでは使用できません。:reason', 'cache_driver_required' => 'キャッシュドライバーを選択してください。', 'cache_driver_invalid' => '正しいキャッシュドライバーを選択してください。', 'redis_host_max' => 'Redisホストは255字を超えることはできません。', @@ -994,6 +994,10 @@ return [ 'pagination_max_page_integer' => 'ページ番号上限は数値である必要があります。', 'pagination_max_page_min' => 'ページ番号上限は:min以上である必要があります。(0 = 無制限)', 'pagination_max_page_max' => 'ページ番号上限は:maxを超えることはできません。', + 'orphan_cleanup_enabled_boolean' => '孤立した添付ファイルの自動クリーンアップは有効化または無効化のいずれかである必要があります。', + 'orphan_retention_days_integer' => '孤立した添付ファイルの保持期間は整数である必要があります。', + 'orphan_retention_days_min' => '孤立した添付ファイルの保持期間は1日以上である必要があります。', + 'orphan_retention_days_max' => '孤立した添付ファイルの保持期間は3650日を超えることはできません。', ], 'identity_policy' => [ 'key_required' => 'ポリシーキーを入力してください。', @@ -1174,6 +1178,8 @@ return [ 'log_level' => 'ログレベル', 'log_days' => 'ログ保持日数', 'public_asset_disk' => '公開アセットディスク', + 'orphan_cleanup_enabled' => '孤立した添付ファイルの自動クリーンアップ', + 'orphan_retention_days' => '孤立した添付ファイルの保持期間', ], 'layout_version' => [ 'limit' => [ diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md index 283e70bf..70618261 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md @@ -10,6 +10,7 @@ - 게시판 유형 삭제 등 관리 작업이 서버 오류로 실패했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다. - 비공개(비밀) 게시글 목록 마스킹 시 표시되는 제목 플레이스홀더(`secret_post_title`)의 일본어 번역을 추가했습니다. +- 첨부 영구 정리 설정의 라벨·도움말과 검증 항목명 일본어 번역을 추가했습니다 (`admin/settings.attachment_purge_*`, `validation.attributes.attachment_settings.*`) — 게시판 환경설정 > 기본 설정의 새 항목과 저장 시 오류 안내가 일본어 로케일에서 표시됩니다. ### Changed diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php index 80da450a..3b7d0dd6 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php @@ -349,6 +349,8 @@ return [ 'override_values.max_file_size' => '最大ファイルサイズ', 'override_values.max_file_count' => '最大ファイル個数', 'override_values.new_display_hours' => '新規表示時間', + 'attachment_settings.purge_enabled' => '削除添付の永久クリーンアップ', + 'attachment_settings.purge_retention_days' => '削除添付の保持期間', ], ], 'blind' => [ diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json index 1d376654..974e6849 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json @@ -60,7 +60,9 @@ "report_notification": "通報通知", "report_notification_description": "通報受け付け時に通知を受け取る管理者と通知発送方式を設定します。", "report_permissions": "通報管理権限", - "report_permissions_description": "通報リスト閲覧および通報処理(承認·却下·ブラインド)を実行できるロールを指定します。通報受け付け通知は「通報処理権限」を持つロール基準で発送されます。" + "report_permissions_description": "通報リスト閲覧および通報処理(承認·却下·ブラインド)を実行できるロールを指定します。通報受け付け通知は「通報処理権限」を持つロール基準で発送されます。", + "attachment_cleanup": "添付ファイルの整理", + "attachment_cleanup_description": "削除された添付ファイルの物理ファイルをいつ廃棄するかを設定します。投稿作成中にアップロードされたが保存されなかった一時的な添付は、この設定に関わらず毎日自動で整理されます。" }, "fields": { "type": "掲示板タイプ", @@ -176,7 +178,11 @@ "max_file_size": "アップロード可能な最大ファイルサイズです (MB単位、{{min}}~{{max}})", "max_file_count": "投稿あたり添付可能な最大ファイル数です ({{min}}~{{max}})", "allowed_extensions": "許可するファイル拡張子を最低1つ以上入力してください" - } + }, + "attachment_purge_enabled": "削除済み添付ファイルの永久削除", + "attachment_purge_enabled_hint": "デフォルトは無効です。運営者が有効にしたときにのみ毎日の自動整理が動作します。有効にすると、削除されてから保存期間が経過した添付ファイルとそのレコードが永久削除されます。サーバーで直接実行する整理コマンドはこの設定に左右されません。", + "attachment_purge_retention_days": "削除済み添付ファイル保存期間(日)", + "attachment_purge_retention_days_hint": "ゴミ箱から投稿を復元すると、この期間内の添付ファイルは一緒に復元されます。ゴミ箱の復元可能期間と同じに保つようにしてください。(1~3650 日)" }, "options": { "date_display_format": { diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md index 36adcbf8..bbbe4033 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md @@ -9,6 +9,10 @@ ### Added - 에디터 설정의 "공개 자산 디스크" 설정 문구 일본어 번역을 추가했습니다. +- 「에디터 업로드 이미지」 관리 화면의 문구 일본어 번역을 추가했습니다 (`admin.uploads.*`) — 목록 제목·열 이름·참조 상태 배지·필터·삭제 확인 창과 판정 범위 안내가 일본어 로케일에서 표시됩니다. +- 미사용 이미지 자동 정리 설정의 라벨·도움말 일본어 번역을 추가했습니다 (`settings.section_cleanup`, `settings.cleanup.*`). +- 업로드 이미지 삭제 시의 안내·오류 메시지 일본어 번역을 추가했습니다 (`messages.uploads.*`, `messages.cleanup.*`). +- 업로드 이미지 조회·삭제 권한의 이름과 설명 일본어 번역을 추가했습니다 — 역할 권한 화면에서 일본어 로케일로 표시됩니다. ## [1.0.0] - 2026-07-01 diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/backend/ja/messages.php index 8926889d..14271cde 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/backend/ja/messages.php @@ -13,4 +13,15 @@ return [ 'forbidden' => '画像アップロード権限がありません。', 'failed' => '画像アップロードに失敗しました。', ], + 'uploads' => [ + 'not_found' => 'アップロード画像が見つかりません。', + 'file_delete_failed' => '画像ファイルの削除に失敗しました。しばらく後に再度お試しください。', + 'ids_required' => '削除する画像を選択してください。', + 'deleted' => '画像を削除しました。', + 'bulk_deleted' => '選択した画像 :deleted件を削除しました。', + 'ids_invalid' => '選択した画像の中に存在しないアイテムがあります。リストをリフレッシュした後、再度選択してください。', + ], + 'cleanup' => [ + 'retention_disabled' => '保存期間が1日未満に設定されているため、クリーンアップを実行しませんでした。', + ], ]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json index 1705344e..0c63715f 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json @@ -1,7 +1,8 @@ { "editor": { "data_source": { - "settings": "エディター設定" + "settings": "エディター設定", + "ckeditor5Uploads": "アップロード画像一覧" } }, "settings": { @@ -46,6 +47,60 @@ "step3": "画像アップロードを有効化するとエディターで画像を直接貼り付けたりアップロードできます。アップロードされた画像はサーバーに保存されます。", "step4": "設定変更後に保存すると即座にすべてのエディターに反映されます。", "step5": "プラグインを無効化するとエディターは基本 HtmlEditor に、コンテンツ表示は基本 HtmlContent に自動復元されます。" + }, + "section_cleanup": "未使用画像の整理", + "cleanup": { + "toggle_label": "未使用画像の自動整理", + "toggle_hint": "デフォルトはオフです。有効にするには運営者が手動で有効にする必要があります。有効にすると、毎日1回、保存期間を過ぎた未使用画像のファイルと記録を一緒に削除します。", + "retention_label": "保存期間(日)", + "retention_hint": "アップロード後、この期間を過ぎた未使用画像のみが整理対象になります。(1~3650日)", + "manual_hint": "今何が削除されるか事前に確認したい場合は、サーバーで次のコマンドを実行してください:php artisan sirsoft-ckeditor5:prune-unused-images --dry-run", + "open_uploads": "アップロード画像管理を開く" + } + }, + "admin": { + "uploads": { + "title": "エディターアップロード画像", + "description": "エディターでアップロードされた画像を確認し、使用されなくなった画像を整理します。", + "refresh": "更新", + "open_settings": "整理設定を開く", + "reference_notice": "参照状態は投稿・ページ・商品説明などの登録されたコンテンツを確認して判定します。参照ソースを登録していない外部拡張のコンテンツのみで使用されている画像は「未参照」と表示される場合があるため、削除前に確認してください。", + "scan_limited_notice": "参照状態フィルターは最近{{count}}件を基準として判定します。それより古い画像はこのリストに表示されません。", + "empty": "アップロードされた画像がありません。", + "col": { + "thumbnail": "プレビュー", + "original_name": "元のファイル名", + "file_size": "サイズ", + "uploader": "アップローダー", + "created_at": "アップロード日時", + "referenced": "参照状態" + }, + "badge": { + "referenced": "参照済み", + "unreferenced": "未参照" + }, + "filter": { + "search_placeholder": "元のファイル名またはハッシュで検索", + "search_button": "検索", + "referenced_all": "すべての状態", + "referenced_only": "参照済みのみ", + "unreferenced_only": "未参照のみ", + "date_from": "開始日", + "date_to": "終了日", + "clear": "フィルターをリセット" + }, + "delete": { + "button": "削除", + "confirm_title": "画像を削除しますか?", + "confirm_message": "選択した画像のファイルと記録が一緒に削除されます。この操作は取り消せません。", + "success": "画像を削除しました。" + }, + "bulk_delete": { + "button": "選択削除", + "selected": "{{count}}件選択されました", + "confirm_title": "選択した画像を削除しますか?", + "confirm_message": "選択した{{count}}件のファイルと記録が一緒に削除されます。この操作は取り消せません。" + } } } } diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/seed/permissions.json b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/seed/permissions.json new file mode 100644 index 00000000..86168eb6 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/seed/permissions.json @@ -0,0 +1,18 @@ +{ + "sirsoft-ckeditor5": { + "name": "CKEditor 5 WYSIWYG エディター", + "description": "CKEditor5 プラグインが提供する権限" + }, + "sirsoft-ckeditor5.uploads": { + "name": "エディター アップロード画像", + "description": "エディターにアップロードされた画像の閲覧・削除権限" + }, + "sirsoft-ckeditor5.uploads.read": { + "name": "アップロード画像の閲覧", + "description": "エディター アップロード画像一覧・参照状態の閲覧" + }, + "sirsoft-ckeditor5.uploads.delete": { + "name": "アップロード画像の削除", + "description": "エディター アップロード画像のファイル・記録の削除" + } +} diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 2d870944..c2eee9a9 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -13,6 +13,7 @@ - 회원 정보 수정 화면의 탈퇴 확인 창 문구 일본어 번역 추가 (`admin.users.modals.withdraw_confirm_*`) — 되돌릴 수 없는 처리라는 안내와 함께 처리되는 항목 목록이 일본어 로케일에서 표시됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.) - 드라이버 선택란에 저장된 값이 현재 사용할 수 없는 값일 때 표시되는 안내 문구 일본어 번역을 추가했습니다. +- 환경설정 > 업로드 탭의 「고아 첨부 정리」 카드 문구 일본어 번역을 추가했습니다 (`settings.upload.orphan_cleanup*`, `orphan_retention_days*`) — 자동 정리 토글과 보존기간 입력의 라벨·도움말이 일본어 로케일에서 표시됩니다. ### Changed diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index 2ba9195f..231be2e9 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1392,7 +1392,13 @@ "image_max_width": "最大幅", "image_max_height": "最大高さ", "image_quality": "画像品質", - "image_quality_hint": "1〜100の値を入力してください。値が大きいほど品質が良くなります。" + "image_quality_hint": "1〜100の値を入力してください。値が大きいほど品質が良くなります。", + "orphan_cleanup": "孤立した添付ファイルのクリーンアップ", + "orphan_cleanup_desc": "フォームを保存せずに離脱して、所有者がいなくなった添付ファイルをクリーンアップします。", + "orphan_cleanup_enabled": "孤立した添付ファイルの自動クリーンアップ", + "orphan_cleanup_enabled_hint": "デフォルトはオフです — 管理者が直接有効化する必要があります。有効化すると、保持期間を過ぎた孤立した添付ファイルのファイルと記録を毎日削除します。サイトロゴなど現在使用中の添付ファイルは削除しません。", + "orphan_retention_days": "孤立した添付ファイルの保持期間(日数)", + "orphan_retention_days_hint": "アップロード後この期間が経過した孤立した添付ファイルのみがクリーンアップの対象になります。フォーム作成中の中断を考慮して、余裕を持って設定してください。(1~3650日)" }, "seo": { "meta_settings": "メタタグ設定", diff --git a/lang/en/settings.php b/lang/en/settings.php index 67fa405a..8b3f2797 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -21,6 +21,7 @@ return [ 'backup_success' => 'Database backup started successfully.', 'backup_failed' => 'Failed to start database backup.', 'backup_error' => 'An error occurred while backing up database.', + 'database_backup_unavailable' => 'Database backup is not available yet. Use the settings backup feature to back up your settings.', 'backup_path_required' => 'Please enter a backup path.', 'restore_success' => 'Settings restored successfully.', 'restore_failed' => 'Failed to restore settings.', diff --git a/lang/en/validation.php b/lang/en/validation.php index 18a1ed1b..df6a093f 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -960,6 +960,10 @@ return [ 'image_quality_integer' => 'Image quality must be an integer.', 'image_quality_min' => 'Image quality must be at least 1.', 'image_quality_max' => 'Image quality may not be greater than 100.', + 'orphan_cleanup_enabled_boolean' => 'Orphan attachment cleanup must be enabled or disabled.', + 'orphan_retention_days_integer' => 'Orphan attachment retention period must be an integer.', + 'orphan_retention_days_min' => 'Orphan attachment retention period must be at least 1 day.', + 'orphan_retention_days_max' => 'Orphan attachment retention period may not be greater than 3650 days.', // SEO settings 'meta_title_suffix_max' => 'Meta title suffix may not be greater than 100 characters.', @@ -1205,6 +1209,8 @@ return [ 'image_max_width' => 'max image width', 'image_max_height' => 'max image height', 'image_quality' => 'image quality', + 'orphan_cleanup_enabled' => 'orphan attachment cleanup', + 'orphan_retention_days' => 'orphan attachment retention period', // SEO settings 'meta_title_suffix' => 'meta title suffix', 'meta_description' => 'meta description', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 93bff45d..7480e8ed 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -21,6 +21,7 @@ return [ 'backup_success' => '데이터베이스 백업이 성공적으로 시작되었습니다.', 'backup_failed' => '데이터베이스 백업에 실패했습니다.', 'backup_error' => '데이터베이스 백업 중 오류가 발생했습니다.', + 'database_backup_unavailable' => '데이터베이스 백업 기능은 아직 제공하지 않습니다. 설정 백업은 설정 백업 기능을 이용해 주세요.', 'backup_path_required' => '백업 경로를 입력해주세요.', 'restore_success' => '설정이 성공적으로 복원되었습니다.', 'restore_failed' => '설정 복원에 실패했습니다.', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 760fc033..6a79544e 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -899,6 +899,10 @@ return [ 'image_quality_integer' => '이미지 품질은 정수여야 합니다.', 'image_quality_min' => '이미지 품질은 1 이상이어야 합니다.', 'image_quality_max' => '이미지 품질은 100을 초과할 수 없습니다.', + 'orphan_cleanup_enabled_boolean' => '고아 첨부 자동 정리는 사용/사용 안 함 중 하나여야 합니다.', + 'orphan_retention_days_integer' => '고아 첨부 보존기간은 정수여야 합니다.', + 'orphan_retention_days_min' => '고아 첨부 보존기간은 1일 이상이어야 합니다.', + 'orphan_retention_days_max' => '고아 첨부 보존기간은 3650일을 초과할 수 없습니다.', // SEO 설정 'meta_title_suffix_max' => '타이틀 접미사는 100자를 초과할 수 없습니다.', @@ -1198,6 +1202,8 @@ return [ 'image_max_width' => '이미지 최대 너비', 'image_max_height' => '이미지 최대 높이', 'image_quality' => '이미지 품질', + 'orphan_cleanup_enabled' => '고아 첨부 자동 정리', + 'orphan_retention_days' => '고아 첨부 보존기간', // SEO 설정 필드 'meta_title_suffix' => '메타 타이틀 접미사', 'meta_description' => '메타 설명', diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index b061d4f9..5b718344 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -13,6 +13,11 @@ - 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 회원 화면은 작성자 본인만 삭제하도록 막고 있었지만 관리 화면에는 같은 확인이 없었고, 순서 변경은 양쪽 모두 확인이 없었습니다. 순서는 목록 전체에 대한 하나의 값이라 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부합니다. (KVE-2026-1919) - 게시판을 찾을 수 없을 때 비밀글 보호가 통과되던 문제를 수정했습니다. 첨부파일 서빙과 댓글 목록은 부모 글을 찾지 못하면 검사를 건너뛰고 진행해, 게이트가 있어야 할 자리가 비어 있었습니다. 이제 부모 글을 확인할 수 없으면 차단합니다. +### Added + +- 글쓰기 중 파일만 올리고 저장하지 않은 채 나간 임시 첨부를 매일 자동 정리합니다. 종전에는 이런 파일이 서버에 계속 쌓여도 회수할 방법이 없었습니다. +- 삭제한 첨부의 파일을 보존기간이 지난 뒤 완전히 파기하는 기능이 추가되었습니다. 게시판 환경설정 > 기본 설정에서 켤 수 있으며(기본 꺼짐), 보존기간은 휴지통 복원 가능 기간과 같게 유지하시면 됩니다. + ### Changed - 글 저장·수정 시 비밀글 여부 값을 문자열(`"true"`/`"false"`)로 보내는 클라이언트도 수용하도록 해석을 관대화했습니다. 해석할 수 없는 값은 종전과 동일하게 거부됩니다. diff --git a/modules/_bundled/sirsoft-board/config/board.php b/modules/_bundled/sirsoft-board/config/board.php index 16f4a55b..2e6af244 100644 --- a/modules/_bundled/sirsoft-board/config/board.php +++ b/modules/_bundled/sirsoft-board/config/board.php @@ -155,6 +155,10 @@ return [ 'max_comment_depth_min' => 0, 'max_comment_depth_max' => 10, + // 삭제 첨부 영구 정리 보존기간 (일) + 'attachment_purge_retention_days_min' => 1, + 'attachment_purge_retention_days_max' => 3650, + // 신고 정책 'auto_hide_threshold_min' => 0, 'auto_hide_threshold_max' => 100, diff --git a/modules/_bundled/sirsoft-board/config/settings/defaults.json b/modules/_bundled/sirsoft-board/config/settings/defaults.json index f7e2e7dc..01bb5f03 100644 --- a/modules/_bundled/sirsoft-board/config/settings/defaults.json +++ b/modules/_bundled/sirsoft-board/config/settings/defaults.json @@ -2,7 +2,7 @@ "_meta": { "version": "1.0.0", "description": "게시판 모듈 환경설정", - "categories": ["basic_defaults", "report_policy", "spam_security", "display", "seo", "notifications"] + "categories": ["basic_defaults", "report_policy", "spam_security", "display", "seo", "notifications", "attachment_settings"] }, "defaults": { "basic_defaults": { @@ -100,6 +100,10 @@ { "id": "mail", "is_active": true, "sort_order": 0 }, { "id": "database", "is_active": true, "sort_order": 1 } ] + }, + "attachment_settings": { + "purge_enabled": false, + "purge_retention_days": 30 } }, "frontend_schema": { @@ -118,6 +122,9 @@ }, "seo": { "expose": false + }, + "attachment_settings": { + "expose": false } } } \ No newline at end of file diff --git a/modules/_bundled/sirsoft-board/docs/api/settings.md b/modules/_bundled/sirsoft-board/docs/api/settings.md index ca8fa8b5..cb4119f4 100644 --- a/modules/_bundled/sirsoft-board/docs/api/settings.md +++ b/modules/_bundled/sirsoft-board/docs/api/settings.md @@ -393,6 +393,9 @@ HTTP/1.1 200 | seo.seo_boards | body | boolean | 아니오 | — | 게시판 목록 페이지의 SEO 페이지 생성 활성화 여부 | | seo.seo_board | body | boolean | 아니오 | — | 개별 게시판 페이지의 SEO 페이지 생성 활성화 여부 | | seo.seo_post_detail | body | boolean | 아니오 | — | 게시글 상세 페이지의 SEO 페이지 생성 활성화 여부 | +| attachment_settings | body | array | 아니오 | — | 첨부 정리 정책 카테고리 값. 삭제된 첨부의 물리 파일을 언제 파기할지 정합니다. | +| attachment_settings.purge_enabled | body | boolean | 아니오 | — | 삭제 첨부 영구 정리 활성화 여부. **기본 false** — 사용자 파일을 실제로 파기하므로 운영자가 직접 켜야 동작합니다. 예약 작업(`sirsoft-board:prune-attachments --scheduled`)이 이 값을 false 폴백으로 재확인합니다. | +| attachment_settings.purge_retention_days | body | integer | 아니오 | min 1, max 3650 | 삭제 첨부 보존기간(일, 기본 30). 게시글을 휴지통에서 복원하면 이 기간 안의 첨부는 함께 복원되므로, 휴지통 복원 가능 기간과 같게 유지해야 합니다. 하한·상한은 `config('sirsoft-board.limits')` 가 정하며 화면 입력도 같은 값을 읽습니다. | **요청 예시** diff --git a/modules/_bundled/sirsoft-board/module.php b/modules/_bundled/sirsoft-board/module.php index bd478cd0..6abef201 100644 --- a/modules/_bundled/sirsoft-board/module.php +++ b/modules/_bundled/sirsoft-board/module.php @@ -14,6 +14,7 @@ use Modules\Sirsoft\Board\Listeners\BoardCommentsCountSyncListener; use Modules\Sirsoft\Board\Listeners\BoardNotificationChannelListener; use Modules\Sirsoft\Board\Listeners\BoardNotificationDataListener; use Modules\Sirsoft\Board\Listeners\BoardPostsCountSyncListener; +use Modules\Sirsoft\Board\Listeners\Ckeditor5ReferenceSourcesListener; use Modules\Sirsoft\Board\Listeners\CommentReplySyncListener; use Modules\Sirsoft\Board\Listeners\EcommerceInquiryHookListener; use Modules\Sirsoft\Board\Listeners\PostAttachmentCountSyncListener; @@ -72,6 +73,15 @@ class Module extends AbstractModule 'schedule' => 'hourly', 'description' => '대시보드 게시물 현황 집계', ], + [ + 'command' => 'sirsoft-board:prune-attachments --scheduled', + 'schedule' => 'daily', + 'description' => '방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리', + // 임시 첨부 정리 파트는 상시 동작해야 하므로 스케줄 자체는 항상 등록한다. + // 사용자 파일을 실제로 파기하는 영구 정리 파트만 커맨드 내부에서 게이트한다 + // (attachment_settings.purge_enabled, false 폴백). + 'enabled_config' => null, + ], ]; } @@ -416,6 +426,7 @@ class Module extends AbstractModule CommentReplySyncListener::class, BoardPostsCountSyncListener::class, BoardCommentsCountSyncListener::class, + Ckeditor5ReferenceSourcesListener::class, ]; } diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-attachment-cleanup-settings.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-attachment-cleanup-settings.test.ts new file mode 100644 index 00000000..f89434b9 --- /dev/null +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-attachment-cleanup-settings.test.ts @@ -0,0 +1,187 @@ +/** + * 게시판 환경설정 — 첨부 정리 섹션 계약 테스트 (공개 #115) + * + * @description + * 삭제 첨부 영구 정리는 사용자 파일을 파기하므로 기본 꺼짐이다. 화면 바인딩 키가 + * 저장 카테고리·기본값·서버 검증 세 지점과 일치하는지를 고정한다. 한 곳만 어긋나도 + * 저장은 성공하는데 값이 조용히 버려진다. + * + * @effects board_settings_purge_toggle_default_off, board_settings_purge_keys_declared + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import generalTab from '../../../layouts/admin/partials/admin_board_settings/_tab_general.json'; + +/** + * 모듈 루트(module.json 기준)를 위로 훑어 찾는다. + * + * @returns 모듈 루트 절대경로 + */ +function moduleRoot(): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 10; depth++) { + if (fs.existsSync(path.join(current, 'module.json'))) { + return current; + } + current = path.dirname(current); + } + + throw new Error('module.json 을 가진 모듈 루트를 찾지 못했습니다.'); +} + +/** + * 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다. + * + * @param node 탐색 시작 노드 + * @param predicate 노드 판정 함수 + * @returns 찾은 노드 또는 null + */ +function findNode(node: any, predicate: (n: any) => boolean): any { + if (!node || typeof node !== 'object') return null; + if (predicate(node)) return node; + + for (const child of node.children ?? []) { + const found = findNode(child, predicate); + if (found) return found; + } + + return null; +} + +/** + * 이 화면을 렌더하는 admin 템플릿의 매니페스트가 선언한 컴포넌트 종류를 읽는다. + * + * 레이아웃 노드의 `type` 은 렌더러가 DOM 안전 필터링을 고르는 기준이라 + * 매니페스트 선언과 어긋나면 `editorAttrs` 가 제거된다(DynamicRenderer). + * + * @param name 컴포넌트 이름 + * @returns 매니페스트가 선언한 type + */ +function manifestType(name: string): string { + // 모듈 루트(modules/_bundled/sirsoft-board) 에서 저장소 루트로 두 단계 올라간다. + const repoRoot = path.dirname(path.dirname(path.dirname(moduleRoot()))); + const manifest = JSON.parse( + fs.readFileSync( + path.join(repoRoot, 'templates/_bundled/sirsoft-admin_basic/components.json'), + 'utf-8', + ), + ); + + for (const [kind, entries] of Object.entries(manifest.components ?? {})) { + if ((entries as any[]).some((entry) => entry.name === name)) { + return kind; + } + } + + throw new Error(`매니페스트에 ${name} 선언이 없습니다.`); +} + +const section = findNode(generalTab, (n) => n.id === 'attachment_cleanup_section'); + +describe('게시판 첨부 정리 설정 — 계약', () => { + it('기본 설정 탭에 첨부 정리 섹션이 있다', () => { + expect(section).not.toBeNull(); + }); + + it('토글 노드의 type 이 매니페스트 선언과 같다', () => { + const toggle = findNode(section, (n) => n.name === 'Toggle'); + + // 어긋나면 렌더러가 editorAttrs 를 떼어내 이 노드만 레이아웃 편집기에서 표식을 잃는다. + expect(toggle.type).toBe(manifestType('Toggle')); + }); + + it('토글과 보존기간이 attachment_settings 카테고리 키를 바인딩한다', () => { + const toggle = findNode(section, (n) => n.name === 'Toggle'); + const input = findNode(section, (n) => n.name === 'Input'); + + expect(toggle.props.name).toBe('attachment_settings.purge_enabled'); + expect(input.props.name).toBe('attachment_settings.purge_retention_days'); + expect(input.props.min).toContain('attachment_purge_retention_days_min'); + expect(input.props.max).toContain('attachment_purge_retention_days_max'); + }); + + it('변경 시 form 상태와 hasChanges 를 함께 갱신한다', () => { + const toggle = findNode(section, (n) => n.name === 'Toggle'); + const action = toggle.actions.find((a: any) => a.type === 'change'); + + expect(action.params.hasChanges).toBe(true); + + // 값 갱신은 정적 점 경로 키로 한다. 중첩 객체(`form: { attachment_settings: {...} }`)로 + // 되돌리면 spread 누적이 되살아나 저장은 200 인데 값이 조용히 버려진다. + expect(action.params['form.attachment_settings.purge_enabled']).toBe('{{$event.target.checked}}'); + expect(action.params.form).toBeUndefined(); + + // 키 자체에 표현식을 쓰면 경로가 해석되지 않는다 (setState 규약). + Object.keys(action.params).forEach((key) => { + expect(key).not.toContain('{{'); + }); + }); + + it('기본값이 꺼짐이고 보존기간 기본이 30일이다 (defaults.json)', () => { + const defaults = JSON.parse( + fs.readFileSync(path.join(moduleRoot(), 'config/settings/defaults.json'), 'utf-8'), + ); + + expect(defaults._meta.categories).toContain('attachment_settings'); + expect(defaults.defaults.attachment_settings.purge_enabled).toBe(false); + expect(defaults.defaults.attachment_settings.purge_retention_days).toBe(30); + }); + + it('서버 검증이 같은 키를 받고 화면과 같은 한계값 출처를 쓴다', () => { + const request = fs.readFileSync( + path.join(moduleRoot(), 'src/Http/Requests/Admin/StoreBoardSettingsRequest.php'), + 'utf-8', + ); + const boardConfig = fs.readFileSync(path.join(moduleRoot(), 'config/board.php'), 'utf-8'); + + expect(request).toContain("'attachment_settings.purge_enabled' => ['nullable', 'boolean']"); + // 화면 입력과 저장 규칙이 같은 limits 키를 읽어야 "화면이 허용한 값인데 422" 가 생기지 않는다. + expect(request).toContain("\$limits['attachment_purge_retention_days_min']"); + expect(request).toContain("\$limits['attachment_purge_retention_days_max']"); + expect(boardConfig).toContain("'attachment_purge_retention_days_min' => 1"); + expect(boardConfig).toContain("'attachment_purge_retention_days_max' => 3650"); + }); + + it('기본 설정 탭 저장 payload 에 attachment_settings 가 실린다', () => { + // 화면에 필드가 있어도 저장 요청 본문에 카테고리가 빠지면 저장은 200 인데 값만 사라진다 + // (브라우저 실측 — 토글을 켜고 저장해도 파일에 반영되지 않음). + const settingsLayout = JSON.parse( + fs.readFileSync( + path.join(moduleRoot(), 'resources/layouts/admin/admin_board_settings.json'), + 'utf-8', + ), + ); + const raw = JSON.stringify(settingsLayout); + const generalBranchStart = raw.indexOf("if (tab === 'general')"); + const generalBranch = raw.slice(generalBranchStart, raw.indexOf('; }', generalBranchStart)); + + expect(generalBranchStart).toBeGreaterThan(-1); + expect(generalBranch).toContain('attachment_settings: form.attachment_settings || {}'); + }); + + it('서버가 attachment_settings 카테고리를 저장 대상으로 받는다', () => { + const request = fs.readFileSync( + path.join(moduleRoot(), 'src/Http/Requests/Admin/StoreBoardSettingsRequest.php'), + 'utf-8', + ); + const validCategories = request.match(/\$validCategories = \[[^\]]*\]/)?.[0] ?? ''; + + expect(validCategories).toContain("'attachment_settings'"); + }); + + it('스케줄이 등록되고 영구 정리 파트만 커맨드 내부에서 게이트된다', () => { + const manifest = fs.readFileSync(path.join(moduleRoot(), 'module.php'), 'utf-8'); + const command = fs.readFileSync( + path.join(moduleRoot(), 'src/Console/Commands/PruneAttachmentsCommand.php'), + 'utf-8', + ); + + expect(manifest).toContain('sirsoft-board:prune-attachments --scheduled'); + expect(command).toContain("module_setting('sirsoft-board', 'attachment_settings.purge_enabled', false)"); + }); +}); diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx index a92c2ff7..d191b1ae 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx @@ -1611,10 +1611,12 @@ describe('기본 설정 탭 (general) - 날짜 표시 방식', () => { expect(option).not.toBeNull(); // click 액션으로 date_display_format: "standard" 설정 + // 키는 점 경로다 — 중첩 객체로 넘기면 spread 누적이 생겨 저장 payload 가 어긋난다. const clickAction = option.actions?.find((a: any) => a.type === 'click'); expect(clickAction).toBeDefined(); expect(clickAction.handler).toBe('setState'); - expect(clickAction.params?.form?.display?.date_display_format).toBe('standard'); + expect(clickAction.params?.['form.display.date_display_format']).toBe('standard'); + expect(clickAction.params?.form).toBeUndefined(); }); it('relative 옵션 라디오 버튼이 존재한다', () => { @@ -1622,10 +1624,12 @@ describe('기본 설정 탭 (general) - 날짜 표시 방식', () => { expect(option).not.toBeNull(); // click 액션으로 date_display_format: "relative" 설정 + // 키는 점 경로다 — 중첩 객체로 넘기면 spread 누적이 생겨 저장 payload 가 어긋난다. const clickAction = option.actions?.find((a: any) => a.type === 'click'); expect(clickAction).toBeDefined(); expect(clickAction.handler).toBe('setState'); - expect(clickAction.params?.form?.display?.date_display_format).toBe('relative'); + expect(clickAction.params?.['form.display.date_display_format']).toBe('relative'); + expect(clickAction.params?.form).toBeUndefined(); }); it('라디오 버튼 name이 display.date_display_format이다', () => { diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json index 2c7a5ec7..111ab0d3 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json @@ -60,9 +60,15 @@ "report_notification": "Report Notification", "report_notification_description": "Configure which administrators receive notifications and how notifications are sent when a report is submitted.", "report_permissions": "Report Management Permissions", - "report_permissions_description": "Assign roles that can view the report list and process reports (approve, reject, or blind). Admin notifications for new reports are sent based on the 'Process Reports Permission' role." + "report_permissions_description": "Assign roles that can view the report list and process reports (approve, reject, or blind). Admin notifications for new reports are sent based on the 'Process Reports Permission' role.", + "attachment_cleanup": "Attachment Cleanup", + "attachment_cleanup_description": "Decide when the files of deleted attachments are destroyed. Temporary attachments uploaded while writing but never saved are cleaned up daily regardless of this setting." }, "fields": { + "attachment_purge_enabled": "Purge deleted attachments", + "attachment_purge_enabled_hint": "Off by default — the operator must turn it on for the daily automatic cleanup to run. When on, files and records of attachments deleted longer ago than the retention period are permanently removed. Cleanup commands run directly on the server are not affected by this setting.", + "attachment_purge_retention_days": "Deleted attachment retention (days)", + "attachment_purge_retention_days_hint": "Restoring a post from the trash also restores attachments within this period. Keep it aligned with your trash retention window. (1 ~ 3650 days)", "type": "Board Type", "secret_mode": "Secret Mode", "per_page": "Posts per Page (PC)", diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json index 02207152..2b6c4694 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json @@ -60,9 +60,15 @@ "report_notification": "신고 알림", "report_notification_description": "신고 접수 시 알림을 받을 관리자와 알림 발송 방식을 설정합니다.", "report_permissions": "신고 관리 권한", - "report_permissions_description": "신고 목록 조회 및 신고 처리(승인·반려·블라인드)를 수행할 수 있는 역할을 지정합니다. 신고 접수 알림은 '신고 처리 권한'을 가진 역할 기준으로 발송됩니다." + "report_permissions_description": "신고 목록 조회 및 신고 처리(승인·반려·블라인드)를 수행할 수 있는 역할을 지정합니다. 신고 접수 알림은 '신고 처리 권한'을 가진 역할 기준으로 발송됩니다.", + "attachment_cleanup": "첨부파일 정리", + "attachment_cleanup_description": "삭제한 첨부파일의 물리 파일을 언제 파기할지 정합니다. 글쓰기 중 올렸다가 저장하지 않은 임시 첨부는 이 설정과 무관하게 매일 자동 정리됩니다." }, "fields": { + "attachment_purge_enabled": "삭제 첨부 영구 정리", + "attachment_purge_enabled_hint": "기본 꺼짐 — 운영자가 직접 켜야 매일 자동 정리가 동작합니다. 켜면 삭제된 지 보존기간이 지난 첨부의 파일과 기록을 영구 삭제합니다. 서버에서 직접 실행하는 정리 명령은 이 설정과 무관하게 동작합니다.", + "attachment_purge_retention_days": "삭제 첨부 보존기간 (일)", + "attachment_purge_retention_days_hint": "휴지통에서 게시글을 복원하면 이 기간 안의 첨부는 함께 복원됩니다. 휴지통 복원 가능 기간과 같게 유지하십시오. (1 ~ 3650 일)", "type": "게시판 유형", "secret_mode": "비밀글 모드", "per_page": "페이지당 게시글 수 (PC)", diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json index 5ad705ee..40e79115 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json @@ -641,7 +641,7 @@ "target": "/api/modules/sirsoft-board/admin/settings", "params": { "method": "PUT", - "body": "{{(function() { const tab = _global.activeBoardSettingsTab || query.tab || 'general'; const form = _local.form || {}; if (tab === 'general') { return { _tab: 'general', display: form.display || {} }; } if (tab === 'report_policy') { return { _tab: 'report_policy', report_policy: form.report_policy || {}, spam_security: form.spam_security || {}, report_permissions: form.report_permissions || {} }; } if (tab === 'spam_security') { return { _tab: 'spam_security', spam_security: form.spam_security || {} }; } if (tab === 'seo') { return { _tab: 'seo', seo: form.seo || {} }; } if (tab === 'notification_definitions') { return { _tab: 'notifications', notifications: { channels: form.notifications?.channels || [] } }; } return { _tab: 'basic_defaults', basic_defaults: form.basic_defaults || {}, notifications: form.notifications || {} }; })()}}" + "body": "{{(function() { const tab = _global.activeBoardSettingsTab || query.tab || 'general'; const form = _local.form || {}; if (tab === 'general') { return { _tab: 'general', display: form.display || {}, attachment_settings: form.attachment_settings || {} }; } if (tab === 'report_policy') { return { _tab: 'report_policy', report_policy: form.report_policy || {}, spam_security: form.spam_security || {}, report_permissions: form.report_permissions || {} }; } if (tab === 'spam_security') { return { _tab: 'spam_security', spam_security: form.spam_security || {} }; } if (tab === 'seo') { return { _tab: 'seo', seo: form.seo || {} }; } if (tab === 'notification_definitions') { return { _tab: 'notifications', notifications: { channels: form.notifications?.channels || [] } }; } return { _tab: 'basic_defaults', basic_defaults: form.basic_defaults || {}, notifications: form.notifications || {} }; })()}}" }, "onSuccess": [ { diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json index 908e4a24..6bf3092a 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json @@ -81,14 +81,8 @@ "handler": "setState", "params": { "target": "local", - "form": { - "...": "{{_local.form}}", - "display": { - "...": "{{_local.form?.display}}", - "date_display_format": "standard" - } - }, - "hasChanges": true + "hasChanges": true, + "form.display.date_display_format": "standard" } } ], @@ -110,14 +104,8 @@ "handler": "setState", "params": { "target": "local", - "form": { - "...": "{{_local.form}}", - "display": { - "...": "{{_local.form?.display}}", - "date_display_format": "standard" - } - }, - "hasChanges": true + "hasChanges": true, + "form.display.date_display_format": "standard" } } ] @@ -170,14 +158,8 @@ "handler": "setState", "params": { "target": "local", - "form": { - "...": "{{_local.form}}", - "display": { - "...": "{{_local.form?.display}}", - "date_display_format": "relative" - } - }, - "hasChanges": true + "hasChanges": true, + "form.display.date_display_format": "relative" } } ], @@ -199,14 +181,8 @@ "handler": "setState", "params": { "target": "local", - "form": { - "...": "{{_local.form}}", - "display": { - "...": "{{_local.form?.display}}", - "date_display_format": "relative" - } - }, - "hasChanges": true + "hasChanges": true, + "form.display.date_display_format": "relative" } } ] @@ -251,6 +227,128 @@ ] } ] + }, + { + "id": "attachment_cleanup_section", + "type": "basic", + "name": "Div", + "props": { + "className": "admin-card mt-6" + }, + "children": [ + { + "type": "basic", + "name": "H2", + "props": { + "className": "card-title" + }, + "text": "$t:sirsoft-board.admin.settings.sections.attachment_cleanup" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "card-description" + }, + "text": "$t:sirsoft-board.admin.settings.sections.attachment_cleanup_description" + }, + { + "id": "attachment_purge_enabled_field", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-4" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_enabled" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-tertiary" + }, + "text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_enabled_hint" + }, + { + "type": "composite", + "name": "Toggle", + "props": { + "name": "attachment_settings.purge_enabled", + "disabled": "{{_computed.isReadOnly}}", + "checked": "{{!!_local.form?.attachment_settings?.purge_enabled}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "hasChanges": true, + "form.attachment_settings.purge_enabled": "{{$event.target.checked}}" + } + } + ] + } + ] + }, + { + "id": "attachment_purge_retention_days_field", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-4" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_retention_days" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-tertiary" + }, + "text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_retention_days_hint" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "number", + "name": "attachment_settings.purge_retention_days", + "min": "{{_local?.form?._meta?.limits?.attachment_purge_retention_days_min ?? 1}}", + "max": "{{_local?.form?._meta?.limits?.attachment_purge_retention_days_max ?? 3650}}", + "className": "input w-40", + "disabled": "{{_computed.isReadOnly}}", + "value": "{{_local.form?.attachment_settings?.purge_retention_days ?? 30}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "hasChanges": true, + "form.attachment_settings.purge_retention_days": "{{$event.target.value}}" + } + } + ] + } + ] + } + ] } ] } diff --git a/modules/_bundled/sirsoft-board/src/Console/Commands/PruneAttachmentsCommand.php b/modules/_bundled/sirsoft-board/src/Console/Commands/PruneAttachmentsCommand.php new file mode 100644 index 00000000..6fd0d62f --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Console/Commands/PruneAttachmentsCommand.php @@ -0,0 +1,180 @@ +option('limit')); + $isDryRun = (bool) $this->option('dry-run'); + + $this->pruneTemp($limit, $isDryRun); + $this->purgeSoftDeleted($limit, $isDryRun); + + return Command::SUCCESS; + } + + /** + * 임시 첨부 정리 파트 (항상 실행). + * + * @param int $limit 최대 처리 건수 + * @param bool $isDryRun 판정만 수행할지 여부 + */ + private function pruneTemp(int $limit, bool $isDryRun): void + { + $days = (int) $this->option('temp-days'); + + // 보존기간 0 이하 = 방금 올린 임시 첨부까지 지우게 되므로 차단한다. + if ($days < 1) { + $this->info('임시 첨부 보존기간이 1일 미만이어서 임시 첨부 정리를 수행하지 않았습니다.'); + + return; + } + + $result = $this->attachmentService->pruneTempUploads($days, $limit, $isDryRun); + + if ($isDryRun) { + $this->info("[DRY RUN] 보존기간({$days}일) 경과 미연결 임시 첨부: {$result['scanned']}건"); + + return; + } + + $this->info(sprintf( + '보존기간(%d일) 경과 임시 첨부 %d건 중 %d건을 삭제했습니다. (실패 %d건)', + $days, + $result['scanned'], + $result['deleted'], + $result['failed'], + )); + + Log::info('PruneAttachmentsCommand: 임시 첨부 정리 완료', [ + 'days' => $days, + 'limit' => $limit, + ] + $result); + } + + /** + * 소프트 삭제 첨부 영구 정리 파트 (운영자 옵트인). + * + * @param int $limit 최대 처리 건수 + * @param bool $isDryRun 판정만 수행할지 여부 + */ + private function purgeSoftDeleted(int $limit, bool $isDryRun): void + { + if ($this->option('scheduled') && ! $this->isPurgeEnabled()) { + $this->info('삭제 첨부 영구 정리가 꺼져 있어 실행하지 않았습니다. (attachment_settings.purge_enabled = false)'); + + return; + } + + $days = $this->resolvePurgeDays(); + + if ($days < 1) { + $this->info('삭제 첨부 보존기간이 1일 미만이어서 영구 정리를 수행하지 않았습니다.'); + + return; + } + + $result = $this->attachmentService->purgeSoftDeleted($days, $limit, $isDryRun); + + if ($isDryRun) { + $this->info("[DRY RUN] 보존기간({$days}일) 경과 삭제 첨부: {$result['scanned']}건"); + + return; + } + + $this->info(sprintf( + '보존기간(%d일) 경과 삭제 첨부 %d건 중 %d건을 영구 삭제했습니다. (실패 %d건)', + $days, + $result['scanned'], + $result['deleted'], + $result['failed'], + )); + + Log::info('PruneAttachmentsCommand: 삭제 첨부 영구 정리 완료', [ + 'days' => $days, + 'limit' => $limit, + ] + $result); + } + + /** + * 영구 정리 토글을 false 폴백으로 조회합니다. + * + * @return bool 영구 정리 활성 여부 + */ + private function isPurgeEnabled(): bool + { + return (bool) module_setting('sirsoft-board', 'attachment_settings.purge_enabled', false); + } + + /** + * 영구 정리 보존기간을 해석합니다 (옵션 > 모듈 설정 > 기본 30일). + * + * @return int 보존기간(일) + */ + private function resolvePurgeDays(): int + { + $option = $this->option('purge-days'); + + if ($option !== null && $option !== '') { + return (int) $option; + } + + return (int) module_setting('sirsoft-board', 'attachment_settings.purge_retention_days', 30); + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php index 15f81a67..ca37cf18 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php @@ -58,6 +58,7 @@ class StoreBoardSettingsRequest extends FormRequest 'spam_security.comment_cooldown_seconds', 'spam_security.report_cooldown_seconds', 'spam_security.view_count_cache_ttl', + 'attachment_settings.purge_retention_days', ]; $booleanFields = [ @@ -73,6 +74,7 @@ class StoreBoardSettingsRequest extends FormRequest 'seo.seo_boards', 'seo.seo_board', 'seo.seo_post_detail', + 'attachment_settings.purge_enabled', ]; $data = $this->all(); @@ -262,6 +264,11 @@ class StoreBoardSettingsRequest extends FormRequest 'seo.seo_boards' => ['nullable', 'boolean'], 'seo.seo_board' => ['nullable', 'boolean'], 'seo.seo_post_detail' => ['nullable', 'boolean'], + + // 첨부 정리 정책 — 사용자 파일을 파기하므로 보존기간 하한을 서버가 강제한다. + // 상한(3650일)은 "사실상 정리하지 않음" 과 구분되지 않는 값을 저장하지 않기 위함이다. + 'attachment_settings.purge_enabled' => ['nullable', 'boolean'], + 'attachment_settings.purge_retention_days' => ['nullable', 'integer', 'min:'.$limits['attachment_purge_retention_days_min'], 'max:'.$limits['attachment_purge_retention_days_max']], ]; } @@ -311,7 +318,7 @@ class StoreBoardSettingsRequest extends FormRequest public function validatedSettings(): array { $validated = $this->validated(); - $validCategories = ['basic_defaults', 'report_policy', 'spam_security', 'display', 'seo', 'notifications']; + $validCategories = ['basic_defaults', 'report_policy', 'spam_security', 'display', 'seo', 'notifications', 'attachment_settings']; return array_filter( $validated, diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php index 8323a609..248380f3 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php @@ -67,6 +67,9 @@ trait ReadsBoardLimits 'new_display_hours_min' => 0, 'new_display_hours_max' => 720, + + 'attachment_purge_retention_days_min' => 1, + 'attachment_purge_retention_days_max' => 3650, ]; /** diff --git a/modules/_bundled/sirsoft-board/src/Listeners/Ckeditor5ReferenceSourcesListener.php b/modules/_bundled/sirsoft-board/src/Listeners/Ckeditor5ReferenceSourcesListener.php new file mode 100644 index 00000000..cfc50493 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Listeners/Ckeditor5ReferenceSourcesListener.php @@ -0,0 +1,57 @@ + + */ + public static function getSubscribedHooks(): array + { + return [ + 'sirsoft-ckeditor5.image.filter_reference_sources' => [ + 'method' => 'addBoardSources', + 'priority' => 10, + 'type' => 'filter', + ], + ]; + } + + /** + * 훅 이벤트를 처리합니다. + * + * Filter 훅은 getSubscribedHooks 에서 지정한 메서드를 직접 호출하므로 + * 이 메서드는 인터페이스 요구사항 충족을 위해서만 존재합니다. + * + * @param mixed ...$args 훅에서 전달된 인수들 + */ + public function handle(...$args): void {} + + /** + * 게시판 참조 소스를 추가합니다. + * + * @param array $sources 기존 참조 소스 목록 + * @return array 게시판 소스가 추가된 목록 + */ + public function addBoardSources(array $sources): array + { + $sources[] = ['table' => 'board_posts', 'columns' => ['content']]; + + return $sources; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php index cc2e521a..60826b1c 100644 --- a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php +++ b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php @@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Providers; use App\Extension\BaseModuleServiceProvider; use App\Seo\SitemapGenerator; use Modules\Sirsoft\Board\Console\Commands\AggregateBoardStatsCommand; +use Modules\Sirsoft\Board\Console\Commands\PruneAttachmentsCommand; use Modules\Sirsoft\Board\Repositories\AttachmentRepository; use Modules\Sirsoft\Board\Repositories\BoardRepository; use Modules\Sirsoft\Board\Repositories\BoardStatRepository; @@ -88,6 +89,7 @@ class BoardServiceProvider extends BaseModuleServiceProvider */ protected array $commands = [ AggregateBoardStatsCommand::class, + PruneAttachmentsCommand::class, ]; /** diff --git a/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php index c2bf9e29..dc54c4d3 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Board\Repositories; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Enums\TriggerType; @@ -431,4 +432,51 @@ class AttachmentRepository implements AttachmentRepositoryInterface { return Attachment::where('board_id', $boardId)->forceDelete(); } + + /** + * 게시글에 연결되지 않은 채 방치된 임시 첨부를 오래된 순으로 조회합니다. + * + * @param Carbon $threshold 기준 시각 + * @param int $limit 최대 조회 건수 + * @return Collection 임시 첨부 목록 + */ + public function findStaleTempAttachments(Carbon $threshold, int $limit): Collection + { + return Attachment::query() + ->whereNotNull('temp_key') + ->whereNull('post_id') + ->where('created_at', '<', $threshold) + ->orderBy('created_at') + ->orderBy('id') + ->limit($limit) + ->get(['id', 'board_id', 'temp_key', 'disk', 'path', 'created_at']); + } + + /** + * 소프트 삭제된 지 오래된 첨부를 오래된 순으로 조회합니다. + * + * @param Carbon $threshold 기준 시각 + * @param int $limit 최대 조회 건수 + * @return Collection 소프트 삭제 첨부 목록 + */ + public function findSoftDeletedOlderThan(Carbon $threshold, int $limit): Collection + { + return Attachment::onlyTrashed() + ->where('deleted_at', '<', $threshold) + ->orderBy('deleted_at') + ->orderBy('id') + ->limit($limit) + ->get(['id', 'board_id', 'post_id', 'disk', 'path', 'deleted_at']); + } + + /** + * 첨부 레코드를 영구 삭제합니다. + * + * @param Attachment $attachment 첨부파일 모델 + * @return bool 삭제 성공 여부 + */ + public function forceDelete(Attachment $attachment): bool + { + return (bool) $attachment->forceDelete(); + } } diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php index 63e6d31c..b5d8a0a7 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Board\Repositories\Contracts; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Carbon; use Modules\Sirsoft\Board\Models\Attachment; use Modules\Sirsoft\Board\Models\Post; @@ -198,4 +199,36 @@ interface AttachmentRepositoryInterface * @return int 삭제된 첨부파일 수 */ public function forceDeleteByBoardId(int $boardId): int; + + /** + * 게시글에 연결되지 않은 채 방치된 임시 첨부를 오래된 순으로 조회합니다. + * + * 글쓰기 폼에서 업로드만 하고 저장 없이 이탈하면 `temp_key` 가 남은 채 + * `board_id = 0` 인 행과 그 파일이 영구 잔존합니다. 그 회수 대상을 찾습니다. + * + * @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상) + * @param int $limit 최대 조회 건수 + * @return Collection 임시 첨부 목록 (created_at 오름차순) + */ + public function findStaleTempAttachments(Carbon $threshold, int $limit): Collection; + + /** + * 소프트 삭제된 지 오래된 첨부를 오래된 순으로 조회합니다. + * + * 첨부 삭제는 소프트 삭제만 수행하므로(휴지통 복원 대비) 물리 파일은 남아 있습니다. + * 보존기간이 지난 뒤 파일과 기록을 함께 파기할 대상을 찾습니다. + * + * @param Carbon $threshold 기준 시각 (이 시각 이전 삭제분이 대상) + * @param int $limit 최대 조회 건수 + * @return Collection 소프트 삭제 첨부 목록 (deleted_at 오름차순) + */ + public function findSoftDeletedOlderThan(Carbon $threshold, int $limit): Collection; + + /** + * 첨부 레코드를 영구 삭제합니다. + * + * @param Attachment $attachment 첨부파일 모델 + * @return bool 삭제 성공 여부 + */ + public function forceDelete(Attachment $attachment): bool; } diff --git a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php index 5d9f96cf..06ba1a3c 100644 --- a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php +++ b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php @@ -9,6 +9,7 @@ use App\Support\ImageResizer; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -440,8 +441,9 @@ class AttachmentService // Before 훅 HookManager::doAction('sirsoft-board.attachment.before_delete', $attachment); - // 물리 파일은 삭제하지 않음 — 소프트 딜리트만 수행 - // 추후 배치 작업(Artisan Command + Scheduler)으로 보존 기간 경과 후 정리 예정 + // 물리 파일은 삭제하지 않음 — 소프트 딜리트만 수행 (휴지통 복원 대비). + // 보존기간이 지난 뒤의 파일·기록 파기는 `sirsoft-board:prune-attachments` 가 + // 담당한다 (purgeSoftDeleted). 운영자가 설정에서 켰을 때만 동작한다. // DB에서 소프트 삭제 $result = $this->repository->delete($slug, $id); @@ -463,6 +465,151 @@ class AttachmentService return $result; } + /** + * 게시글에 연결되지 않은 채 방치된 임시 첨부를 정리합니다. + * + * 글쓰기 폼에서 파일만 올리고 저장하지 않고 이탈하면 `temp_key` 만 남은 행과 그 파일이 + * 남습니다. 연결 시점에 본경로로 옮겨지므로 `temp_key` 가 남아 있다는 것은 "끝내 연결되지 + * 않았다" 는 뜻이고, 그래서 오탐 여지가 없습니다. + * + * 파일 → 행 순서를 지켜, 행이 먼저 사라져 파일을 못 찾는 상태를 만들지 않습니다. + * + * @param int $days 보존기간(일) + * @param int $limit 한 회차에 처리할 최대 건수 + * @param bool $dryRun true 면 대상만 세고 삭제하지 않음 + * @return array{scanned: int, deleted: int, failed: int} 처리 결과 + */ + public function pruneTempUploads(int $days, int $limit, bool $dryRun = false): array + { + $threshold = Carbon::now()->subDays($days); + $attachments = $this->repository->findStaleTempAttachments($threshold, $limit); + + $result = $this->purgeCollection($attachments, $dryRun, '임시 게시판 첨부'); + + if (! $dryRun) { + $this->removeEmptyTempDirectories($attachments); + } + + return $result; + } + + /** + * 파일을 모두 지운 temp_key 디렉토리를 정리합니다. + * + * 파일만 지우고 디렉토리를 남기면 폼 세션마다 빈 디렉토리가 쌓여, 정리를 돌려도 + * 저장소에는 흔적이 계속 늘어납니다. + * + * 디렉토리에 파일이 남아 있으면(같은 temp_key 의 다른 첨부가 limit 에 걸려 이번 회차에서 + * 빠진 경우 등) 삭제하지 않습니다. + * + * @param Collection $attachments 이번 회차에 처리한 첨부 목록 + */ + private function removeEmptyTempDirectories(Collection $attachments): void + { + $directories = []; + + foreach ($attachments as $attachment) { + $directory = dirname((string) $attachment->path); + + if ($directory === '' || $directory === '.') { + continue; + } + + $directories[$attachment->disk.'|'.$directory] = [$attachment->disk, $directory]; + } + + foreach ($directories as [$disk, $directory]) { + $storage = $this->storageForRow($disk); + + if ($storage->files('attachments', $directory) !== []) { + continue; + } + + $storage->deleteDirectory('attachments', $directory); + } + } + + /** + * 소프트 삭제된 지 보존기간이 지난 첨부를 파일까지 영구 파기합니다. + * + * 첨부 삭제는 소프트 삭제만 수행하므로 파일이 남아 있습니다. 게시글 복원 + * (restoreCascadedByPostId)은 첨부의 `deleted_at` 이 보존기간 안에 있을 때에만 첨부까지 + * 온전히 복원되므로, 보존기간은 휴지통 복원 가능 기간과 같게 유지해야 합니다. + * + * @param int $days 보존기간(일) + * @param int $limit 한 회차에 처리할 최대 건수 + * @param bool $dryRun true 면 대상만 세고 삭제하지 않음 + * @return array{scanned: int, deleted: int, failed: int} 처리 결과 + */ + public function purgeSoftDeleted(int $days, int $limit, bool $dryRun = false): array + { + $threshold = Carbon::now()->subDays($days); + $attachments = $this->repository->findSoftDeletedOlderThan($threshold, $limit); + + return $this->purgeCollection($attachments, $dryRun, '소프트 삭제 게시판 첨부'); + } + + /** + * 첨부 컬렉션을 파일 → 기록 순으로 영구 파기합니다. + * + * @param Collection $attachments 대상 첨부 목록 + * @param bool $dryRun true 면 대상만 세고 삭제하지 않음 + * @param string $label 로그용 대상 설명 + * @return array{scanned: int, deleted: int, failed: int} 처리 결과 + */ + private function purgeCollection(Collection $attachments, bool $dryRun, string $label): array + { + $result = ['scanned' => $attachments->count(), 'deleted' => 0, 'failed' => 0]; + + if ($dryRun) { + return $result; + } + + foreach ($attachments as $attachment) { + $storage = $this->storageForRow($attachment->disk); + + if ($storage->exists('attachments', $attachment->path) && ! $storage->delete('attachments', $attachment->path)) { + Log::warning("{$label} 파일 삭제 실패 — 기록 보존", [ + 'attachment_id' => $attachment->id, + 'disk' => $attachment->disk, + 'path' => $attachment->path, + ]); + + $result['failed']++; + + continue; + } + + $this->repository->forceDelete($attachment); + $result['deleted']++; + } + + return $result; + } + + /** + * 첨부 행에 기록된 disk 기준 스토리지를 반환합니다. + * + * 디스크를 전환한 뒤에도 전환 이전 행의 파일을 그 행의 실제 저장 위치에서 지우기 위한 + * 해석입니다. 미등록 disk(그 디스크를 제공하던 확장이 비활성화된 경우)는 주입 스토리지로 + * 폴백합니다 — withDisk 로 미등록 disk 인스턴스를 만들면 이후 호출이 예외가 됩니다. + * + * @param string|null $disk 행의 disk 컬럼 값 + * @return StorageInterface 행 disk 의 스토리지 + */ + private function storageForRow(?string $disk): StorageInterface + { + if ($disk === null || $disk === '' || $disk === $this->storage->getDisk()) { + return $this->storage; + } + + if (config("filesystems.disks.{$disk}") === null) { + return $this->storage; + } + + return $this->storage->withDisk($disk); + } + /** * 순서 변경 * diff --git a/modules/_bundled/sirsoft-board/src/lang/en/validation.php b/modules/_bundled/sirsoft-board/src/lang/en/validation.php index ae4efe83..1645edeb 100644 --- a/modules/_bundled/sirsoft-board/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-board/src/lang/en/validation.php @@ -401,6 +401,10 @@ return [ 'override_values.max_file_size' => 'Max File Size', 'override_values.max_file_count' => 'Max File Count', 'override_values.new_display_hours' => 'New Display Hours', + + // attachment_settings + 'attachment_settings.purge_enabled' => 'Permanent Cleanup Of Deleted Attachments', + 'attachment_settings.purge_retention_days' => 'Deleted Attachment Retention Period', ], ], diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/validation.php b/modules/_bundled/sirsoft-board/src/lang/ko/validation.php index a4f87097..8ae61184 100644 --- a/modules/_bundled/sirsoft-board/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-board/src/lang/ko/validation.php @@ -401,6 +401,10 @@ return [ 'override_values.max_file_size' => '최대 파일 크기', 'override_values.max_file_count' => '최대 파일 개수', 'override_values.new_display_hours' => '신규 표시 시간', + + // attachment_settings + 'attachment_settings.purge_enabled' => '삭제 첨부 영구 정리', + 'attachment_settings.purge_retention_days' => '삭제 첨부 보존기간', ], ], diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php index 5e0504de..2c2f331f 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php @@ -144,13 +144,14 @@ class BoardSettingsControllerTest extends ModuleTestCase $response->assertStatus(200); - // 저장된 값이 파일에 반영되었는지 확인 - $filePath = $this->settingsStoragePath.'/basic_defaults.json'; - $this->assertFileExists($filePath); + // 저장 결과는 조회 API 로 확인한다 — settings 디스크는 테스트 하네스가 페이크로 + // 대체하므로 실제 storage 경로를 직접 읽으면 저장 성공에도 파일이 없다. + $saved = $this->actingAs($this->adminUser) + ->getJson('/api/modules/sirsoft-board/admin/settings/basic_defaults'); - $content = json_decode(File::get($filePath), true); - $this->assertEquals(30, $content['per_page']); - $this->assertEquals(10, $content['per_page_mobile']); + $saved->assertStatus(200); + $this->assertEquals(30, $saved->json('data.settings.per_page')); + $this->assertEquals(10, $saved->json('data.settings.per_page_mobile')); } /** @@ -170,13 +171,13 @@ class BoardSettingsControllerTest extends ModuleTestCase $response->assertStatus(200); - // 저장된 값이 파일에 반영되었는지 확인 - $filePath = $this->settingsStoragePath.'/notifications.json'; - $this->assertFileExists($filePath); + // 저장 결과는 조회 API 로 확인한다 (settings 디스크 페이크 — 위 주석 참조). + $saved = $this->actingAs($this->adminUser) + ->getJson('/api/modules/sirsoft-board/admin/settings/notifications'); - $content = json_decode(File::get($filePath), true); - $this->assertIsArray($content['channels']); - $this->assertEquals('mail', $content['channels'][0]['id']); + $saved->assertStatus(200); + $this->assertIsArray($saved->json('data.settings.channels')); + $this->assertEquals('mail', $saved->json('data.settings.channels.0.id')); } /** @@ -617,8 +618,11 @@ class BoardSettingsControllerTest extends ModuleTestCase $response->assertStatus(200); - $content = json_decode(File::get($this->settingsStoragePath.'/report_policy.json'), true); - $this->assertSame(0, (int) $content['auto_hide_threshold']); + $saved = $this->actingAs($this->adminUser) + ->getJson('/api/modules/sirsoft-board/admin/settings/report_policy'); + + $saved->assertStatus(200); + $this->assertSame(0, (int) $saved->json('data.settings.auto_hide_threshold')); } /** @@ -759,4 +763,49 @@ class BoardSettingsControllerTest extends ModuleTestCase $board->refresh(); $this->assertEquals(10, $board->per_page); } + + /** + * 첨부 정리 설정이 general 탭 저장으로 실제 반영되는지 확인 (회귀) + * + * 화면은 general 탭에서 이 카테고리를 함께 제출한다. 서버가 카테고리를 허용하지 않으면 + * 저장은 200 인데 값만 사라져, 운영자가 토글을 켜도 정리가 영원히 꺼진 상태로 남는다. + */ + public function test_attachment_cleanup_settings_are_saved_on_general_tab(): void + { + $response = $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + '_tab' => 'general', + 'display' => ['date_display_format' => 'standard'], + 'attachment_settings' => [ + 'purge_enabled' => true, + 'purge_retention_days' => 15, + ], + ]); + + $response->assertStatus(200); + + $followUp = $this->actingAs($this->adminUser) + ->getJson('/api/modules/sirsoft-board/admin/settings/attachment_settings'); + + $followUp->assertStatus(200); + $this->assertTrue($followUp->json('data.settings.purge_enabled')); + $this->assertSame(15, $followUp->json('data.settings.purge_retention_days')); + } + + /** + * 보존기간 하한(1일) 미만은 거부되는지 확인 — 사용자 파일을 파기하는 설정이므로. + */ + public function test_attachment_purge_retention_days_below_minimum_is_rejected(): void + { + $response = $this->actingAs($this->adminUser) + ->putJson('/api/modules/sirsoft-board/admin/settings', [ + '_tab' => 'general', + 'attachment_settings' => [ + 'purge_enabled' => true, + 'purge_retention_days' => 0, + ], + ]); + + $response->assertStatus(422); + } } diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Console/PruneAttachmentsCommandTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Console/PruneAttachmentsCommandTest.php new file mode 100644 index 00000000..6061bd81 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Console/PruneAttachmentsCommandTest.php @@ -0,0 +1,339 @@ + $values 설정 키 => 값 + */ + private function fakeModuleSettings(array $values): void + { + $service = Mockery::mock(ModuleSettingsService::class); + + $service->shouldReceive('get') + ->andReturnUsing(function (string $identifier, ?string $key = null, mixed $default = null) use ($values) { + return array_key_exists($key, $values) ? $values[$key] : $default; + }); + + $this->app->instance(ModuleSettingsService::class, $service); + } + + /** + * 첨부 파일과 기록을 함께 만듭니다. + * + * @param array $attributes 덮어쓸 속성 + * @return Attachment 생성된 첨부 + */ + private function makeAttachment(array $attributes = []): Attachment + { + $filename = uniqid('board-att-').'.png'; + $path = $attributes['path'] ?? "free/temp/tempkey/{$filename}"; + + (new ModuleStorageDriver('sirsoft-board', 'modules'))->put('attachments', $path, 'bytes'); + + return Attachment::create(array_merge([ + 'board_id' => 0, + 'post_id' => null, + 'temp_key' => 'tempkey', + 'original_filename' => $filename, + 'stored_filename' => $filename, + 'disk' => 'modules', + 'path' => $path, + 'mime_type' => 'image/png', + 'size' => 5, + 'collection' => 'attachments', + 'order' => 1, + ], $attributes)); + } + + /** + * @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off + * + * @effects board_temp_prune_deletes_file_and_record + */ + #[Test] + public function stale_temp_attachments_lose_both_file_and_record(): void + { + $attachment = $this->makeAttachment(); + $attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + $this->artisan('sirsoft-board:prune-attachments')->assertSuccessful(); + + $this->assertFalse( + (new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path) + ); + $this->assertDatabaseMissing('board_attachments', ['id' => $attachment->id]); + } + + /** + * @scenario age=within_retention, attachment_state=temp_unlinked, purge_toggle=off + * + * @effects board_temp_prune_keeps_within_retention + */ + #[Test] + public function temp_attachment_within_retention_is_kept(): void + { + $attachment = $this->makeAttachment(); + + $this->artisan('sirsoft-board:prune-attachments')->assertSuccessful(); + + $this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]); + } + + /** + * 게시글에 연결된(temp_key 가 비워진) 첨부는 임시 정리 대상이 아니다. + * + * @scenario age=past_retention, attachment_state=linked, purge_toggle=off + * + * @effects board_temp_prune_skips_linked + */ + #[Test] + public function linked_attachment_is_never_treated_as_temporary(): void + { + $attachment = $this->makeAttachment([ + 'board_id' => 1, + 'post_id' => 1, + 'temp_key' => null, + 'path' => 'free/2026/08/14/'.uniqid('linked-').'.png', + ]); + $attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly(); + + $this->artisan('sirsoft-board:prune-attachments')->assertSuccessful(); + + $this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]); + } + + /** + * @scenario age=past_retention, attachment_state=soft_deleted, purge_toggle=off + * + * @effects board_purge_gate_off_by_default + */ + #[Test] + public function scheduled_run_does_not_purge_soft_deleted_when_the_toggle_is_off(): void + { + $this->fakeModuleSettings(['attachment_settings.purge_enabled' => false]); + + $attachment = $this->makeAttachment([ + 'board_id' => 1, + 'post_id' => 1, + 'temp_key' => null, + 'path' => 'free/2026/08/14/'.uniqid('soft-').'.png', + ]); + $attachment->delete(); + $attachment->forceFill(['deleted_at' => now()->subDays(90)])->saveQuietly(); + + $this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful(); + + $this->assertNotNull(Attachment::withTrashed()->find($attachment->id)); + } + + /** + * @scenario age=past_retention, attachment_state=soft_deleted, purge_toggle=on + * + * @effects board_purge_gate_on + */ + #[Test] + public function scheduled_run_purges_soft_deleted_when_the_toggle_is_on(): void + { + $this->fakeModuleSettings([ + 'attachment_settings.purge_enabled' => true, + 'attachment_settings.purge_retention_days' => 30, + ]); + + $attachment = $this->makeAttachment([ + 'board_id' => 1, + 'post_id' => 1, + 'temp_key' => null, + 'path' => 'free/2026/08/14/'.uniqid('purge-').'.png', + ]); + $path = $attachment->path; + $attachment->delete(); + $attachment->forceFill(['deleted_at' => now()->subDays(90)])->saveQuietly(); + + $this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful(); + + $this->assertNull(Attachment::withTrashed()->find($attachment->id)); + $this->assertFalse( + (new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $path) + ); + } + + /** + * 보존기간 안의 소프트 삭제 첨부는 복원 가능해야 하므로 파기하지 않는다. + * + * @scenario age=within_retention, attachment_state=soft_deleted, purge_toggle=on + * + * @effects board_purge_keeps_restorable_window + */ + #[Test] + public function soft_deleted_attachment_within_retention_stays_restorable(): void + { + $this->fakeModuleSettings([ + 'attachment_settings.purge_enabled' => true, + 'attachment_settings.purge_retention_days' => 30, + ]); + + $attachment = $this->makeAttachment([ + 'board_id' => 1, + 'post_id' => 1, + 'temp_key' => null, + 'path' => 'free/2026/08/14/'.uniqid('recent-').'.png', + ]); + $attachment->delete(); + + $this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful(); + + $trashed = Attachment::withTrashed()->find($attachment->id); + $this->assertNotNull($trashed); + $this->assertTrue( + (new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path), + '복원 가능 기간 안에는 파일도 남아 있어야 한다.' + ); + } + + /** + * @effects board_prune_dry_run_no_delete + */ + #[Test] + public function dry_run_reports_targets_without_deleting(): void + { + $attachment = $this->makeAttachment(); + $attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + $this->artisan('sirsoft-board:prune-attachments --dry-run') + ->expectsOutputToContain('[DRY RUN]') + ->assertSuccessful(); + + $this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]); + } + + /** + * @effects board_temp_prune_days_guard + */ + #[Test] + public function temp_retention_below_one_day_performs_no_cleanup(): void + { + $attachment = $this->makeAttachment(); + $attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly(); + + $this->artisan('sirsoft-board:prune-attachments --temp-days=0 --purge-days=0') + ->assertSuccessful(); + + $this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]); + } + + /** + * 파일을 비운 temp_key 디렉토리는 남기지 않는다 (회귀) + * + * 파일만 지우고 디렉토리를 남기면 폼 세션마다 빈 디렉토리가 쌓여, 정리를 돌려도 + * 저장소 흔적은 계속 늘어난다. + * + * @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off + * + * @effects board_temp_prune_removes_empty_directory + */ + #[Test] + public function emptied_temp_directory_is_removed(): void + { + $attachment = $this->makeAttachment(['path' => 'free/temp/tempkey-empty-dir/'.uniqid('t-').'.png']); + $attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + $this->artisan('sirsoft-board:prune-attachments')->assertSuccessful(); + + $storage = new ModuleStorageDriver('sirsoft-board', 'modules'); + + $this->assertSame( + [], + $storage->files('attachments', 'free/temp/tempkey-empty-dir'), + '디렉토리 안의 파일은 모두 삭제되어야 한다.' + ); + $this->assertDirectoryDoesNotExist( + rtrim($storage->getBasePath('attachments'), '/\\').'/free/temp/tempkey-empty-dir', + '비워진 temp 디렉토리는 남기지 않는다.' + ); + } + + /** + * 같은 temp_key 에 처리되지 않은 파일이 남아 있으면 디렉토리를 지우지 않는다. + * + * @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off + * + * @effects board_temp_prune_keeps_directory_with_remaining_files + */ + #[Test] + public function temp_directory_with_remaining_files_is_kept(): void + { + $directory = 'free/temp/tempkey-partial'; + + $processed = $this->makeAttachment(['path' => $directory.'/'.uniqid('done-').'.png']); + $processed->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + // 같은 디렉토리에 이번 회차 대상이 아닌 파일이 남아 있는 상태 + $storage = new ModuleStorageDriver('sirsoft-board', 'modules'); + $storage->put('attachments', $directory.'/keep.png', 'bytes'); + + $this->artisan('sirsoft-board:prune-attachments')->assertSuccessful(); + + $this->assertTrue($storage->exists('attachments', $directory.'/keep.png')); + } + + /** + * 대상이 한 회차 상한을 넘어도 누락 없이 전수 처리된다 (경계). + * + * 순회는 `--limit` 으로 잘린 단일 윈도우를 훑는다. 처리하며 행을 지우므로, 남은 대상이 + * 다음 회차에서 반드시 잡혀야 적체가 수렴한다 — 커서가 밀려 영영 건너뛰는 행이 있으면 + * 정리를 매일 돌려도 잔존물이 남는다. + * + * @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off + * + * @effects board_temp_prune_processes_all_across_runs + */ + #[Test] + public function targets_beyond_the_limit_are_processed_on_the_next_run(): void + { + $attachments = []; + + for ($i = 0; $i < 3; $i++) { + $attachment = $this->makeAttachment([ + 'path' => "free/temp/tempkey-batch-{$i}/".uniqid('b-').'.png', + ]); + $attachment->forceFill(['created_at' => now()->subDays(5 + $i)])->saveQuietly(); + $attachments[] = $attachment; + } + + // 1회차: 상한 2건까지만 처리된다 + $this->artisan('sirsoft-board:prune-attachments --limit=2')->assertSuccessful(); + + $remaining = Attachment::withTrashed() + ->whereIn('id', array_column($attachments, 'id')) + ->count(); + + $this->assertSame(1, $remaining, '한 회차는 --limit 건까지만 처리한다.'); + + // 2회차: 남은 1건이 반드시 잡힌다 (커서 밀림으로 건너뛰지 않는다) + $this->artisan('sirsoft-board:prune-attachments --limit=2')->assertSuccessful(); + + foreach ($attachments as $attachment) { + $this->assertDatabaseMissing('board_attachments', ['id' => $attachment->id]); + $this->assertFalse( + (new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path) + ); + } + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-settings-attachment-cleanup.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-settings-attachment-cleanup.spec.ts new file mode 100644 index 00000000..0156f33e --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-settings-attachment-cleanup.spec.ts @@ -0,0 +1,129 @@ +/** + * E2E: 게시판 환경설정 > 기본 설정 — "삭제 첨부 영구 정리" (공개 #115) + * + * @scenario board-settings-attachment-cleanup + * @effects board_settings_purge_toggle_default_off, board_settings_purge_keys_declared + * + * 배경: 삭제한 첨부의 파일을 보존기간 경과 후 완전히 파기하는 운영자 스위치다. 저장 payload + * 빌더가 이 카테고리를 싣지 않아 "저장 200 인데 값이 사라지던" 결함이 있었고, 그 증상은 + * 응답이 정상이라 화면만 봐서는 드러나지 않았다. 단위 테스트는 레이아웃 JSON 의 바인딩만 + * 보므로, 실제 요청 본문에 카테고리가 실리는지는 브라우저로만 확인된다. + * + * 검증: + * 1. 기본 설정 탭에 토글·보존기간 필드가 번역문과 함께 마운트된다 + * 2. 토글은 기본 꺼짐으로 렌더된다 (사용자 파일을 지우는 기능의 인수 기준) + * 3. 값을 바꿔 저장하면 요청 본문에 `attachment_settings` 가 실제로 실린다 + * + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; + +// 탭 판정은 `_global.activeBoardSettingsTab || query.tab || 'general'` 이다. +// 다른 값을 실으면 탭 콘텐츠 자체가 렌더되지 않아 섹션을 찾을 수 없다. +const SETTINGS_URL = '/admin/boards/settings?tab=general'; + +const SECTION = '#attachment_cleanup_section'; +const TOGGLE_ROW = '#attachment_purge_enabled_field'; +const RETENTION_ROW = '#attachment_purge_retention_days_field'; + +/** 설정 저장 PUT 인지 판정한다. */ +const isSettingsPut = (url: string, method: string) => url.includes('/settings') && method === 'PUT'; + +/** + * 보존기간 값을 바꿔 저장하고, 요청 본문을 돌려준다. + * + * 응답 상태까지 확인한다 — 요청만 관찰하면 서버가 4xx 로 거부해도 단언이 통과해 + * "저장됐다" 는 잘못된 결론이 남는다. + * + * @param page 대상 페이지 + * @param input 보존기간 입력 + * @param saveButton 저장 버튼 + * @param value 채워 넣을 값 + * @returns 저장 요청 본문 + */ +async function save( + page: import('@playwright/test').Page, + input: import('@playwright/test').Locator, + saveButton: import('@playwright/test').Locator, + value: string, +): Promise> { + await input.fill(value); + await expect(saveButton).toBeEnabled({ timeout: 20_000 }); + + const requestPromise = page.waitForRequest( + (req) => isSettingsPut(req.url(), req.method()), + { timeout: 20_000 }, + ); + const responsePromise = page.waitForResponse( + (res) => isSettingsPut(res.url(), res.request().method()), + { timeout: 20_000 }, + ); + + await saveButton.click(); + + const payload = (await requestPromise).postDataJSON() as Record; + expect((await responsePromise).status()).toBe(200); + + return payload; +} + +test.describe('게시판 환경설정 — 삭제 첨부 영구 정리 (#115)', () => { + test('토글·보존기간 필드가 번역문과 함께 마운트되고 기본 꺼짐이다', async ({ + page, + settingsToken, + }) => { + await authenticatePage(page, settingsToken); + await page.goto(SETTINGS_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + await expect(page.locator(SECTION)).toBeAttached({ timeout: 20_000 }); + await expect(page.locator(TOGGLE_ROW)).toBeAttached(); + await expect(page.locator(RETENTION_ROW)).toBeAttached(); + + const text = (await page.locator(SECTION).innerText()).trim(); + expect(text).not.toContain('$t:'); + expect(text).not.toContain('purge_enabled'); + + const toggle = page.locator(TOGGLE_ROW).locator('input[type="checkbox"]').first(); + expect(await toggle.isChecked()).toBe(false); + }); + + test('저장 요청 본문에 attachment_settings 카테고리가 실린다', async ({ + page, + settingsToken, + }) => { + await authenticatePage(page, settingsToken); + await page.goto(SETTINGS_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + await expect(page.locator(RETENTION_ROW)).toBeAttached({ timeout: 20_000 }); + + const input = page.locator(RETENTION_ROW).locator('input').first(); + const saveButton = page.locator('#footer_save_button'); + const original = (await input.inputValue()) || '30'; + + // 현재값을 그대로 채우면 hasChanges 가 서지 않아 저장 버튼이 비활성인 채로 남고, + // PUT 이 영원히 오지 않아 테스트가 타임아웃한다. 반드시 다른 값으로 바꾼다. + const target = original === '15' ? '20' : '15'; + + const payload = await save(page, input, saveButton, target); + + // 카테고리 자체가 빠지면 서버는 200 을 돌려주고 값만 조용히 사라진다. + expect(payload).toHaveProperty('attachment_settings'); + + // number 입력의 값은 와이어에 문자열로 실리고(서버가 정수로 캐스팅한다) — + // 여기서 검증할 것은 표현 타입이 아니라 "그 값이 실제로 실렸는가" 다. + expect( + Number((payload.attachment_settings as Record).purge_retention_days), + ).toBe(Number(target)); + + // 요청 본문만 보면 서버가 그 값을 버려도 통과한다. 새로고침 후 값이 살아 있어야 + // "실제로 저장됐다" 가 증명된다 (F4 가 정확히 이 지점에서 200 인 채 값을 잃었다). + await page.reload(); + await expect(input).toHaveValue(target, { timeout: 20_000 }); + + // 검증 때문에 운영 설정이 바뀐 채로 남지 않도록 원래 값으로 되돌린다. + await save(page, input, saveButton, original); + await page.reload(); + await expect(input).toHaveValue(original, { timeout: 20_000 }); + }); +}); diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php new file mode 100644 index 00000000..192eefee --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php @@ -0,0 +1,60 @@ +assertArrayHasKey('sirsoft-ckeditor5.image.filter_reference_sources', $hooks); + $this->assertSame('filter', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['type']); + $this->assertSame('addBoardSources', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['method']); + } + + /** + * @effects reference_source_listener_appends_without_dropping + */ + #[Test] + public function it_appends_board_sources_while_preserving_existing_ones(): void + { + $existing = [['table' => 'mail_templates', 'columns' => ['body']]]; + + $result = (new Ckeditor5ReferenceSourcesListener)->addBoardSources($existing); + + $this->assertContains($existing[0], $result); + $this->assertContains(['table' => 'board_posts', 'columns' => ['content']], $result); + } + + /** + * 선언 형태가 계약(table/columns)을 지켜야 플러그인 판정부가 해석할 수 있다. + * + * @effects reference_source_listener_schema_contract + */ + #[Test] + public function appended_sources_follow_the_declared_schema(): void + { + foreach ((new Ckeditor5ReferenceSourcesListener)->addBoardSources([]) as $source) { + $this->assertArrayHasKey('table', $source); + $this->assertArrayHasKey('columns', $source); + $this->assertIsString($source['table']); + $this->assertIsArray($source['columns']); + $this->assertNotEmpty($source['columns']); + } + } +} diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/attachment-cleanup.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/attachment-cleanup.yaml new file mode 100644 index 00000000..35f9f73d --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/attachment-cleanup.yaml @@ -0,0 +1,74 @@ +feature: 게시판 첨부 정리 (Board Attachment Cleanup) + +description: | + 두 종류의 잔존 첨부를 회수한다. 공개 이슈 #115 동류 결함. + + 1. 임시 첨부 (상시 실행) — 글쓰기 폼에서 올린 뒤 저장 없이 이탈해 `temp_key` 만 남은 행. + 연결 시점에 본경로로 옮겨지므로 `temp_key` 잔존 = 끝내 연결되지 않음이 확정이라 + 오탐 여지가 구조적으로 없다. 운영 데이터가 아닌 폼 세션 부산물이므로 토글이 없다. + 2. 소프트 삭제 첨부 영구 정리 (운영자 옵트인) — 첨부 삭제는 휴지통 복원을 위해 소프트 + 삭제만 수행하므로 물리 파일이 남는다. 사용자 파일을 실제로 파기하므로 기본 꺼짐이며, + 보존기간은 휴지통 복원 가능 기간과 같게 유지해야 한다. + + 삭제 순서는 파일 먼저, 기록 나중. 행마다 disk 가 다를 수 있어 그 행의 disk 를 향해 지운다. + +# axes 는 "한 첨부 행의 처리 여부를 가르는 입력" 만 둔다. 실행 흐름(dry-run / 보존기간 해석)은 +# 판정과 직교하므로 sub_flows 로 분리했다 — 한 축으로 묶으면 존재하지 않는 조합이 생긴다. +axes: + attachment_state: [temp_unlinked, linked, soft_deleted] + age: [within_retention, past_retention] + purge_toggle: [off, on] + +exclusions: + - { attachment_state: temp_unlinked, purge_toggle: on, reason: "임시 첨부 정리는 영구 정리 토글과 무관하게 상시 실행된다" } + - { attachment_state: linked, purge_toggle: on, reason: "게시글에 연결된 첨부는 어느 파트의 대상도 아니다" } + - { attachment_state: linked, age: within_retention, reason: "연결 첨부는 나이와 무관하게 대상이 아니다 — 경과분 1건으로 충분" } + - { attachment_state: soft_deleted, purge_toggle: off, age: within_retention, reason: "토글이 꺼져 있으면 보존기간 판정에 도달하지 않는다" } + +sub_flows: + - id: dry_run + description: 판정만 수행하고 파일·기록을 건드리지 않는 미리보기 경로 + effects: + - board_prune_dry_run_no_delete + - id: retention_guard + description: 보존기간 1일 미만 자기 차단 (방금 올린 임시 첨부까지 지우는 사고 방지) + effects: + - board_temp_prune_days_guard + - id: settings_surface + description: 화면 바인딩 키가 저장 카테고리·기본값·서버 검증과 일치하는지 + effects: + - board_settings_purge_toggle_default_off + - board_settings_purge_keys_declared + - id: temp_directory_hygiene + description: | + 파일을 지운 뒤 남는 temp 디렉토리 처리. 비워졌을 때만 지우고, 다른 글의 파일이 남아 + 있으면 그대로 둔다 (디렉토리째 지우면 아직 작성 중인 글의 첨부가 사라진다). + effects: + - board_temp_prune_removes_empty_directory + - board_temp_prune_keeps_directory_with_remaining_files + - id: batch_limit_boundary + description: | + 대상이 한 회차 상한(--limit)을 넘을 때. 처리하며 행을 지우는 순회이므로, 남은 대상이 + 다음 회차에서 반드시 잡혀야 적체가 수렴한다. + effects: + - board_temp_prune_processes_all_across_runs + - id: reference_source_registry + description: 게시글 본문을 에디터 이미지 참조 소스로 등록하는 리스너 + effects: + - reference_source_listener_subscribes_as_filter + - reference_source_listener_appends_without_dropping + - reference_source_listener_schema_contract + +effects: + - board_temp_prune_deletes_file_and_record + - board_temp_prune_keeps_within_retention + - board_temp_prune_skips_linked + - board_purge_gate_off_by_default + - board_purge_gate_on + - board_purge_keeps_restorable_window + +test_files: + - modules/_bundled/sirsoft-board/tests/Feature/Console/PruneAttachmentsCommandTest.php + - modules/_bundled/sirsoft-board/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php + - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-attachment-cleanup-settings.test.ts + - modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-settings-attachment-cleanup.spec.ts diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 39d6698e..03d6203b 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -17,6 +17,8 @@ - 리뷰 이미지 업로드에 파일 가공 필터 훅 제공 — 확장에서 상품·카테고리 이미지와 동일하게 리뷰 이미지도 업로드 시점에 변환(압축·리사이즈·포맷 변경)할 수 있습니다. (#96 @lyg-kaban 님께서 건의해주셨습니다.) - 상품·카테고리·리뷰 이미지를 코어의 공개 자산 스토리지(S3+CDN 등) 디스크에 저장하고 화면에는 CDN 직접 주소로 서빙할 수 있습니다. 기본정보 탭의 「공개 자산 스토리지」에서 이 모듈만 다른 디스크를 쓰도록 개별 설정할 수도 있습니다(비우면 코어 설정 따름). 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시·삭제됩니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.) +- 상품 등록 중 이미지만 올리고 저장하지 않은 채 나간 임시 이미지를 매일 자동 정리합니다. 파일을 지운 뒤 남는 빈 폴더까지 함께 정리하므로 저장소에 흔적이 남지 않습니다. 종전에는 이런 파일이 서버에 계속 쌓여도 회수할 방법이 없었습니다. + ### Changed - 코어 최소 요구 버전을 7.0.7 로 상향했습니다. @@ -24,6 +26,11 @@ ### Fixed +#### 카테고리 이미지 + +- 카테고리를 삭제해도 그 카테고리의 이미지 파일이 서버에 그대로 남던 문제를 수정했습니다. 상품 삭제와 동일하게 파일까지 함께 삭제됩니다. +- 카테고리 등록·수정 화면에서 이미지를 목록에서 빼도 화면에 그대로 남아 저장되던 문제를 수정했습니다. + #### 삭제된 PG·현금영수증 발급사 - 결제수단에 지정한 PG 사의 플러그인을 삭제해도 그 결제수단이 주문서에 계속 노출되던 문제를 수정했습니다. 이전에는 구매자가 그 수단을 고르면 결제창이 뜨지 않은 채 주문완료로 넘어갔습니다. 이제 해당 결제수단은 주문서에서 제외되고, 관리자 주문설정 화면에는 「지정 PG 삭제됨」 표시가 붙습니다. 살아 있는 PG 로 다시 지정하면 즉시 복구되며, 주문서를 거치지 않고 직접 주문을 시도하는 경우에도 안내와 함께 거절됩니다. @@ -69,6 +76,8 @@ - 같은 모달의 "다운로드 기록" 목록이 항상 비어 보이던 문제를 수정했습니다. 이제 누가 언제 몇 번째로 내려받았는지 기록이 표시됩니다. - 같은 모달의 "처리중"·"실패"·"만료" 상태 배지에서 아이콘이 표시되지 않던 문제를 수정했습니다. - 관리자 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다. 주문·상품·쿠폰·배송정책·마일리지 내역·리뷰 목록에 적용됩니다. 쿠폰 적용 대상 고르기처럼 여러 페이지에 걸쳐 선택하는 화면은 종전대로 선택이 유지됩니다. +- 환경설정의 배송·환불 설정 화면과 상품평 목록에서 켜기/끄기 스위치와 상태 배지가 레이아웃 편집기의 편집 대상으로 선택되지 않던 문제를 수정했습니다. +- 배송 국가·배송 유형·환불 사유·추가 배송비 목록에서 각 행이 같은 HTML 식별자를 공유하던 문제를 수정했습니다. #### 기타 diff --git a/modules/_bundled/sirsoft-ecommerce/module.php b/modules/_bundled/sirsoft-ecommerce/module.php index edfe8b7c..d40f81f2 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.php +++ b/modules/_bundled/sirsoft-ecommerce/module.php @@ -19,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultCurrencyOnRegisterListener; use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultShippingCountryOnRegisterListener; use Modules\Sirsoft\Ecommerce\Listeners\CategoryActivityLogListener; use Modules\Sirsoft\Ecommerce\Listeners\CategoryTreeCacheListener; +use Modules\Sirsoft\Ecommerce\Listeners\Ckeditor5ReferenceSourcesListener; use Modules\Sirsoft\Ecommerce\Listeners\CouponActivityLogListener; use Modules\Sirsoft\Ecommerce\Listeners\CouponRestoreListener; use Modules\Sirsoft\Ecommerce\Listeners\CouponUseListener; @@ -2143,6 +2144,7 @@ class Module extends AbstractModule InjectAppConfigDeviceListener::class, IssueCashReceiptOnDepositListener::class, PurgeCashReceiptIdentifierListener::class, + Ckeditor5ReferenceSourcesListener::class, ]; } @@ -2178,6 +2180,14 @@ class Module extends AbstractModule // cart_expiry_days < 1 시 커맨드 내부 self-guard 로 비활성 (별도 토글 없음) 'enabled_config' => null, ], + [ + 'command' => 'sirsoft-ecommerce:prune-temp-product-images', + 'schedule' => 'daily', + 'description' => '미연결 임시 상품 이미지 자동 삭제', + // temp_key 가 남아 있으면 끝내 연결되지 않은 폼 세션 부산물이라 오탐 여지가 없다. + // 운영 데이터가 아니므로 별도 토글 없이 상시 동작한다 (보존기간은 커맨드 옵션). + 'enabled_config' => null, + ], [ 'command' => 'sirsoft-ecommerce:prune-temp-orders', 'schedule' => 'hourly', diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminCategoryImageRemoveExpression.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminCategoryImageRemoveExpression.test.ts new file mode 100644 index 00000000..a40e87c0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminCategoryImageRemoveExpression.test.ts @@ -0,0 +1,135 @@ +/** + * @file adminCategoryImageRemoveExpression.test.ts + * @description 카테고리 이미지 제거 시 form 에서 실제로 빠지는지 — 레이아웃 표현식 자체를 평가한다. + * + * 배경(브라우저 실측): FileUploader 는 `onRemove` 에 **hash 우선**(`item.hash || item.id`)으로 + * 식별자를 넘긴다. 레이아웃이 `img.id !== $args[0]` 로만 걸러내면 저장된 이미지(항상 hash 보유)는 + * 어떤 항목도 제거되지 않아, 이미지는 서버에서 즉시 삭제됐는데 폼에는 그대로 남는다. + * + * 코어 사이트 로고(`admin-settings-logo-remove-expression`)와 같은 근본 원인이며, 같은 방식으로 + * **실제 레이아웃 파일**에서 식을 꺼내 평가한다 — 액션을 테스트 안에 복사해 두면 표현식 자체는 + * 한 번도 평가되지 않아 회귀를 잡지 못한다. + * + * @scenario image_state=temp_unlinked, age=within_retention + * + * @effects category_image_remove_filters_by_hash, category_image_remove_falls_back_to_id + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * 모듈 루트(module.json 기준)를 위로 훑어 찾는다. + * + * @returns 모듈 루트 절대경로 + */ +function moduleRoot(): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 10; depth++) { + if (fs.existsSync(path.join(current, 'module.json'))) { + return current; + } + current = path.dirname(current); + } + + throw new Error('module.json 을 가진 모듈 루트를 찾지 못했습니다.'); +} + +/** + * 레이아웃 트리 전체를 훑어 조건을 만족하는 첫 노드를 찾는다. + * + * 슬롯·partial 등 children 이외의 키에도 노드가 매달리므로 모든 값을 훑는다. + * + * @param node 탐색 시작 노드 + * @param predicate 노드 판정 함수 + * @returns 찾은 노드 또는 null + */ +function findNode(node: unknown, predicate: (n: any) => boolean): any { + if (!node || typeof node !== 'object') return null; + + if (!Array.isArray(node) && predicate(node)) return node; + + for (const value of Object.values(node)) { + const found = findNode(value, predicate); + if (found) return found; + } + + return null; +} + +const panelForm = JSON.parse( + fs.readFileSync( + path.join( + moduleRoot(), + 'resources/layouts/admin/partials/admin_ecommerce_category_index/_panel_form.json', + ), + 'utf-8', + ), +); + +const uploader = findNode( + panelForm, + (n: any) => + n.name === 'FileUploader' + && Array.isArray(n.actions) + && n.actions.some((a: any) => a.event === 'onRemove'), +); + +const removeExpression: string = uploader?.actions + .filter((a: any) => a.event === 'onRemove') + .map((a: any) => a.params?.['form.images']) + .find((v: unknown) => typeof v === 'string'); + +/** + * `{{...}}` 바인딩 식을 주어진 컨텍스트로 평가한다. + * + * @param expression 레이아웃 바인딩 식 + * @param context 평가 컨텍스트 (_local, $args) + * @returns 평가 결과 + */ +function evaluateBinding(expression: string, context: { _local: any; $args: unknown[] }): unknown { + const body = expression.trim().replace(/^\{\{/, '').replace(/\}\}$/, ''); + + return new Function('_local', '$args', `return (${body});`)(context._local, context.$args); +} + +const SAVED_IMAGE = { id: 101, hash: 'abc123', original_filename: 'category1.png' }; +const OTHER_IMAGE = { id: 202, hash: 'def456', original_filename: 'category2.png' }; + +describe('카테고리 이미지 제거 — 레이아웃 표현식 평가', () => { + it('onRemove 식이 레이아웃에 선언되어 있다', () => { + expect(typeof removeExpression).toBe('string'); + }); + + it('hash 로 통지된 항목이 form 에서 제거된다', () => { + const result = evaluateBinding(removeExpression, { + _local: { form: { images: [SAVED_IMAGE, OTHER_IMAGE] } }, + $args: [SAVED_IMAGE.hash], + }) as any[]; + + expect(result.map((item) => item.id)).toEqual([OTHER_IMAGE.id]); + }); + + it('hash 가 없는 항목은 id 로도 제거된다', () => { + const idOnly = { id: 303, original_filename: 'no-hash.png' }; + + const result = evaluateBinding(removeExpression, { + _local: { form: { images: [idOnly, OTHER_IMAGE] } }, + $args: [idOnly.id], + }) as any[]; + + expect(result.map((item) => item.id)).toEqual([OTHER_IMAGE.id]); + }); + + it('대상이 아닌 식별자면 아무것도 제거하지 않는다', () => { + const result = evaluateBinding(removeExpression, { + _local: { form: { images: [SAVED_IMAGE, OTHER_IMAGE] } }, + $args: ['nonexistent-hash'], + }) as any[]; + + expect(result).toHaveLength(2); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicySettingsTab.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicySettingsTab.test.tsx index 9085713f..fcc7b828 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicySettingsTab.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicySettingsTab.test.tsx @@ -6,6 +6,9 @@ * - _tab_shipping.json (배송설정 기본 + 배송가능국가 + 도서산간) * - _shipping_country_table.json (국가 테이블) */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; // 레이아웃 JSON 임포트 @@ -16,6 +19,40 @@ import countryCards from '../../../layouts/admin/partials/admin_ecommerce_settin import disableIntlModal from '../../../layouts/admin/partials/admin_ecommerce_settings/_disable_international_shipping_modal.json'; // ── 헬퍼 함수 ────────────────────────────────────────── + +/** + * 이 화면을 렌더하는 admin 템플릿의 매니페스트가 선언한 컴포넌트 종류를 읽는다. + * + * 레이아웃 노드의 `type` 은 렌더러가 props 전달 범위를 고르는 기준이라 + * 매니페스트 선언과 어긋나면 `editorAttrs` 가 제거돼 레이아웃 편집기에서 표식을 잃는다. + * 바인딩 방식(자동/수동)과는 무관한 값이다. + * + * @param name 컴포넌트 이름 + * @returns 매니페스트가 선언한 type + */ +function manifestType(name: string): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 10; depth++) { + if (fs.existsSync(path.join(current, 'artisan'))) break; + current = path.dirname(current); + } + + const manifest = JSON.parse( + fs.readFileSync( + path.join(current, 'templates/_bundled/sirsoft-admin_basic/components.json'), + 'utf-8', + ), + ); + + for (const [kind, entries] of Object.entries(manifest.components ?? {})) { + if ((entries as any[]).some((entry) => entry.name === name)) { + return kind; + } + } + + throw new Error(`매니페스트에 ${name} 선언이 없습니다.`); +} function flattenAll(node: any): any[] { const result: any[] = []; if (!node) return result; @@ -123,14 +160,15 @@ describe('배송설정 탭 콘텐츠 (_tab_shipping.json)', () => { expect(select.props?.options).toContain('is_active'); }); - it('해외배송 Toggle이 수동 바인딩(basic + checked + actions)으로 구성되어 있다', () => { + it('해외배송 Toggle이 수동 바인딩(checked + actions)으로 구성되어 있다', () => { const intlField = findById(allNodes, 'international_shipping_field'); expect(intlField).toBeDefined(); const toggles = flattenAll(intlField).filter((n: any) => n.name === 'Toggle'); expect(toggles.length).toBeGreaterThan(0); const toggle = toggles[0]; - // 수동 바인딩: type=basic, checked prop, actions 있음 - expect(toggle.type).toBe('basic'); + // 수동 바인딩의 실체는 checked prop + actions 다. 노드 type 은 바인딩 방식과 무관하며 + // 매니페스트 선언을 따라야 한다(어긋나면 레이아웃 편집기에서 표식을 잃는다). + expect(toggle.type).toBe(manifestType('Toggle')); expect(toggle.props?.checked).toContain('international_shipping_enabled'); expect(toggle.actions).toBeDefined(); expect(toggle.actions.length).toBeGreaterThanOrEqual(2); @@ -359,7 +397,7 @@ describe('배송가능국가 테이블 (_shipping_country_table.json)', () => { }); it('Tbody에 iteration이 있다', () => { - const iterNode = findById(allNodes, 'country_row'); + const iterNode = findById(allNodes, 'country_row_{{countryIndex}}'); expect(iterNode).toBeDefined(); expect(iterNode.iteration).toBeDefined(); expect(iterNode.iteration.source).toContain('available_countries'); @@ -496,7 +534,7 @@ describe('바인딩 패턴 일관성 검증', () => { it('해외배송 Toggle이 수동 바인딩이며 ON/OFF 모두 hasChanges를 설정한다', () => { const intlField = findById(allNodes, 'international_shipping_field'); const toggle = flattenAll(intlField).find((n: any) => n.name === 'Toggle'); - expect(toggle.type).toBe('basic'); + expect(toggle.type).toBe(manifestType('Toggle')); expect(toggle.props?.checked).toContain('international_shipping_enabled'); expect(toggle.actions).toBeDefined(); // ON action에 hasChanges: true @@ -758,7 +796,7 @@ describe('배송가능국가 모바일 카드 — 루트 구조', () => { it('카드가 excel-card 클래스로 반복 렌더링된다', () => { const allNodes = flattenAll(countryCards); - const card = findById(allNodes, 'country_card'); + const card = findById(allNodes, 'country_card_{{countryIndex}}'); expect(card).toBeDefined(); expect(card.props?.className).toContain('excel-card'); expect(card.iteration).toBeDefined(); @@ -769,7 +807,7 @@ describe('배송가능국가 모바일 카드 — 루트 구조', () => { it('iteration source에 _idx 인덱스 주입이 포함된다', () => { const allNodes = flattenAll(countryCards); - const card = findById(allNodes, 'country_card'); + const card = findById(allNodes, 'country_card_{{countryIndex}}'); expect(card.iteration.source).toContain('_idx'); expect(card.iteration.source).toContain('.map('); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingTypeSection.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingTypeSection.test.tsx index 04c5325e..0995cf28 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingTypeSection.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingTypeSection.test.tsx @@ -277,7 +277,7 @@ describe('배송유형 카드뷰 구조', () => { const allNodes = flattenAll(typeCards); it('iteration으로 _local.form.shipping.types를 순회한다', () => { - const card = allNodes.find((n: any) => n.id === 'shipping_type_card'); + const card = allNodes.find((n: any) => n.id === 'shipping_type_card_{{tIdx}}'); expect(card).toBeDefined(); expect(card.iteration.source).toContain('_local.form?.shipping?.types'); expect(card.iteration.item_var).toBe('type'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json index 69c7e662..93f671fd 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json @@ -2483,7 +2483,7 @@ "text": "$t:sirsoft-ecommerce.admin.product_review.detail.seller_reply.title" }, { - "type": "basic", + "type": "composite", "name": "Badge", "props": { "color": "{{row.has_reply_badge_color}}", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_category_index/_panel_form.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_category_index/_panel_form.json index bc330b86..5cb56598 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_category_index/_panel_form.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_category_index/_panel_form.json @@ -90,7 +90,7 @@ "if": "{{!_local.isSaving}}", "props": { "name": "save", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -141,7 +141,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" } }, { @@ -567,7 +567,7 @@ "handler": "setState", "params": { "target": "local", - "form.images": "{{(_local.form?.images ?? []).filter(img => img.id !== $args[0])}}" + "form.images": "{{(_local.form?.images ?? []).filter(img => (img.hash || img.id) !== $args[0])}}" } } ] @@ -627,7 +627,7 @@ "props": { "name": "parent_id", "placeholder": "$t:sirsoft-ecommerce.admin.category.form.parent_category_placeholder", - "options": "{{(() => { const result = []; const flatten = (items, path = [], depth = 0) => { if (!items || !Array.isArray(items) || items.length === 0) return; for (const item of items) { if (!item || !item.id) continue; const currentPath = [...path, $localized(item.name)]; result.push({ value: item.id, label: currentPath.join(' > '), id: item.id }); if (item.children && Array.isArray(item.children) && item.children.length > 0 && depth < 2) { flatten(item.children, currentPath, depth + 1); } } }; flatten(categories?.data?.data); const filtered = result.filter(c => c && c.id && (_global.panelMode === 'create' || c.id !== _global.selectedCategoryId)); return [{ value: '', label: $t('sirsoft-ecommerce.admin.category.form.parent_top_level') }, ...filtered]; })()}}", + "options": "{{(() => { const result = []; const flatten = (items, path = [], depth = 0) => { if (!items || !Array.isArray(items) || items.length === 0) return; for (const item of items) { if (!item || !item.id) continue; const currentPath = [...path, $localized(item.name)]; result.push({ value: item.id, label: currentPath.join(' > '), id: item.id }); if (item.children && Array.isArray(item.children) && item.children.length > 0 && depth < 2) { flatten(item.children, currentPath, depth + 1); } } }; flatten(categories?.data?.data); const filtered = result.filter(c => c && c.id && (_global.panelMode === 'create' || c.id !== _global.selectedCategoryId)); return [{ value: '', label: $t('sirsoft-ecommerce.admin.category.form.parent_top_level') }, ...filtered]; })() ?? []}}", "isMulti": false, "isClearable": false, "isSearchable": true, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_refund_reason_cards.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_refund_reason_cards.json index 38822682..a9de4b1d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_refund_reason_cards.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_refund_reason_cards.json @@ -10,7 +10,7 @@ }, "children": [ { - "id": "refund_reason_card", + "id": "refund_reason_card_{{rIdx}}", "type": "basic", "name": "Div", "iteration": { @@ -63,7 +63,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "checked": "{{reason.is_active ?? false}}", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_refund_reason_section.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_refund_reason_section.json index 7407de52..428d605a 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_refund_reason_section.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_refund_reason_section.json @@ -604,7 +604,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "checked": "{{reason.is_active ?? false}}", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_carrier_cards.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_carrier_cards.json index ab5b859a..0438acf9 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_carrier_cards.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_carrier_cards.json @@ -8,7 +8,7 @@ "name": "Div", "children": [ { - "id": "carrier_card", + "id": "carrier_card_{{$cIdx}}", "type": "basic", "name": "Div", "iteration": { @@ -69,7 +69,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "checked": "{{$carrier.is_active ?? false}}", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_carrier_section.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_carrier_section.json index 7683d4aa..d1da0235 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_carrier_section.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_carrier_section.json @@ -677,7 +677,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "checked": "{{$carrier.is_active ?? false}}", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_country_cards.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_country_cards.json index 5e15a721..57b46826 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_country_cards.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_country_cards.json @@ -8,7 +8,7 @@ "name": "Div", "children": [ { - "id": "country_card", + "id": "country_card_{{countryIndex}}", "type": "basic", "name": "Div", "iteration": { @@ -76,7 +76,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "checked": "{{country.is_active ?? false}}", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_country_table.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_country_table.json index aae81b89..e9326691 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_country_table.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_country_table.json @@ -108,7 +108,7 @@ "name": "Tbody", "children": [ { - "id": "country_row", + "id": "country_row_{{countryIndex}}", "type": "basic", "name": "Tr", "iteration": { @@ -166,7 +166,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "checked": "{{country.is_active ?? false}}", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_type_cards.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_type_cards.json index 4ba078bf..664708f9 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_type_cards.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_type_cards.json @@ -10,7 +10,7 @@ }, "children": [ { - "id": "shipping_type_card", + "id": "shipping_type_card_{{tIdx}}", "type": "basic", "name": "Div", "iteration": { @@ -63,7 +63,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "checked": "{{type.is_active ?? false}}", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_type_section.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_type_section.json index 81562972..53b9469e 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_type_section.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_shipping_type_section.json @@ -567,7 +567,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "checked": "{{$type.is_active ?? false}}", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json index 8a6cfc3b..be62378f 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_shipping.json @@ -96,7 +96,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "checked": "{{_local.form?.shipping?.international_shipping_enabled ?? false}}", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_country_basic_fields.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_country_basic_fields.json index b64d7904..6889176d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_country_basic_fields.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_country_basic_fields.json @@ -207,7 +207,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "label": "$t:sirsoft-ecommerce.admin.shipping_policy.form.country_active", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_extra_fee.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_extra_fee.json index c5a161e2..555c7dd2 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_extra_fee.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_extra_fee.json @@ -47,7 +47,7 @@ "name": "Div", "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "label": "$t:sirsoft-ecommerce.admin.shipping_policy.form.extra_fee_enabled", @@ -90,7 +90,7 @@ "name": "Div", "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "label": "$t:sirsoft-ecommerce.admin.shipping_policy.form.extra_fee_multiply", @@ -435,7 +435,7 @@ "portable": { "children": [ { - "id": "extra_fee_cards", + "id": "extra_fee_cards_{{feeIdx}}", "type": "basic", "name": "Div", "iteration": { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Console/Commands/PruneTempProductImagesCommand.php b/modules/_bundled/sirsoft-ecommerce/src/Console/Commands/PruneTempProductImagesCommand.php new file mode 100644 index 00000000..167e1b3a --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Console/Commands/PruneTempProductImagesCommand.php @@ -0,0 +1,92 @@ +option('days'); + + // 보존기간 0 이하 = 방금 올린 임시 이미지까지 지우게 되므로 차단한다. + if ($days < 1) { + $this->info('임시 이미지 보존기간이 1일 미만이어서 정리를 수행하지 않았습니다.'); + + return Command::SUCCESS; + } + + $limit = max(1, (int) $this->option('limit')); + $isDryRun = (bool) $this->option('dry-run'); + + $result = $this->productImageService->pruneTempUploads($days, $limit, $isDryRun); + + if ($isDryRun) { + $this->info("[DRY RUN] 보존기간({$days}일) 경과 미연결 임시 상품 이미지: {$result['scanned']}건"); + + return Command::SUCCESS; + } + + $this->info(sprintf( + '보존기간(%d일) 경과 임시 상품 이미지 %d건 중 %d건을 삭제했습니다. (실패 %d건)', + $days, + $result['scanned'], + $result['deleted'], + $result['failed'], + )); + + Log::info('PruneTempProductImagesCommand: 임시 상품 이미지 정리 완료', [ + 'days' => $days, + 'limit' => $limit, + 'scanned' => $result['scanned'], + 'deleted' => $result['deleted'], + 'failed' => $result['failed'], + ]); + + return Command::SUCCESS; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/Ckeditor5ReferenceSourcesListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/Ckeditor5ReferenceSourcesListener.php new file mode 100644 index 00000000..a85269db --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/Ckeditor5ReferenceSourcesListener.php @@ -0,0 +1,57 @@ + + */ + public static function getSubscribedHooks(): array + { + return [ + 'sirsoft-ckeditor5.image.filter_reference_sources' => [ + 'method' => 'addEcommerceSources', + 'priority' => 10, + 'type' => 'filter', + ], + ]; + } + + /** + * 훅 이벤트를 처리합니다. + * + * Filter 훅은 getSubscribedHooks 에서 지정한 메서드를 직접 호출하므로 + * 이 메서드는 인터페이스 요구사항 충족을 위해서만 존재합니다. + * + * @param mixed ...$args 훅에서 전달된 인수들 + */ + public function handle(...$args): void {} + + /** + * 이커머스 참조 소스를 추가합니다. + * + * @param array $sources 기존 참조 소스 목록 + * @return array 이커머스 소스가 추가된 목록 + */ + public function addEcommerceSources(array $sources): array + { + $sources[] = ['table' => 'ecommerce_products', 'columns' => ['description']]; + $sources[] = ['table' => 'ecommerce_product_common_infos', 'columns' => ['content']]; + + return $sources; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php index 91d3cbf1..681180c0 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php @@ -11,6 +11,7 @@ use Modules\Sirsoft\Ecommerce\Console\Commands\ExpireMileageCommand; use Modules\Sirsoft\Ecommerce\Console\Commands\NotifyExpiringMileageCommand; use Modules\Sirsoft\Ecommerce\Console\Commands\PruneExpiredCartsCommand; use Modules\Sirsoft\Ecommerce\Console\Commands\PruneExpiredTempOrdersCommand; +use Modules\Sirsoft\Ecommerce\Console\Commands\PruneTempProductImagesCommand; use Modules\Sirsoft\Ecommerce\Console\Commands\ReconcileMileageBalanceCommand; use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice; use Modules\Sirsoft\Ecommerce\Repositories\BrandRepository; @@ -199,6 +200,7 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider NotifyExpiringMileageCommand::class, PruneExpiredCartsCommand::class, PruneExpiredTempOrdersCommand::class, + PruneTempProductImagesCommand::class, ReconcileMileageBalanceCommand::class, ]; diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryImageRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryImageRepository.php index b250f70b..1e581ef5 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryImageRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryImageRepository.php @@ -90,6 +90,14 @@ class CategoryImageRepository implements CategoryImageRepositoryInterface return $image?->delete() ?? false; } + /** + * {@inheritDoc} + */ + public function deleteByCategoryId(int $categoryId): int + { + return $this->model->where('category_id', $categoryId)->delete(); + } + /** * {@inheritDoc} */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryImageRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryImageRepositoryInterface.php index 792629ed..ea2f2b6b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryImageRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryImageRepositoryInterface.php @@ -13,7 +13,7 @@ interface CategoryImageRepositoryInterface /** * ID로 이미지 조회 * - * @param int $id 이미지 ID + * @param int $id 이미지 ID * @return CategoryImage|null */ public function findById(int $id): ?CategoryImage; @@ -21,7 +21,7 @@ interface CategoryImageRepositoryInterface /** * 해시로 이미지 조회 * - * @param string $hash 이미지 해시 + * @param string $hash 이미지 해시 * @return CategoryImage|null */ public function findByHash(string $hash): ?CategoryImage; @@ -29,8 +29,8 @@ interface CategoryImageRepositoryInterface /** * 카테고리의 모든 이미지 조회 * - * @param int $categoryId 카테고리 ID - * @param string|null $collection 컬렉션 필터 + * @param int $categoryId 카테고리 ID + * @param string|null $collection 컬렉션 필터 * @return Collection */ public function getByCategoryId(int $categoryId, ?string $collection = null): Collection; @@ -38,8 +38,8 @@ interface CategoryImageRepositoryInterface /** * temp_key로 임시 이미지 조회 * - * @param string $tempKey 임시 키 - * @param string|null $collection 컬렉션 필터 + * @param string $tempKey 임시 키 + * @param string|null $collection 컬렉션 필터 * @return Collection */ public function getByTempKey(string $tempKey, ?string $collection = null): Collection; @@ -47,7 +47,7 @@ interface CategoryImageRepositoryInterface /** * 카테고리 이미지 생성 * - * @param array $data 이미지 데이터 + * @param array $data 이미지 데이터 * @return CategoryImage */ public function create(array $data): CategoryImage; @@ -55,8 +55,8 @@ interface CategoryImageRepositoryInterface /** * 이미지 수정 * - * @param int $id 이미지 ID - * @param array $data 수정할 데이터 + * @param int $id 이미지 ID + * @param array $data 수정할 데이터 * @return CategoryImage */ public function update(int $id, array $data): CategoryImage; @@ -64,16 +64,26 @@ interface CategoryImageRepositoryInterface /** * 이미지 삭제 * - * @param int $id 이미지 ID + * @param int $id 이미지 ID * @return bool */ public function delete(int $id): bool; + /** + * 카테고리에 속한 이미지 행을 일괄 삭제 + * + * 물리 파일 삭제는 호출한 Service 가 담당합니다 (행마다 disk 가 다를 수 있음). + * + * @param int $categoryId 카테고리 ID + * @return int 삭제된 행 수 + */ + public function deleteByCategoryId(int $categoryId): int; + /** * 임시 이미지를 카테고리에 연결 * - * @param string $tempKey 임시 키 - * @param int $categoryId 카테고리 ID + * @param string $tempKey 임시 키 + * @param int $categoryId 카테고리 ID * @return int 연결된 이미지 수 */ public function linkTempImages(string $tempKey, int $categoryId): int; @@ -81,8 +91,8 @@ interface CategoryImageRepositoryInterface /** * 카테고리의 최대 sort_order 조회 * - * @param int $categoryId 카테고리 ID - * @param string $collection 컬렉션명 + * @param int $categoryId 카테고리 ID + * @param string $collection 컬렉션명 * @return int */ public function getMaxSortOrder(int $categoryId, string $collection): int; @@ -90,8 +100,8 @@ interface CategoryImageRepositoryInterface /** * temp_key의 최대 sort_order 조회 * - * @param string $tempKey 임시 키 - * @param string $collection 컬렉션명 + * @param string $tempKey 임시 키 + * @param string $collection 컬렉션명 * @return int */ public function getMaxSortOrderByTempKey(string $tempKey, string $collection): int; @@ -99,7 +109,7 @@ interface CategoryImageRepositoryInterface /** * 순서 변경 * - * @param array $orders 이미지 ID => sort_order 매핑 + * @param array $orders 이미지 ID => sort_order 매핑 * @return bool */ public function reorder(array $orders): bool; diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductImageRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductImageRepositoryInterface.php index 82e16160..a839f867 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductImageRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductImageRepositoryInterface.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories\Contracts; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Carbon; use Modules\Sirsoft\Ecommerce\Models\ProductImage; /** @@ -69,6 +70,18 @@ interface ProductImageRepositoryInterface */ public function delete(int $id): bool; + /** + * 상품에 연결되지 않은 채 방치된 임시 이미지를 오래된 순으로 조회 + * + * 상품 등록 폼에서 업로드만 하고 저장 없이 이탈하면 `temp_key` 가 남은 채 + * `product_id` 가 비어 있는 행과 그 파일이 영구 잔존합니다. 그 회수 대상을 찾습니다. + * + * @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상) + * @param int $limit 최대 조회 건수 + * @return Collection 임시 이미지 목록 (created_at 오름차순) + */ + public function findStaleTempImages(Carbon $threshold, int $limit): Collection; + /** * 임시 이미지를 상품에 연결 * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductImageRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductImageRepository.php index 383f0a13..4d081c19 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductImageRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductImageRepository.php @@ -3,6 +3,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Carbon; use Modules\Sirsoft\Ecommerce\Models\ProductImage; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductImageRepositoryInterface; @@ -90,6 +91,21 @@ class ProductImageRepository implements ProductImageRepositoryInterface return $image?->forceDelete() ?? false; } + /** + * {@inheritDoc} + */ + public function findStaleTempImages(Carbon $threshold, int $limit): Collection + { + return $this->model->newQuery() + ->whereNotNull('temp_key') + ->whereNull('product_id') + ->where('created_at', '<', $threshold) + ->orderBy('created_at') + ->orderBy('id') + ->limit($limit) + ->get(['id', 'temp_key', 'disk', 'path', 'created_at']); + } + /** * {@inheritDoc} */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryImageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryImageService.php index 04260ad0..44893d77 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryImageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryImageService.php @@ -248,6 +248,49 @@ class CategoryImageService return $result; } + /** + * 카테고리에 속한 이미지를 파일까지 함께 삭제합니다. + * + * 카테고리 삭제 흐름에서 호출합니다. 단건 삭제(delete)와 달리 남은 이미지 재정렬이 + * 필요 없으므로(카테고리 자체가 사라짐) 재정렬 없이 파일 → 행 순으로 정리합니다. + * + * 행마다 disk 가 다를 수 있으므로 파일 삭제는 각 행의 disk 를 향해 수행합니다. + * + * @param int $categoryId 카테고리 ID + * @return int 삭제된 이미지 행 수 + */ + public function deleteByCategoryId(int $categoryId): int + { + $images = $this->repository->getByCategoryId($categoryId); + + if ($images->isEmpty()) { + return 0; + } + + foreach ($images as $image) { + HookManager::doAction('sirsoft-ecommerce.category-image.before_delete', $image); + + $rowStorage = $this->storageForRow($image->disk); + + if ($rowStorage->exists('images', $image->path)) { + $rowStorage->delete('images', $image->path); + } + } + + $deleted = $this->repository->deleteByCategoryId($categoryId); + + Log::info('카테고리 이미지 일괄 삭제 완료', [ + 'category_id' => $categoryId, + 'deleted' => $deleted, + ]); + + foreach ($images as $image) { + HookManager::doAction('sirsoft-ecommerce.category-image.after_delete', $image); + } + + return $deleted; + } + /** * 순서 변경 * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php index 3fa89184..c413ebba 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php @@ -18,7 +18,8 @@ class CategoryService { public function __construct( protected CategoryRepositoryInterface $repository, - protected CategoryImageRepositoryInterface $imageRepository + protected CategoryImageRepositoryInterface $imageRepository, + protected CategoryImageService $categoryImageService ) {} /** @@ -274,7 +275,9 @@ class CategoryService DB::transaction(function () use ($category) { // 카테고리 이미지 삭제 (명시적 삭제 - CASCADE 의존 금지) - $category->images()->delete(); + // 물리 파일 → 행 순으로 정리한다. 행만 지우면 파일이 디스크에 영구 잔존한다 + // (상품 삭제와 동일한 순서 — ProductService::delete 선례). + $this->categoryImageService->deleteByCategoryId($category->id); // 카테고리 삭제 $this->repository->delete($category->id); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php index d40f2b63..8ec5d373 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php @@ -7,6 +7,7 @@ use App\Extension\HookManager; use App\Support\ImageResizer; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -492,6 +493,92 @@ class ProductImageService return ! in_array(false, $results, true); } + /** + * 상품에 연결되지 않은 채 방치된 임시 이미지를 정리합니다. + * + * 상품 등록 폼에서 이미지를 올린 뒤 저장하지 않고 이탈하면 `temp_key` 만 남은 행과 + * 그 파일이 남습니다. 연결 시점에 본경로로 옮겨지므로 `temp_key` 가 남아 있다는 것은 + * "끝내 연결되지 않았다" 는 뜻이고, 그래서 오탐 여지가 없습니다. + * + * 파일은 행마다 기록된 disk 를 향해 지웁니다 (디스크 전환 이후 혼재 대응). + * 파일 → 행 순서를 지켜, 행이 먼저 사라져 파일을 못 찾는 상태를 만들지 않습니다. + * + * @param int $days 보존기간(일) + * @param int $limit 한 회차에 처리할 최대 건수 + * @param bool $dryRun true 면 대상만 세고 삭제하지 않음 + * @return array{scanned: int, deleted: int, failed: int} 처리 결과 + */ + public function pruneTempUploads(int $days, int $limit, bool $dryRun = false): array + { + $threshold = Carbon::now()->subDays($days); + $images = $this->repository->findStaleTempImages($threshold, $limit); + + $result = ['scanned' => $images->count(), 'deleted' => 0, 'failed' => 0]; + + if ($dryRun) { + return $result; + } + + foreach ($images as $image) { + $rowStorage = $this->storageForRow($image->disk); + + if ($rowStorage->exists('images', $image->path) && ! $rowStorage->delete('images', $image->path)) { + Log::warning('임시 상품 이미지 파일 삭제 실패 — 기록 보존', [ + 'image_id' => $image->id, + 'disk' => $image->disk, + 'path' => $image->path, + ]); + + $result['failed']++; + + continue; + } + + $this->repository->delete($image->id); + $result['deleted']++; + } + + $this->removeEmptyTempDirectories($images); + + return $result; + } + + /** + * 파일을 모두 지운 temp_key 디렉토리를 정리합니다. + * + * 파일만 지우고 디렉토리를 남기면 폼 세션마다 빈 디렉토리가 쌓여, 정리를 돌려도 + * 저장소에는 흔적이 계속 늘어납니다. + * + * 디렉토리에 파일이 남아 있으면(같은 temp_key 의 다른 이미지가 limit 에 걸려 이번 회차에서 + * 빠졌거나 파일 삭제에 실패한 경우 등) 삭제하지 않습니다. + * + * @param Collection $images 이번 회차에 처리한 이미지 목록 + */ + private function removeEmptyTempDirectories(Collection $images): void + { + $directories = []; + + foreach ($images as $image) { + $directory = dirname((string) $image->path); + + if ($directory === '' || $directory === '.') { + continue; + } + + $directories[$image->disk.'|'.$directory] = [$image->disk, $directory]; + } + + foreach ($directories as [$disk, $directory]) { + $storage = $this->storageForRow($disk); + + if ($storage->files('images', $directory) !== []) { + continue; + } + + $storage->deleteDirectory('images', $directory); + } + } + /** * 상품의 이미지 목록을 조회합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Console/PruneTempProductImagesCommandTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Console/PruneTempProductImagesCommandTest.php new file mode 100644 index 00000000..43adc641 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Console/PruneTempProductImagesCommandTest.php @@ -0,0 +1,186 @@ + $attributes 덮어쓸 속성 + * @return ProductImage 생성된 이미지 + */ + private function makeImage(array $attributes = []): ProductImage + { + $filename = uniqid('temp-product-').'.png'; + $path = $attributes['path'] ?? "products/temp/tempkey/{$filename}"; + + (new ModuleStorageDriver('sirsoft-ecommerce', 'modules'))->put('images', $path, 'bytes'); + + return ProductImage::create(array_merge([ + 'product_id' => null, + 'temp_key' => 'tempkey', + 'original_filename' => $filename, + 'stored_filename' => $filename, + 'disk' => 'modules', + 'path' => $path, + 'mime_type' => 'image/png', + 'file_size' => 5, + 'collection' => 'main', + 'sort_order' => 1, + ], $attributes)); + } + + /** + * @scenario age=past_retention, image_state=temp_unlinked + * + * @effects ecommerce_temp_prune_deletes_file_and_record + */ + #[Test] + public function stale_temp_images_lose_both_file_and_record(): void + { + $image = $this->makeImage(); + $image->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + $this->artisan('sirsoft-ecommerce:prune-temp-product-images')->assertSuccessful(); + + $this->assertFalse( + (new ModuleStorageDriver('sirsoft-ecommerce', 'modules'))->exists('images', $image->path) + ); + $this->assertDatabaseMissing('ecommerce_product_images', ['id' => $image->id]); + } + + /** + * @scenario age=within_retention, image_state=temp_unlinked + * + * @effects ecommerce_temp_prune_keeps_within_retention + */ + #[Test] + public function temp_image_within_retention_is_kept(): void + { + $image = $this->makeImage(); + + $this->artisan('sirsoft-ecommerce:prune-temp-product-images')->assertSuccessful(); + + $this->assertDatabaseHas('ecommerce_product_images', ['id' => $image->id]); + } + + /** + * @scenario age=past_retention, image_state=linked_to_product + * + * @effects ecommerce_temp_prune_skips_linked + */ + #[Test] + public function image_linked_to_a_product_is_never_pruned(): void + { + $product = Product::factory()->create(); + + $image = $this->makeImage([ + 'product_id' => $product->id, + 'temp_key' => null, + 'path' => "products/{$product->product_code}/".uniqid('linked-').'.png', + ]); + $image->forceFill(['created_at' => now()->subDays(90)])->saveQuietly(); + + $this->artisan('sirsoft-ecommerce:prune-temp-product-images')->assertSuccessful(); + + $this->assertDatabaseHas('ecommerce_product_images', ['id' => $image->id]); + } + + /** + * @effects ecommerce_temp_prune_dry_run + */ + #[Test] + public function dry_run_reports_targets_without_deleting(): void + { + $image = $this->makeImage(); + $image->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + $this->artisan('sirsoft-ecommerce:prune-temp-product-images --dry-run') + ->expectsOutputToContain('[DRY RUN]') + ->assertSuccessful(); + + $this->assertDatabaseHas('ecommerce_product_images', ['id' => $image->id]); + } + + /** + * @effects ecommerce_temp_prune_days_guard + */ + #[Test] + public function retention_below_one_day_performs_no_cleanup(): void + { + $image = $this->makeImage(); + $image->forceFill(['created_at' => now()->subDays(90)])->saveQuietly(); + + $this->artisan('sirsoft-ecommerce:prune-temp-product-images --days=0')->assertSuccessful(); + + $this->assertDatabaseHas('ecommerce_product_images', ['id' => $image->id]); + } + + /** + * 파일을 비운 temp_key 디렉토리는 남기지 않는다 (회귀) + * + * 파일만 지우고 디렉토리를 남기면 상품 등록 폼 세션마다 빈 디렉토리가 쌓여, 정리를 + * 돌려도 저장소 흔적은 계속 늘어난다. + * + * @scenario age=past_retention, image_state=temp_unlinked + * + * @effects ecommerce_temp_prune_removes_empty_directory + */ + #[Test] + public function emptied_temp_directory_is_removed(): void + { + $image = $this->makeImage(['path' => 'products/temp/tempkey-empty-dir/'.uniqid('t-').'.png']); + $image->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + $this->artisan('sirsoft-ecommerce:prune-temp-product-images')->assertSuccessful(); + + $storage = new ModuleStorageDriver('sirsoft-ecommerce', 'modules'); + + $this->assertSame( + [], + $storage->files('images', 'products/temp/tempkey-empty-dir'), + '디렉토리 안의 파일은 모두 삭제되어야 한다.' + ); + $this->assertDirectoryDoesNotExist( + rtrim($storage->getBasePath('images'), '/\\').'/products/temp/tempkey-empty-dir', + '비워진 temp 디렉토리는 남기지 않는다.' + ); + } + + /** + * 같은 temp_key 에 처리되지 않은 파일이 남아 있으면 디렉토리를 지우지 않는다. + * + * @scenario age=past_retention, image_state=temp_unlinked + * + * @effects ecommerce_temp_prune_keeps_directory_with_remaining_files + */ + #[Test] + public function temp_directory_with_remaining_files_is_kept(): void + { + $directory = 'products/temp/tempkey-partial'; + + $processed = $this->makeImage(['path' => $directory.'/'.uniqid('done-').'.png']); + $processed->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + // 같은 디렉토리에 이번 회차 대상이 아닌 파일이 남아 있는 상태 + $storage = new ModuleStorageDriver('sirsoft-ecommerce', 'modules'); + $storage->put('images', $directory.'/keep.png', 'bytes'); + + $this->artisan('sirsoft-ecommerce:prune-temp-product-images')->assertSuccessful(); + + $this->assertTrue($storage->exists('images', $directory.'/keep.png')); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/CategoryDeleteImageFileTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/CategoryDeleteImageFileTest.php new file mode 100644 index 00000000..9c105ecc --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/CategoryDeleteImageFileTest.php @@ -0,0 +1,119 @@ +images()->delete()` 로 DB 행만 지워 파일이 디스크에 영구 잔존했다. + * 같은 모듈의 상품 삭제는 파일까지 지우므로 두 경로가 비대칭이었다. + * + * @see ProductService::delete() 대칭 선례 + */ +class CategoryDeleteImageFileTest extends ModuleTestCase +{ + /** + * 지정 disk 에 파일과 카테고리 이미지 행을 함께 생성합니다. + * + * @param int $categoryId 카테고리 ID + * @param string $disk 행 disk + * @param string $path 이미지 경로 (images/ 하위 상대 경로) + * @return CategoryImage 생성된 행 + */ + private function makeImage(int $categoryId, string $disk, string $path): CategoryImage + { + (new ModuleStorageDriver('sirsoft-ecommerce', $disk))->put('images', $path, 'image-bytes'); + + return CategoryImage::create([ + 'category_id' => $categoryId, + 'original_filename' => 'category.jpg', + 'stored_filename' => basename($path), + 'disk' => $disk, + 'path' => $path, + 'mime_type' => 'image/jpeg', + 'file_size' => 11, + 'collection' => 'main', + 'sort_order' => 0, + ]); + } + + /** + * @effects category_delete_removes_image_files, category_delete_removes_image_rows + */ + #[Test] + public function deleting_a_category_removes_its_image_files_and_rows(): void + { + $category = Category::create([ + 'name' => ['ko' => '삭제 대상 카테고리', 'en' => 'Category To Delete'], + 'slug' => 'category-delete-image-file-test', + 'is_active' => true, + 'depth' => 0, + 'path' => '', + 'sort_order' => 0, + ]); + + $image = $this->makeImage($category->id, 'modules', 'category/delete-test/main.jpg'); + + $storage = new ModuleStorageDriver('sirsoft-ecommerce', 'modules'); + $this->assertTrue( + $storage->exists('images', $image->path), + '사전 조건: 삭제 전에는 이미지 파일이 존재해야 한다.' + ); + + app(CategoryService::class)->deleteCategory($category->id); + + $this->assertFalse( + $storage->exists('images', $image->path), + '카테고리를 삭제하면 이미지 물리 파일도 함께 삭제되어야 한다.' + ); + + $this->assertDatabaseMissing('ecommerce_category_images', ['id' => $image->id]); + $this->assertDatabaseMissing('ecommerce_categories', ['id' => $category->id]); + } + + /** + * 행마다 disk 가 다른 혼재 상태에서도 각 행의 실제 저장 위치를 향해 삭제해야 한다. + * + * @effects category_delete_removes_image_files + */ + #[Test] + public function deleting_a_category_removes_image_files_across_mixed_disks(): void + { + config()->set('filesystems.disks.fake_cdn', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + + $category = Category::create([ + 'name' => ['ko' => '혼재 디스크 카테고리', 'en' => 'Mixed Disk Category'], + 'slug' => 'category-delete-mixed-disk-test', + 'is_active' => true, + 'depth' => 0, + 'path' => '', + 'sort_order' => 0, + ]); + + $local = $this->makeImage($category->id, 'modules', 'category/delete-mixed/local.jpg'); + $cdn = $this->makeImage($category->id, 'fake_cdn', 'category/delete-mixed/cdn.jpg'); + + app(CategoryService::class)->deleteCategory($category->id); + + $this->assertFalse( + (new ModuleStorageDriver('sirsoft-ecommerce', 'modules'))->exists('images', $local->path), + '기본 디스크 행의 파일이 삭제되어야 한다.' + ); + $this->assertFalse( + (new ModuleStorageDriver('sirsoft-ecommerce', 'fake_cdn'))->exists('images', $cdn->path), + '다른 디스크 행의 파일도 그 행의 disk 를 향해 삭제되어야 한다.' + ); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php new file mode 100644 index 00000000..6a3b7b11 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php @@ -0,0 +1,59 @@ +assertArrayHasKey('sirsoft-ckeditor5.image.filter_reference_sources', $hooks); + $this->assertSame('filter', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['type']); + $this->assertSame('addEcommerceSources', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['method']); + } + + /** + * @effects reference_source_listener_appends_without_dropping + */ + #[Test] + public function it_appends_ecommerce_sources_while_preserving_existing_ones(): void + { + $existing = [['table' => 'mail_templates', 'columns' => ['body']]]; + + $result = (new Ckeditor5ReferenceSourcesListener)->addEcommerceSources($existing); + + $this->assertContains($existing[0], $result); + $this->assertContains(['table' => 'ecommerce_products', 'columns' => ['description']], $result); + $this->assertContains(['table' => 'ecommerce_product_common_infos', 'columns' => ['content']], $result); + } + + /** + * @effects reference_source_listener_schema_contract + */ + #[Test] + public function appended_sources_follow_the_declared_schema(): void + { + foreach ((new Ckeditor5ReferenceSourcesListener)->addEcommerceSources([]) as $source) { + $this->assertArrayHasKey('table', $source); + $this->assertArrayHasKey('columns', $source); + $this->assertIsString($source['table']); + $this->assertIsArray($source['columns']); + $this->assertNotEmpty($source['columns']); + } + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryServiceTest.php index 3e551499..2c913027 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryServiceTest.php @@ -7,6 +7,7 @@ use Mockery; use Modules\Sirsoft\Ecommerce\Models\Category; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryImageRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Services\CategoryImageService; use Modules\Sirsoft\Ecommerce\Services\CategoryService; use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; @@ -23,13 +24,20 @@ class CategoryServiceTest extends ModuleTestCase protected $mockImageRepository; + protected $mockImageService; + protected function setUp(): void { parent::setUp(); $this->mockRepository = Mockery::mock(CategoryRepositoryInterface::class); $this->mockImageRepository = Mockery::mock(CategoryImageRepositoryInterface::class); - $this->service = new CategoryService($this->mockRepository, $this->mockImageRepository); + $this->mockImageService = Mockery::mock(CategoryImageService::class); + $this->service = new CategoryService( + $this->mockRepository, + $this->mockImageRepository, + $this->mockImageService, + ); } protected function tearDown(): void @@ -43,7 +51,10 @@ class CategoryServiceTest extends ModuleTestCase // ======================================== /** - * 카테고리 삭제 시 이미지가 명시적으로 삭제되는지 확인 + * 카테고리 삭제 시 이미지가 파일까지 명시적으로 삭제되는지 확인 + * + * 행만 지우면 물리 파일이 디스크에 영구 잔존하므로, 삭제는 파일 삭제를 포함하는 + * CategoryImageService 경로를 거쳐야 한다. */ public function test_delete_category_deletes_images(): void { @@ -51,9 +62,11 @@ class CategoryServiceTest extends ModuleTestCase $category = Mockery::mock(Category::class)->makePartial(); $category->shouldReceive('getAttribute')->with('id')->andReturn(1); - $mockImages = Mockery::mock(HasMany::class); - $mockImages->shouldReceive('delete')->once(); - $category->shouldReceive('images')->once()->andReturn($mockImages); + $this->mockImageService + ->shouldReceive('deleteByCategoryId') + ->with(1) + ->once() + ->andReturn(1); $this->mockRepository ->shouldReceive('findById') diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/temp-image-cleanup.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/temp-image-cleanup.yaml new file mode 100644 index 00000000..8297a7bf --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/temp-image-cleanup.yaml @@ -0,0 +1,66 @@ +feature: 상품 이미지 잔존물 회수 (Temp Product Image Cleanup + Category Delete) + +description: | + 이커머스의 이미지 잔존물 두 갈래를 회수한다. 공개 이슈 #115 동류 결함. + + 1. 미연결 임시 상품 이미지 — 상품 등록 폼에서 올린 뒤 저장 없이 이탈해 `temp_key` 만 + 남고 `product_id` 가 비어 있는 행. 연결 시점에 본경로로 옮겨지므로 오탐 여지가 없다. + 운영 데이터가 아닌 폼 세션 부산물이라 토글 없이 상시 실행된다. + 2. 카테고리 삭제 시 이미지 파일 잔존 (버그) — `$category->images()->delete()` 로 DB 행만 + 지워 파일이 디스크에 영구 잔존했다. 같은 모듈의 상품 삭제는 파일까지 지우므로 두 경로가 + 비대칭이었다. 파일 → 행 순서로 정리하도록 교정했다(ProductService 선례와 동일 순서). + +axes: + image_state: [temp_unlinked, linked_to_product] + age: [within_retention, past_retention] + +exclusions: + - { image_state: linked_to_product, age: within_retention, reason: "연결된 이미지는 나이와 무관하게 대상이 아니다 — 경과분 1건으로 충분" } + +sub_flows: + - id: dry_run + description: 판정만 수행하고 파일·기록을 건드리지 않는 미리보기 경로 + effects: + - ecommerce_temp_prune_dry_run + - id: retention_guard + description: 보존기간 1일 미만 자기 차단 (방금 올린 임시 이미지까지 지우는 사고 방지) + effects: + - ecommerce_temp_prune_days_guard + - id: temp_directory_hygiene + description: | + 파일을 지운 뒤 남는 temp 디렉토리 처리. 비워졌을 때만 지우고, 다른 폼 세션의 파일이 + 남아 있으면 그대로 둔다 (디렉토리째 지우면 아직 작성 중인 상품의 이미지가 사라진다). + effects: + - ecommerce_temp_prune_removes_empty_directory + - ecommerce_temp_prune_keeps_directory_with_remaining_files + - id: category_delete + description: | + 카테고리 삭제가 이미지 파일까지 지우는지 (회귀). 행마다 disk 가 다른 혼재 상태에서도 + 각 행의 실제 저장 위치를 향해 지워야 한다. + effects: + - category_delete_removes_image_files + - category_delete_removes_image_rows + - id: category_image_remove_ui + description: | + 카테고리 폼에서 이미지를 뺐을 때 폼 상태에서도 빠지는지. FileUploader 는 hash 우선으로 + 식별자를 넘기므로 id 로만 걸러내면 서버에서는 지워졌는데 폼에는 남는다. + effects: + - category_image_remove_filters_by_hash + - category_image_remove_falls_back_to_id + - id: reference_source_registry + description: 상품 설명·공통 안내를 에디터 이미지 참조 소스로 등록하는 리스너 + effects: + - reference_source_listener_subscribes_as_filter + - reference_source_listener_appends_without_dropping + - reference_source_listener_schema_contract + +effects: + - ecommerce_temp_prune_deletes_file_and_record + - ecommerce_temp_prune_keeps_within_retention + - ecommerce_temp_prune_skips_linked + +test_files: + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Console/PruneTempProductImagesCommandTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/CategoryDeleteImageFileTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php + - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminCategoryImageRemoveExpression.test.ts diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 514da00b..230962cb 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -11,6 +11,10 @@ - 미발행(초안) 페이지에 붙은 이미지 첨부의 미리보기가 주소만 알면 누구에게나 열리던 문제를 수정했습니다. 다운로드는 이미 발행 상태를 확인하고 있었지만 미리보기는 확인하지 않아, 발행 전 콘텐츠의 이미지가 새어 나갈 수 있었습니다. 이제 미리보기도 다운로드와 같은 기준을 적용해, 발행된 페이지의 첨부는 누구나, 미발행 페이지의 첨부는 페이지 조회 권한이 있는 관리자만 미리볼 수 있습니다. 편집 중인 초안의 썸네일은 편집 권한이 있는 관리자에게 종전처럼 정상 표시됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914) - 페이지 일괄 발행/발행취소와 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 페이지를 하나씩 여는 화면에는 이 확인이 있었지만 목록에서 여러 건을 한 번에 처리하는 경로와 첨부 관리 경로에는 없어, "본인이 만든 페이지만" 으로 범위를 좁혀 위임받은 관리자가 담당 밖 페이지까지 발행 상태를 바꾸거나 첨부를 지울 수 있었습니다. 이제 대상마다 같은 기준으로 확인하며, 범위 밖 대상이 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. 범위 제한 없이 위임받은 관리자의 작업은 종전처럼 정상 동작합니다. (KVE-2026-1919) +### Added + +- 페이지 작성 중 파일만 올리고 저장하지 않은 채 나간 임시 첨부를 매일 자동 정리합니다. 파일을 지운 뒤 남는 빈 폴더까지 함께 정리하므로 저장소에 흔적이 남지 않습니다. 종전에는 이런 파일이 서버에 계속 쌓여도 회수할 방법이 없었습니다. + ### Changed - 첨부 개수 상한 초과 안내 문구를 만드는 시점이 응답을 만드는 시점과 어긋나 있던 것을 맞췄습니다. 표시되는 문구와 상태는 종전과 동일합니다. diff --git a/modules/_bundled/sirsoft-page/module.php b/modules/_bundled/sirsoft-page/module.php index cf10e164..9b8e7855 100644 --- a/modules/_bundled/sirsoft-page/module.php +++ b/modules/_bundled/sirsoft-page/module.php @@ -6,6 +6,7 @@ use App\Extension\AbstractModule; use Illuminate\Database\Seeder; use Modules\Sirsoft\Page\Database\Seeders\PageSeeder; use Modules\Sirsoft\Page\Listeners\ActivityLogDescriptionResolver; +use Modules\Sirsoft\Page\Listeners\Ckeditor5ReferenceSourcesListener; use Modules\Sirsoft\Page\Listeners\PageActivityLogListener; use Modules\Sirsoft\Page\Listeners\SearchPagesListener; use Modules\Sirsoft\Page\Listeners\SeoPageCacheListener; @@ -132,6 +133,28 @@ class Module extends AbstractModule PageActivityLogListener::class, ActivityLogDescriptionResolver::class, SeoPageCacheListener::class, + Ckeditor5ReferenceSourcesListener::class, + ]; + } + + /** + * 모듈 스케줄 목록 반환 + * + * 페이지 작성 폼에서 올린 뒤 저장되지 않은 임시 첨부를 매일 정리합니다. + * `temp_key` 가 남아 있으면 끝내 연결되지 않은 폼 세션 부산물이라 오탐 여지가 없어 + * 별도 운영자 토글 없이 상시 동작합니다 (보존기간은 커맨드 옵션). + * + * @return array> 스케줄 정의 목록 + */ + public function getSchedules(): array + { + return [ + [ + 'command' => 'sirsoft-page:prune-temp-attachments', + 'schedule' => 'daily', + 'description' => '미연결 임시 페이지 첨부 자동 삭제', + 'enabled_config' => null, + ], ]; } diff --git a/modules/_bundled/sirsoft-page/src/Console/Commands/PruneTempAttachmentsCommand.php b/modules/_bundled/sirsoft-page/src/Console/Commands/PruneTempAttachmentsCommand.php new file mode 100644 index 00000000..997c2a95 --- /dev/null +++ b/modules/_bundled/sirsoft-page/src/Console/Commands/PruneTempAttachmentsCommand.php @@ -0,0 +1,92 @@ +option('days'); + + // 보존기간 0 이하 = 방금 올린 임시 첨부까지 지우게 되므로 차단한다. + if ($days < 1) { + $this->info('임시 첨부 보존기간이 1일 미만이어서 정리를 수행하지 않았습니다.'); + + return Command::SUCCESS; + } + + $limit = max(1, (int) $this->option('limit')); + $isDryRun = (bool) $this->option('dry-run'); + + $result = $this->attachmentService->pruneTempUploads($days, $limit, $isDryRun); + + if ($isDryRun) { + $this->info("[DRY RUN] 보존기간({$days}일) 경과 미연결 임시 페이지 첨부: {$result['scanned']}건"); + + return Command::SUCCESS; + } + + $this->info(sprintf( + '보존기간(%d일) 경과 임시 페이지 첨부 %d건 중 %d건을 삭제했습니다. (실패 %d건)', + $days, + $result['scanned'], + $result['deleted'], + $result['failed'], + )); + + Log::info('PruneTempAttachmentsCommand: 임시 페이지 첨부 정리 완료', [ + 'days' => $days, + 'limit' => $limit, + 'scanned' => $result['scanned'], + 'deleted' => $result['deleted'], + 'failed' => $result['failed'], + ]); + + return Command::SUCCESS; + } +} diff --git a/modules/_bundled/sirsoft-page/src/Listeners/Ckeditor5ReferenceSourcesListener.php b/modules/_bundled/sirsoft-page/src/Listeners/Ckeditor5ReferenceSourcesListener.php new file mode 100644 index 00000000..611208c5 --- /dev/null +++ b/modules/_bundled/sirsoft-page/src/Listeners/Ckeditor5ReferenceSourcesListener.php @@ -0,0 +1,57 @@ + + */ + public static function getSubscribedHooks(): array + { + return [ + 'sirsoft-ckeditor5.image.filter_reference_sources' => [ + 'method' => 'addPageSources', + 'priority' => 10, + 'type' => 'filter', + ], + ]; + } + + /** + * 훅 이벤트를 처리합니다. + * + * Filter 훅은 getSubscribedHooks 에서 지정한 메서드를 직접 호출하므로 + * 이 메서드는 인터페이스 요구사항 충족을 위해서만 존재합니다. + * + * @param mixed ...$args 훅에서 전달된 인수들 + */ + public function handle(...$args): void {} + + /** + * 페이지 참조 소스를 추가합니다. + * + * @param array $sources 기존 참조 소스 목록 + * @return array 페이지 소스가 추가된 목록 + */ + public function addPageSources(array $sources): array + { + $sources[] = ['table' => 'pages', 'columns' => ['content']]; + $sources[] = ['table' => 'page_versions', 'columns' => ['content']]; + + return $sources; + } +} diff --git a/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php b/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php index 8f16dda5..2207a73f 100644 --- a/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php +++ b/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php @@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Page\Providers; use App\Extension\BaseModuleServiceProvider; use App\Seo\SitemapGenerator; use Modules\Sirsoft\Page\Console\Commands\PlaywrightSeedPage; +use Modules\Sirsoft\Page\Console\Commands\PruneTempAttachmentsCommand; use Modules\Sirsoft\Page\Repositories\Contracts\PageAttachmentRepositoryInterface; use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface; use Modules\Sirsoft\Page\Repositories\Contracts\PageVersionRepositoryInterface; @@ -57,6 +58,7 @@ class PageServiceProvider extends BaseModuleServiceProvider if ($this->app->runningInConsole()) { $this->commands([ PlaywrightSeedPage::class, + PruneTempAttachmentsCommand::class, ]); } diff --git a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageAttachmentRepositoryInterface.php b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageAttachmentRepositoryInterface.php index e7b935b7..1cefb469 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageAttachmentRepositoryInterface.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageAttachmentRepositoryInterface.php @@ -3,6 +3,8 @@ namespace Modules\Sirsoft\Page\Repositories\Contracts; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Support\Carbon; use Modules\Sirsoft\Page\Models\PageAttachment; /** @@ -65,7 +67,7 @@ interface PageAttachmentRepositoryInterface * @param int $id 첨부파일 ID * @return PageAttachment 첨부파일 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function findOrFail(int $id): PageAttachment; @@ -77,6 +79,18 @@ interface PageAttachmentRepositoryInterface */ public function delete(PageAttachment $attachment): bool; + /** + * 페이지에 연결되지 않은 채 방치된 임시 첨부를 오래된 순으로 조회합니다. + * + * 페이지 작성 폼에서 업로드만 하고 저장 없이 이탈하면 `temp_key` 가 남은 채 + * `page_id` 가 비어 있는 행과 그 파일이 영구 잔존합니다. 그 회수 대상을 찾습니다. + * + * @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상) + * @param int $limit 최대 조회 건수 + * @return Collection 임시 첨부 목록 (created_at 오름차순) + */ + public function findStaleTempAttachments(Carbon $threshold, int $limit): Collection; + /** * 첨부파일을 수정합니다. * diff --git a/modules/_bundled/sirsoft-page/src/Repositories/PageAttachmentRepository.php b/modules/_bundled/sirsoft-page/src/Repositories/PageAttachmentRepository.php index 6b7ee7a9..a2e92f7f 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/PageAttachmentRepository.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/PageAttachmentRepository.php @@ -3,6 +3,8 @@ namespace Modules\Sirsoft\Page\Repositories; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Page\Models\PageAttachment; use Modules\Sirsoft\Page\Repositories\Contracts\PageAttachmentRepositoryInterface; @@ -97,7 +99,7 @@ class PageAttachmentRepository implements PageAttachmentRepositoryInterface * @param int $id 첨부파일 ID * @return PageAttachment 첨부파일 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function findOrFail(int $id): PageAttachment { @@ -115,6 +117,21 @@ class PageAttachmentRepository implements PageAttachmentRepositoryInterface return (bool) $attachment->delete(); } + /** + * {@inheritDoc} + */ + public function findStaleTempAttachments(Carbon $threshold, int $limit): Collection + { + return PageAttachment::query() + ->whereNotNull('temp_key') + ->whereNull('page_id') + ->where('created_at', '<', $threshold) + ->orderBy('created_at') + ->orderBy('id') + ->limit($limit) + ->get(['id', 'temp_key', 'disk', 'path', 'created_at']); + } + /** * 첨부파일을 수정합니다. * diff --git a/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php b/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php index 750eb722..c9cbd4f8 100644 --- a/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php +++ b/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php @@ -9,7 +9,9 @@ use App\Support\ImageResizer; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Modules\Sirsoft\Page\Exceptions\AttachmentLimitExceededException; use Modules\Sirsoft\Page\Models\PageAttachment; @@ -208,6 +210,114 @@ class PageAttachmentService return $result; } + /** + * 페이지에 연결되지 않은 채 방치된 임시 첨부를 정리합니다. + * + * 페이지 작성 폼에서 첨부만 올리고 저장하지 않고 이탈하면 `temp_key` 만 남은 행과 + * 그 파일이 남습니다. 연결 시점에 본경로로 옮겨지므로 `temp_key` 가 남아 있다는 것은 + * "끝내 연결되지 않았다" 는 뜻이고, 그래서 오탐 여지가 없습니다. + * + * 파일 → 행 순서를 지켜, 행이 먼저 사라져 파일을 못 찾는 상태를 만들지 않습니다. + * + * @param int $days 보존기간(일) + * @param int $limit 한 회차에 처리할 최대 건수 + * @param bool $dryRun true 면 대상만 세고 삭제하지 않음 + * @return array{scanned: int, deleted: int, failed: int} 처리 결과 + */ + public function pruneTempUploads(int $days, int $limit, bool $dryRun = false): array + { + $threshold = Carbon::now()->subDays($days); + $attachments = $this->attachmentRepository->findStaleTempAttachments($threshold, $limit); + + $result = ['scanned' => $attachments->count(), 'deleted' => 0, 'failed' => 0]; + + if ($dryRun) { + return $result; + } + + foreach ($attachments as $attachment) { + $storage = $this->storageForRow($attachment->disk); + + if ($storage->exists('attachments', $attachment->path) && ! $storage->delete('attachments', $attachment->path)) { + Log::warning('임시 페이지 첨부 파일 삭제 실패 — 기록 보존', [ + 'attachment_id' => $attachment->id, + 'disk' => $attachment->disk, + 'path' => $attachment->path, + ]); + + $result['failed']++; + + continue; + } + + $this->attachmentRepository->delete($attachment); + $result['deleted']++; + } + + $this->removeEmptyTempDirectories($attachments); + + return $result; + } + + /** + * 파일을 모두 지운 temp_key 디렉토리를 정리합니다. + * + * 파일만 지우고 디렉토리를 남기면 폼 세션마다 빈 디렉토리가 쌓여, 정리를 돌려도 + * 저장소에는 흔적이 계속 늘어납니다. + * + * 디렉토리에 파일이 남아 있으면(같은 temp_key 의 다른 첨부가 limit 에 걸려 이번 회차에서 + * 빠졌거나 파일 삭제에 실패한 경우 등) 삭제하지 않습니다. + * + * @param Collection $attachments 이번 회차에 처리한 첨부 목록 + */ + private function removeEmptyTempDirectories(Collection $attachments): void + { + $directories = []; + + foreach ($attachments as $attachment) { + $directory = dirname((string) $attachment->path); + + if ($directory === '' || $directory === '.') { + continue; + } + + $directories[$attachment->disk.'|'.$directory] = [$attachment->disk, $directory]; + } + + foreach ($directories as [$disk, $directory]) { + $storage = $this->storageForRow($disk); + + if ($storage->files('attachments', $directory) !== []) { + continue; + } + + $storage->deleteDirectory('attachments', $directory); + } + } + + /** + * 첨부 행에 기록된 disk 기준 스토리지를 반환합니다. + * + * 디스크를 전환한 뒤에도 전환 이전 행의 파일을 그 행의 실제 저장 위치에서 지우기 위한 + * 해석입니다. 미등록 disk(그 디스크를 제공하던 확장이 비활성화된 경우)는 주입 스토리지로 + * 폴백합니다 — withDisk 로 미등록 disk 인스턴스를 만들면 이후 호출이 예외가 됩니다. + * + * @param string|null $disk 행의 disk 컬럼 값 + * @return StorageInterface 행 disk 의 스토리지 + */ + private function storageForRow(?string $disk): StorageInterface + { + if ($disk === null || $disk === '' || $disk === $this->storage->getDisk()) { + return $this->storage; + } + + if (config("filesystems.disks.{$disk}") === null) { + return $this->storage; + } + + return $this->storage->withDisk($disk); + } + /** * 첨부파일 순서를 변경합니다. * diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Console/PruneTempAttachmentsCommandTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Console/PruneTempAttachmentsCommandTest.php new file mode 100644 index 00000000..886b9a11 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Feature/Console/PruneTempAttachmentsCommandTest.php @@ -0,0 +1,191 @@ + $attributes 덮어쓸 속성 + * @return PageAttachment 생성된 첨부 + */ + private function makeAttachment(array $attributes = []): PageAttachment + { + $filename = uniqid('temp-page-').'.png'; + $path = $attributes['path'] ?? "temp/tempkey/{$filename}"; + + (new ModuleStorageDriver('sirsoft-page', 'modules'))->put('attachments', $path, 'bytes'); + + return PageAttachment::create(array_merge([ + 'page_id' => null, + 'temp_key' => 'tempkey', + 'original_filename' => $filename, + 'stored_filename' => $filename, + 'disk' => 'modules', + 'path' => $path, + 'mime_type' => 'image/png', + 'size' => 5, + 'collection' => 'attachments', + 'order' => 1, + ], $attributes)); + } + + /** + * @scenario age=past_retention, attachment_state=temp_unlinked + * + * @effects page_temp_prune_deletes_file_and_record + */ + #[Test] + public function stale_temp_attachments_lose_both_file_and_record(): void + { + $attachment = $this->makeAttachment(); + $attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + $this->artisan('sirsoft-page:prune-temp-attachments')->assertSuccessful(); + + $this->assertFalse( + (new ModuleStorageDriver('sirsoft-page', 'modules'))->exists('attachments', $attachment->path) + ); + $this->assertDatabaseMissing('page_attachments', ['id' => $attachment->id]); + } + + /** + * @scenario age=within_retention, attachment_state=temp_unlinked + * + * @effects page_temp_prune_keeps_within_retention + */ + #[Test] + public function temp_attachment_within_retention_is_kept(): void + { + $attachment = $this->makeAttachment(); + + $this->artisan('sirsoft-page:prune-temp-attachments')->assertSuccessful(); + + $this->assertDatabaseHas('page_attachments', ['id' => $attachment->id]); + } + + /** + * @scenario age=past_retention, attachment_state=linked_to_page + * + * @effects page_temp_prune_skips_linked + */ + #[Test] + public function attachment_linked_to_a_page_is_never_pruned(): void + { + $page = Page::create([ + 'slug' => 'prune-temp-attachment-test-'.uniqid(), + 'title' => ['ko' => '연결 첨부 테스트', 'en' => 'Linked Attachment Test'], + 'content' => ['ko' => '', 'en' => ''], + 'published' => true, + ]); + + $attachment = $this->makeAttachment([ + 'page_id' => $page->id, + 'temp_key' => null, + 'path' => '2026/08/14/'.uniqid('linked-').'.png', + ]); + $attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly(); + + $this->artisan('sirsoft-page:prune-temp-attachments')->assertSuccessful(); + + $this->assertDatabaseHas('page_attachments', ['id' => $attachment->id]); + } + + /** + * @effects page_temp_prune_dry_run + */ + #[Test] + public function dry_run_reports_targets_without_deleting(): void + { + $attachment = $this->makeAttachment(); + $attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + $this->artisan('sirsoft-page:prune-temp-attachments --dry-run') + ->expectsOutputToContain('[DRY RUN]') + ->assertSuccessful(); + + $this->assertDatabaseHas('page_attachments', ['id' => $attachment->id]); + } + + /** + * @effects page_temp_prune_days_guard + */ + #[Test] + public function retention_below_one_day_performs_no_cleanup(): void + { + $attachment = $this->makeAttachment(); + $attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly(); + + $this->artisan('sirsoft-page:prune-temp-attachments --days=0')->assertSuccessful(); + + $this->assertDatabaseHas('page_attachments', ['id' => $attachment->id]); + } + + /** + * 파일을 비운 temp_key 디렉토리는 남기지 않는다 (회귀) + * + * 파일만 지우고 디렉토리를 남기면 페이지 작성 폼 세션마다 빈 디렉토리가 쌓여, 정리를 + * 돌려도 저장소 흔적은 계속 늘어난다. + * + * @scenario age=past_retention, attachment_state=temp_unlinked + * + * @effects page_temp_prune_removes_empty_directory + */ + #[Test] + public function emptied_temp_directory_is_removed(): void + { + $attachment = $this->makeAttachment(['path' => 'temp/tempkey-empty-dir/'.uniqid('t-').'.png']); + $attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + $this->artisan('sirsoft-page:prune-temp-attachments')->assertSuccessful(); + + $storage = new ModuleStorageDriver('sirsoft-page', 'modules'); + + $this->assertSame( + [], + $storage->files('attachments', 'temp/tempkey-empty-dir'), + '디렉토리 안의 파일은 모두 삭제되어야 한다.' + ); + $this->assertDirectoryDoesNotExist( + rtrim($storage->getBasePath('attachments'), '/\\').'/temp/tempkey-empty-dir', + '비워진 temp 디렉토리는 남기지 않는다.' + ); + } + + /** + * 같은 temp_key 에 처리되지 않은 파일이 남아 있으면 디렉토리를 지우지 않는다. + * + * @scenario age=past_retention, attachment_state=temp_unlinked + * + * @effects page_temp_prune_keeps_directory_with_remaining_files + */ + #[Test] + public function temp_directory_with_remaining_files_is_kept(): void + { + $directory = 'temp/tempkey-partial'; + + $processed = $this->makeAttachment(['path' => $directory.'/'.uniqid('done-').'.png']); + $processed->forceFill(['created_at' => now()->subDays(5)])->saveQuietly(); + + // 같은 디렉토리에 이번 회차 대상이 아닌 파일이 남아 있는 상태 + $storage = new ModuleStorageDriver('sirsoft-page', 'modules'); + $storage->put('attachments', $directory.'/keep.png', 'bytes'); + + $this->artisan('sirsoft-page:prune-temp-attachments')->assertSuccessful(); + + $this->assertTrue($storage->exists('attachments', $directory.'/keep.png')); + } +} diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php new file mode 100644 index 00000000..d5dbe441 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php @@ -0,0 +1,61 @@ +assertArrayHasKey('sirsoft-ckeditor5.image.filter_reference_sources', $hooks); + $this->assertSame('filter', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['type']); + $this->assertSame('addPageSources', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['method']); + } + + /** + * 버전 스냅샷도 포함해야 과거 버전으로 되돌릴 때 그 시점 이미지가 남아 있다. + * + * @effects reference_source_listener_appends_without_dropping + */ + #[Test] + public function it_appends_page_and_version_sources_while_preserving_existing_ones(): void + { + $existing = [['table' => 'mail_templates', 'columns' => ['body']]]; + + $result = (new Ckeditor5ReferenceSourcesListener)->addPageSources($existing); + + $this->assertContains($existing[0], $result); + $this->assertContains(['table' => 'pages', 'columns' => ['content']], $result); + $this->assertContains(['table' => 'page_versions', 'columns' => ['content']], $result); + } + + /** + * @effects reference_source_listener_schema_contract + */ + #[Test] + public function appended_sources_follow_the_declared_schema(): void + { + foreach ((new Ckeditor5ReferenceSourcesListener)->addPageSources([]) as $source) { + $this->assertArrayHasKey('table', $source); + $this->assertArrayHasKey('columns', $source); + $this->assertIsString($source['table']); + $this->assertIsArray($source['columns']); + $this->assertNotEmpty($source['columns']); + } + } +} diff --git a/modules/_bundled/sirsoft-page/tests/scenarios/temp-attachment-cleanup.yaml b/modules/_bundled/sirsoft-page/tests/scenarios/temp-attachment-cleanup.yaml new file mode 100644 index 00000000..8c56582d --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/scenarios/temp-attachment-cleanup.yaml @@ -0,0 +1,50 @@ +feature: 미연결 임시 페이지 첨부 회수 (Temp Page Attachment Cleanup) + +description: | + 페이지 작성 폼에서 첨부만 올리고 저장 없이 이탈하면 `temp_key` 가 남고 `page_id` 가 비어 + 있는 행과 그 파일이 영구 잔존한다. 공개 이슈 #115 동류 결함. + + 연결 시점에 본경로로 옮겨지므로 `temp_key` 잔존 = 끝내 연결되지 않음이 확정이라 오탐 + 여지가 구조적으로 없다. 운영 데이터가 아닌 폼 세션 부산물이므로 토글 없이 상시 실행되며, + 보존기간만 커맨드 옵션으로 조정한다. + + 삭제 순서는 파일 먼저, 기록 나중. 행마다 disk 가 다를 수 있어 그 행의 disk 를 향해 지운다. + +axes: + attachment_state: [temp_unlinked, linked_to_page] + age: [within_retention, past_retention] + +exclusions: + - { attachment_state: linked_to_page, age: within_retention, reason: "연결된 첨부는 나이와 무관하게 대상이 아니다 — 경과분 1건으로 충분" } + +sub_flows: + - id: dry_run + description: 판정만 수행하고 파일·기록을 건드리지 않는 미리보기 경로 + effects: + - page_temp_prune_dry_run + - id: retention_guard + description: 보존기간 1일 미만 자기 차단 (방금 올린 임시 첨부까지 지우는 사고 방지) + effects: + - page_temp_prune_days_guard + - id: temp_directory_hygiene + description: | + 파일을 지운 뒤 남는 temp 디렉토리 처리. 비워졌을 때만 지우고, 다른 폼 세션의 파일이 + 남아 있으면 그대로 둔다 (디렉토리째 지우면 아직 작성 중인 페이지의 첨부가 사라진다). + effects: + - page_temp_prune_removes_empty_directory + - page_temp_prune_keeps_directory_with_remaining_files + - id: reference_source_registry + description: 페이지 본문·버전 스냅샷을 에디터 이미지 참조 소스로 등록하는 리스너 + effects: + - reference_source_listener_subscribes_as_filter + - reference_source_listener_appends_without_dropping + - reference_source_listener_schema_contract + +effects: + - page_temp_prune_deletes_file_and_record + - page_temp_prune_keeps_within_retention + - page_temp_prune_skips_linked + +test_files: + - modules/_bundled/sirsoft-page/tests/Feature/Console/PruneTempAttachmentsCommandTest.php + - modules/_bundled/sirsoft-page/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php diff --git a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md index 9a7bdb1c..f5b6748a 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md @@ -11,14 +11,21 @@ - 에디터 이미지 업로드에 파일 가공 필터 훅 제공 — 확장에서 업로드 이미지를 저장 전에 변환할 수 있습니다. - 에디터에 올린 이미지를 코어의 공개 자산 스토리지(S3+CDN 등) 디스크에 저장하고 본문에는 CDN 직접 주소로 서빙할 수 있습니다. 에디터 설정의 「공개 자산 디스크」에서 이 플러그인만 다른 디스크를 쓰도록 개별 설정할 수도 있습니다(비우면 코어 설정 따름). 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시됩니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.) -### Changed +#### 미사용 이미지 정리 -- 코어 최소 요구 버전을 7.0.7 로 상향했습니다. +- 에디터에 올렸지만 어떤 콘텐츠에서도 더 이상 사용되지 않는 이미지를 자동으로 정리할 수 있습니다. 플러그인 설정에서 자동 정리를 켜면(기본 꺼짐) 설정한 보존기간(기본 30일)이 지난 미사용 이미지를 파일과 기록까지 매일 삭제합니다. (#115 @Tuwasduliebst 님께서 건의해주셨습니다.) +- 「에디터 업로드 이미지」 관리 화면이 추가되었습니다. 업로드한 이미지를 미리보기·업로더·용량과 함께 확인하고, 사용 중인지 여부를 배지로 구분해 필요한 것만 골라 삭제할 수 있습니다(개별·선택 삭제). +- 사용 여부는 게시글·페이지·상품 설명 등 등록된 콘텐츠를 훑어 판정합니다. 자체 콘텐츠를 가진 확장은 판정 대상에 자기 콘텐츠를 등록할 수 있어, 그 확장에서만 쓰이는 이미지도 「사용 중」으로 올바르게 인식됩니다. +- 무엇이 지워질지 먼저 확인하려면 서버에서 `php artisan sirsoft-ckeditor5:prune-unused-images --dry-run` 을 실행하면 됩니다. ### Changed - 코어 7.0.7의 외부 스크립트 보안 정책 강화에 맞춰, 에디터가 사용하는 CKEditor CDN 주소(`cdn.ckeditor.com`)를 이 플러그인의 신뢰 출처로 선언했습니다. 정책이 강화된 이후에도 위지윅 에디터가 정상적으로 로드됩니다. -- 신뢰 출처 선언 기능이 코어 7.0.7에서 도입되었으므로, 설치에 필요한 최소 코어 버전을 7.0.7로 조정했습니다. +- 코어 최소 요구 버전을 7.0.7 로 상향했습니다. + +### Fixed + +- 플러그인 설정 화면의 「에디터 이미지 업로드」 스위치가 레이아웃 편집기에서 편집 대상으로 선택되지 않던 문제를 수정했습니다. ## [1.0.1] - 2026-08-10 diff --git a/plugins/_bundled/sirsoft-ckeditor5/config/settings/defaults.json b/plugins/_bundled/sirsoft-ckeditor5/config/settings/defaults.json index ab8ef4c2..b475374c 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/config/settings/defaults.json +++ b/plugins/_bundled/sirsoft-ckeditor5/config/settings/defaults.json @@ -8,13 +8,17 @@ "imageMaxSizeMb": 2, "editorHeight": 400, "toolbar": "standard", - "public_asset_disk": "" + "public_asset_disk": "", + "unusedImageCleanup": false, + "unusedImageRetentionDays": 30 }, "frontend_schema": { "imageUpload": { "expose": true }, "imageMaxSizeMb": { "expose": true }, "editorHeight": { "expose": true }, "toolbar": { "expose": true }, - "public_asset_disk": { "expose": false } + "public_asset_disk": { "expose": false }, + "unusedImageCleanup": { "expose": false }, + "unusedImageRetentionDays": { "expose": false } } -} \ No newline at end of file +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/database/migrations/2026_08_14_000001_add_created_at_index_to_ckeditor5_image_uploads.php b/plugins/_bundled/sirsoft-ckeditor5/database/migrations/2026_08_14_000001_add_created_at_index_to_ckeditor5_image_uploads.php new file mode 100644 index 00000000..ccc8d7f9 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/database/migrations/2026_08_14_000001_add_created_at_index_to_ckeditor5_image_uploads.php @@ -0,0 +1,39 @@ +index('created_at', 'ckeditor5_image_uploads_created_at_index'); + }); + } + + /** + * 마이그레이션 롤백 + */ + public function down(): void + { + if (! Schema::hasTable('ckeditor5_image_uploads')) { + return; + } + + Schema::table('ckeditor5_image_uploads', function (Blueprint $table) { + $table->dropIndex('ckeditor5_image_uploads_created_at_index'); + }); + } +}; diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md b/plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md index 19f7e45f..f48760e8 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md @@ -4,12 +4,13 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 2 · **엔드포인트 수**: 2 +- **문서 수**: 3 · **엔드포인트 수**: 5 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | | [images.md](images.md) | `images` | 1 | | [upload.md](upload.md) | `upload` | 1 | +| [uploads.md](uploads.md) | `uploads` | 3 | diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/api/uploads.md b/plugins/_bundled/sirsoft-ckeditor5/docs/api/uploads.md new file mode 100644 index 00000000..c14c7ba6 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/api/uploads.md @@ -0,0 +1,253 @@ +# Uploads API 레퍼런스 + +> **소유**: plugin `sirsoft-ckeditor5` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 에디터 업로드 이미지 관리(조회·삭제) 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(raw HTTP) + 응답 필드 표 + 응답 예시(envelope) +3. 조회(uploads.read)와 삭제(uploads.delete)는 별도 권한입니다 — 삭제는 파일을 실제로 파기합니다 +4. 참조 상태(referenced)는 저장 컬럼이 아니라 조회 시점에 판정한 값입니다 +5. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +``` + +--- + +### GET /api/plugins/sirsoft-ckeditor5/admin/uploads + + + +- **라우트명**: `api.plugins.sirsoft-ckeditor5.admin.uploads.index` +- **컨트롤러**: `Plugins\Sirsoft\Ckeditor5\Http\Controllers\Admin\ImageUploadAdminController@index` +- **인증/권한**: `auth:sanctum`, `permission:admin,sirsoft-ckeditor5.uploads.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | query | integer | 아니오 | min 1 | 페이지 번호 (기본 1) | +| per_page | query | integer | 아니오 | 1 ~ 100 | 페이지 크기 (기본 20). 참조 판정을 페이지 단위로 수행하므로 이 값이 곧 판정 비용의 상한이다. | +| search | query | string | 아니오 | max 255 | 원본 파일명 또는 해시 부분 일치 | +| referenced | query | string | 아니오 | `all`, `referenced`, `unreferenced` | 참조 상태 필터 (기본 `all`) | +| date_from | query | date | 아니오 | — | 업로드 시작일 (그날 00:00:00 포함) | +| date_to | query | date | 아니오 | — | 업로드 종료일 (그날 23:59:59 포함) | +| sort_by | query | string | 아니오 | `created_at`, `file_size`, `original_name` | 정렬 컬럼 (기본 `created_at`). 목록 밖 컬럼은 422 | +| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (기본 `desc`) | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-ckeditor5/admin/uploads?referenced=unreferenced&per_page=20 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| data[].id | integer | `12` | 업로드 기록 ID (삭제 요청의 대상 식별자) | +| data[].hash | string | `a1b2c3d4e5f6` | URL용 고유 해시 12자리 hex. 본문에 API 폴백형 URL 로 삽입될 때 이 값이 들어간다 | +| data[].original_name | string | `banner.png` | 업로드 당시 원본 파일명 | +| data[].file_size | integer | `78310` | 파일 크기 (bytes) | +| data[].mime_type | string | `image/png` | MIME 타입 | +| data[].uploaded_by | integer\|null | `1` | 업로드한 사용자 ID (탈퇴·미상이면 null) | +| data[].uploader_name | string\|null | `관리자` | 업로더 표시명. 관계가 로드된 경우에만 채워진다 | +| data[].created_at | string | `2026-08-14 19:20:11` | 업로드 일시 (요청자 시간대 기준 포맷) | +| data[].download_url | string | `/api/plugins/sirsoft-ckeditor5/images/a1b2c3d4e5f6` | 이미지 서빙 URL. 공개 자산 디스크가 직접 URL 을 지원하면 CDN 주소가 반환된다 | +| data[].referenced | boolean | `false` | **조회 시점 판정값**(저장 컬럼 아님). 등록된 참조 소스 본문에 hash 또는 저장 파일명이 등장하면 true. 판정 불가 행은 안전 방향인 true 로 본다 | +| pagination.current_page | integer | `1` | 현재 페이지 | +| pagination.last_page | integer | `1` | 마지막 페이지 | +| pagination.per_page | integer | `20` | 페이지 크기 | +| pagination.total | integer | `3` | 총 건수 | +| pagination.from | integer\|null | `1` | 현재 페이지 첫 항목 순번 (비어 있으면 null) | +| pagination.to | integer\|null | `3` | 현재 페이지 마지막 항목 순번 (비어 있으면 null) | +| meta.scan_limited | boolean | `false` | 참조 상태 필터가 스캔 윈도우 상한에 걸렸는지. true 면 그보다 오래된 이미지는 목록에 포함되지 않는다 | +| meta.scan_window | integer | `500` | 참조 상태 필터가 훑는 최신 업로드 건수 상한 | + +**응답 예시** + +```json +{ + "success": true, + "message": "요청이 성공적으로 처리되었습니다.", + "data": { + "data": [ + { + "id": 12, + "hash": "a1b2c3d4e5f6", + "original_name": "banner.png", + "file_size": 78310, + "mime_type": "image/png", + "uploaded_by": 1, + "uploader_name": "관리자", + "created_at": "2026-08-14 19:20:11", + "download_url": "/api/plugins/sirsoft-ckeditor5/images/a1b2c3d4e5f6", + "referenced": false + } + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 20, + "total": 1, + "from": 1, + "to": 1 + }, + "meta": { + "scan_limited": false, + "scan_window": 500 + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | `sirsoft-ckeditor5.uploads.read` 권한이 없는 경우 | +| 422 | Unprocessable Entity | 허용 목록 밖 `sort_by`/`sort_order`/`referenced`, `per_page` 범위 초과, 날짜 형식 오류 | + + + +**설명** + +에디터로 업로드된 이미지 목록을 관리 화면에 제공하는 엔드포인트다. + +- **참조 상태는 저장하지 않는다.** 참조 관계는 콘텐츠가 편집될 때마다 바뀌므로 스냅샷을 저장하면 "저장 당시엔 미참조였던" 이미지를 이후에 오판 삭제할 수 있다. 대신 판정 비용에 상한을 둔다 — 기본 목록은 현재 페이지(≤100행)만 판정하고, 참조 상태 필터를 쓸 때만 최신 `meta.scan_window` 건을 훑는다. +- **`meta.scan_limited` 가 true 면 목록이 전수가 아니다.** 화면은 이 값을 근거로 "최근 N건 기준" 안내를 표시해야 한다. 이 값을 무시하면 운영자가 "미참조 이미지가 이게 전부" 로 오해한다. +- 판정 토큰은 `hash` 와 저장 파일명 둘 다이다. 본문에 박히는 URL 이 API 폴백형(hash 포함)과 디스크 직접 URL(저장 파일명 포함) 두 형태이기 때문이며, 한쪽만 검사하면 다른 형태로 저장된 이미지를 미참조로 오판한다. + +--- + +### DELETE /api/plugins/sirsoft-ckeditor5/admin/uploads/{id} + + + +- **라우트명**: `api.plugins.sirsoft-ckeditor5.admin.uploads.destroy` +- **컨트롤러**: `Plugins\Sirsoft\Ckeditor5\Http\Controllers\Admin\ImageUploadAdminController@destroy` +- **인증/권한**: `auth:sanctum`, `permission:admin,sirsoft-ckeditor5.uploads.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | integer | 예 | 숫자만 | 삭제할 업로드 기록 ID | + +**요청 예시** + +```http +DELETE /api/plugins/sirsoft-ckeditor5/admin/uploads/12 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +성공 시 `data` 는 `null` 이며, 결과는 `message` 로만 전달된다. + +**응답 예시** + +```json +{ + "success": true, + "message": "이미지를 삭제했습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | `sirsoft-ckeditor5.uploads.delete` 권한이 없는 경우 (조회 권한만으로는 삭제 불가) | +| 404 | Not Found | 해당 ID 의 업로드 기록이 없는 경우 | +| 500 | Internal Server Error | 물리 파일이 존재하는데 삭제에 실패한 경우 — 기록은 보존되며 다음 시도에서 재시도할 수 있다 | + + + +**설명** + +업로드 이미지 1건을 파일과 기록까지 삭제한다. + +- **삭제 순서는 파일 먼저, 기록 나중이다.** 반대로 하면 기록이 사라진 파일은 어떤 경로로도 다시 찾을 수 없어 영구 고아가 된다. +- 파일이 이미 없는 행은 고아 기록으로 보고 기록만 지운다. 그러지 않으면 매 회차 같은 행을 재시도하는 루프가 된다. +- 행마다 `storage_disk` 가 다를 수 있으므로 삭제는 그 행에 기록된 디스크를 향해 수행된다(디스크 전환 이후 혼재 대응). +- 이 경로는 정리 커맨드(`sirsoft-ckeditor5:prune-unused-images`)와 **같은 삭제 구현을 공유**한다 — 화면 삭제와 자동 정리의 동작이 갈리지 않게 하기 위함이다. + +--- + +### POST /api/plugins/sirsoft-ckeditor5/admin/uploads/bulk-delete + + + +- **라우트명**: `api.plugins.sirsoft-ckeditor5.admin.uploads.bulk-delete` +- **컨트롤러**: `Plugins\Sirsoft\Ckeditor5\Http\Controllers\Admin\ImageUploadAdminController@bulkDestroy` +- **인증/권한**: `auth:sanctum`, `permission:admin,sirsoft-ckeditor5.uploads.delete` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| ids | body | array | 예 | 1 ~ 100개 | 삭제할 업로드 기록 ID 목록 | +| ids.* | body | integer | 예 | 존재하는 업로드 ID | 각 항목은 실재하는 기록이어야 한다 (아니면 422) | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-ckeditor5/admin/uploads/bulk-delete HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "ids": [12, 13, 14] +} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | integer | `3` | 파일·기록이 함께 삭제된 건수 | +| failed | integer | `0` | 물리 파일 삭제 실패로 기록을 보존한 건수 (다음 시도에서 재시도 가능) | + +**응답 예시** + +```json +{ + "success": true, + "message": "선택한 이미지 3건을 삭제했습니다.", + "data": { + "deleted": 3, + "failed": 0 + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | `sirsoft-ckeditor5.uploads.delete` 권한이 없는 경우 | +| 422 | Unprocessable Entity | `ids` 누락/빈 배열/100개 초과, 또는 존재하지 않는 ID 포함 | +| 500 | Internal Server Error | 요청한 전부가 파일 삭제에 실패한 경우 (일부만 실패하면 200 + `failed` 로 보고) | + + + +**설명** + +관리 화면에서 체크한 이미지를 한 번에 삭제한다. + +- 선택 범위는 **화면에 보이는 페이지**로 한정된다(화면 계약). 검색·필터·페이지 이동으로 목록에서 빠진 행의 선택이 남아 대상에 실리면, 운영자가 보지도 체크하지도 않은 파일이 지워지기 때문이다. +- 일부만 실패한 경우 요청 자체는 성공(200)으로 응답하고 `failed` 로 보고한다. 전부 실패한 경우에만 500 이다 — 부분 성공을 실패로 되돌릴 방법이 없기 때문이다. diff --git a/plugins/_bundled/sirsoft-ckeditor5/lang/en/messages.php b/plugins/_bundled/sirsoft-ckeditor5/lang/en/messages.php index 9dbaa1d4..384aca27 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-ckeditor5/lang/en/messages.php @@ -5,12 +5,23 @@ return [ 'not_found' => 'Image not found.', ], 'upload' => [ - 'required' => 'An image file is required.', + 'required' => 'An image file is required.', 'invalid_file' => 'Invalid file.', - 'not_image' => 'Only image files can be uploaded.', + 'not_image' => 'Only image files can be uploaded.', 'invalid_mime' => 'Only jpeg, jpg, png, gif, webp formats are supported.', - 'too_large' => 'The file size exceeds the allowed limit (:max MB).', - 'forbidden' => 'You do not have permission to upload images.', - 'failed' => 'Image upload failed.', + 'too_large' => 'The file size exceeds the allowed limit (:max MB).', + 'forbidden' => 'You do not have permission to upload images.', + 'failed' => 'Image upload failed.', + ], + 'uploads' => [ + 'not_found' => 'Upload not found.', + 'file_delete_failed' => 'Failed to delete the image file. Please try again shortly.', + 'ids_required' => 'Please select the images to delete.', + 'ids_invalid' => 'Some of the selected images no longer exist. Refresh the list and select again.', + 'deleted' => 'The image has been deleted.', + 'bulk_deleted' => 'Deleted :deleted selected image(s).', + ], + 'cleanup' => [ + 'retention_disabled' => 'The retention period is less than 1 day, so no cleanup was performed.', ], ]; diff --git a/plugins/_bundled/sirsoft-ckeditor5/lang/ko/messages.php b/plugins/_bundled/sirsoft-ckeditor5/lang/ko/messages.php index 750d897e..7ded9c70 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-ckeditor5/lang/ko/messages.php @@ -5,12 +5,23 @@ return [ 'not_found' => '이미지를 찾을 수 없습니다.', ], 'upload' => [ - 'required' => '업로드할 이미지 파일이 필요합니다.', + 'required' => '업로드할 이미지 파일이 필요합니다.', 'invalid_file' => '유효하지 않은 파일입니다.', - 'not_image' => '이미지 파일만 업로드할 수 있습니다.', + 'not_image' => '이미지 파일만 업로드할 수 있습니다.', 'invalid_mime' => 'jpeg, jpg, png, gif, webp 형식만 지원합니다.', - 'too_large' => '파일 크기가 허용 용량(:max MB)을 초과합니다.', - 'forbidden' => '이미지 업로드 권한이 없습니다.', - 'failed' => '이미지 업로드에 실패했습니다.', + 'too_large' => '파일 크기가 허용 용량(:max MB)을 초과합니다.', + 'forbidden' => '이미지 업로드 권한이 없습니다.', + 'failed' => '이미지 업로드에 실패했습니다.', + ], + 'uploads' => [ + 'not_found' => '업로드 이미지를 찾을 수 없습니다.', + 'file_delete_failed' => '이미지 파일 삭제에 실패했습니다. 잠시 후 다시 시도해 주세요.', + 'ids_required' => '삭제할 이미지를 선택해 주세요.', + 'ids_invalid' => '선택한 이미지 중 더 이상 존재하지 않는 항목이 있습니다. 목록을 새로고침한 뒤 다시 선택해 주세요.', + 'deleted' => '이미지를 삭제했습니다.', + 'bulk_deleted' => '선택한 이미지 :deleted건을 삭제했습니다.', + ], + 'cleanup' => [ + 'retention_disabled' => '보존기간이 1일 미만으로 설정되어 정리를 수행하지 않았습니다.', ], ]; diff --git a/plugins/_bundled/sirsoft-ckeditor5/package.json b/plugins/_bundled/sirsoft-ckeditor5/package.json index 31c144d5..1d32594a 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/package.json +++ b/plugins/_bundled/sirsoft-ckeditor5/package.json @@ -9,7 +9,9 @@ "build": "vite build", "preview": "vite preview", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run", + "test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts", + "test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui" }, "devDependencies": { "jsdom": "^27.4.0", diff --git a/plugins/_bundled/sirsoft-ckeditor5/plugin.php b/plugins/_bundled/sirsoft-ckeditor5/plugin.php index d27dd7c3..c48fdc79 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/plugin.php +++ b/plugins/_bundled/sirsoft-ckeditor5/plugin.php @@ -2,7 +2,9 @@ namespace Plugins\Sirsoft\Ckeditor5; +use App\Enums\ExtensionOwnerType; use App\Extension\AbstractPlugin; +use App\Extension\Helpers\ExtensionMenuSyncHelper; /** * CKEditor 5 WYSIWYG 에디터 플러그인 @@ -89,6 +91,235 @@ class Plugin extends AbstractPlugin ], 'required' => false, ], + 'unusedImageCleanup' => [ + 'type' => 'boolean', + 'default' => false, + 'label' => [ + 'ko' => '미사용 이미지 자동 정리', + 'en' => 'Auto Cleanup Unused Images', + ], + 'hint' => [ + 'ko' => '기본 꺼짐 — 운영자가 직접 켜야 동작합니다. 켜면 보존기간이 지난 미사용 이미지를 매일 삭제합니다.', + 'en' => 'Off by default — the operator must turn it on. When on, unused images past the retention period are deleted daily.', + ], + 'required' => false, + ], + 'unusedImageRetentionDays' => [ + 'type' => 'integer', + 'min' => 1, + 'max' => 3650, + 'default' => 30, + 'label' => [ + 'ko' => '미사용 이미지 보존기간 (일)', + 'en' => 'Unused Image Retention (days)', + ], + 'hint' => [ + 'ko' => '업로드 후 이 기간이 지난 미사용 이미지만 정리 대상이 됩니다. (1 ~ 3650 일)', + 'en' => 'Only unused images older than this period are eligible for cleanup. (1 ~ 3650 days)', + ], + 'required' => false, + ], + ]; + } + + /** + * 플러그인이 제공하는 훅 정보 반환 + * + * @return array 훅 정의 배열 (action 2 + filter 2) + */ + public function getHooks(): array + { + return [ + [ + 'name' => 'sirsoft-ckeditor5.image.before_upload', + 'type' => 'action', + 'description' => [ + 'ko' => '에디터 이미지 업로드 직전 발화 (본인인증·쿼터 등 확장 지점)', + 'en' => 'Fired right before an editor image upload (identity verification, quota, etc.)', + ], + 'parameters' => [ + 'file' => 'UploadedFile - 업로드된 파일', + 'uploadedBy' => 'int|null - 업로드 사용자 ID', + ], + ], + [ + 'name' => 'sirsoft-ckeditor5.image.after_upload', + 'type' => 'action', + 'description' => [ + 'ko' => '에디터 이미지 업로드 기록 생성 후 발화', + 'en' => 'Fired after the editor image upload record is created', + ], + 'parameters' => [ + 'record' => 'Model - Ckeditor5ImageUpload', + ], + ], + [ + 'name' => 'sirsoft-ckeditor5.image.filter_upload_file', + 'type' => 'filter', + 'description' => [ + 'ko' => '업로드 파일 변형 지점 (압축·리사이즈 등)', + 'en' => 'Transform the uploaded file (compression, resizing, etc.)', + ], + 'parameters' => [ + 'file' => 'UploadedFile - 업로드된 파일', + ], + ], + [ + 'name' => 'sirsoft-ckeditor5.image.filter_reference_sources', + 'type' => 'filter', + 'description' => [ + 'ko' => '에디터 이미지 참조 스캔 대상 테이블/컬럼 목록에 확장 콘텐츠를 추가', + 'en' => 'Append extension content tables/columns to the editor image reference scan sources', + ], + 'parameters' => [ + 'sources' => 'array - list}>', + ], + ], + ]; + } + + /** + * 플러그인 권한 목록 반환 (계층 구조) + * + * 업로드 이미지 관리 화면(조회/삭제)의 접근을 분리한다 — 조회는 감사·현황 파악, + * 삭제는 파일을 실제로 파기하므로 별도 권한으로 둔다. + * + * @return array 권한 정의 배열 + */ + public function getPermissions(): array + { + return [ + 'name' => [ + 'ko' => 'CKEditor 5 WYSIWYG 에디터', + 'en' => 'CKEditor 5 WYSIWYG Editor', + ], + 'description' => [ + 'ko' => 'CKEditor5 플러그인이 제공하는 권한', + 'en' => 'Permissions provided by the CKEditor5 plugin', + ], + 'categories' => [ + [ + 'identifier' => 'uploads', + 'name' => ['ko' => '에디터 업로드 이미지', 'en' => 'Editor Uploads'], + 'description' => [ + 'ko' => '에디터로 업로드된 이미지의 조회·삭제 권한', + 'en' => 'View and delete permissions for images uploaded via the editor', + ], + 'permissions' => [ + [ + 'action' => 'read', + 'name' => ['ko' => '업로드 이미지 조회', 'en' => 'View Uploads'], + 'description' => [ + 'ko' => '에디터 업로드 이미지 목록·참조 상태 조회', + 'en' => 'View the editor upload list and reference status', + ], + 'type' => 'admin', + 'roles' => ['admin'], + ], + [ + 'action' => 'delete', + 'name' => ['ko' => '업로드 이미지 삭제', 'en' => 'Delete Uploads'], + 'description' => [ + 'ko' => '에디터 업로드 이미지의 파일·기록 삭제', + 'en' => 'Delete editor upload files and records', + ], + 'type' => 'admin', + 'roles' => ['admin'], + ], + ], + ], + ], + ]; + } + + /** + * 관리자 메뉴 정의 + * + * 코어 PluginManager 가 설치·업데이트 공통 경로에서 이 선언을 동기화한다. + * activate() 의 직접 동기화는 그 경로를 타지 않는 활성화 단독 호출을 위한 것이며, + * 동기화는 upsert 라 두 경로가 겹쳐도 메뉴가 중복 생성되지 않는다. + * + * @return array> + */ + public function getAdminMenus(): array + { + return [ + [ + 'name' => ['ko' => '에디터 업로드 이미지', 'en' => 'Editor Uploads'], + 'slug' => 'sirsoft-ckeditor5-uploads', + 'url' => '/admin/plugins/sirsoft-ckeditor5/uploads', + 'icon' => 'fas fa-images', + 'order' => 50, + ], + ]; + } + + /** + * 플러그인 활성화 — 관리자 메뉴 자동 등록. + * + * @return bool 활성화 성공 여부 + */ + public function activate(): bool + { + $helper = app(ExtensionMenuSyncHelper::class); + + foreach ($this->getAdminMenus() as $menuData) { + $helper->syncMenuRecursive( + $menuData, + ExtensionOwnerType::Plugin, + $this->getIdentifier(), + ); + } + + return true; + } + + /** + * 플러그인 비활성화 — 관리자 메뉴 일괄 제거. + * + * @return bool 비활성화 성공 여부 + */ + public function deactivate(): bool + { + app(ExtensionMenuSyncHelper::class)->cleanupStaleMenus( + ExtensionOwnerType::Plugin, + $this->getIdentifier(), + currentSlugs: [], + ); + + return true; + } + + /** + * 플러그인 제거 — 메뉴 잔존 안전망 (정상 흐름은 deactivate 가 먼저 처리). + * + * @return bool 제거 성공 여부 + */ + public function uninstall(): bool + { + $this->deactivate(); + + return true; + } + + /** + * 플러그인 스케줄 목록 반환 + * + * 미참조 이미지 정리는 사용자 파일을 지우므로 기본 꺼짐(옵트인)이다. + * enabled_config 게이트는 설정 조회 실패 시 true 로 폴백하므로, 커맨드가 + * `--scheduled` 에서 같은 설정을 false 폴백으로 재확인해 자동 삭제를 차단한다. + * + * @return array> 스케줄 정의 목록 + */ + public function getSchedules(): array + { + return [ + [ + 'command' => 'sirsoft-ckeditor5:prune-unused-images --scheduled', + 'schedule' => 'daily', + 'description' => '미참조 에디터 업로드 이미지 정리', + 'enabled_config' => 'sirsoft-ckeditor5.unusedImageCleanup', + ], ]; } diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/ckeditor5Uploads.test.tsx b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/ckeditor5Uploads.test.tsx new file mode 100644 index 00000000..393bee93 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/ckeditor5Uploads.test.tsx @@ -0,0 +1,358 @@ +/** + * CKEditor5 업로드 관리 화면 — 계약 + 렌더링 테스트 (공개 #115) + * + * @description + * 이 화면은 사용자 파일을 실제로 파기하므로, 화면이 지키기로 한 계약을 코드에 고정한다. + * + * - 일괄 삭제가 달린 목록이므로 선택 범위는 반드시 `page` (화면 밖 선택이 대상에 실리면 + * 운영자가 보지도 체크하지도 않은 파일이 지워진다) + * - 목록 클러스터 안의 이동은 목록 상태를 승계(mergeQuery)하고, 의도적 리셋만 예외 + * - 정렬 선택지는 서버 게이트(IndexUploadsRequest)의 부분집합이어야 한다 + * + * @effects admin_ui_selection_scope_page, admin_ui_merge_query, admin_ui_sort_subset, + * admin_ui_reference_badge + */ + +import React from 'react'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils'; +import { ComponentRegistry } from '@core/template-engine/ComponentRegistry'; + +import uploadsLayout from '../../../layouts/admin/ckeditor5_uploads.json'; +import routesJson from '../../../routes.json'; + +// ─── 헬퍼 ─── + +/** + * 플러그인 루트(plugin.json 기준)를 위로 훑어 찾는다. + * + * @returns 플러그인 루트 절대경로 + */ +function pluginRoot(): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 10; depth++) { + if (fs.existsSync(path.join(current, 'plugin.json'))) { + return current; + } + current = path.dirname(current); + } + + throw new Error('plugin.json 을 가진 플러그인 루트를 찾지 못했습니다.'); +} + +/** + * 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다 (slots/modals 포함). + * + * @param node 탐색 시작 노드 + * @param predicate 노드 판정 함수 + * @returns 찾은 노드 또는 null + */ +function findNode(node: any, predicate: (n: any) => boolean): any { + if (!node || typeof node !== 'object') return null; + if (predicate(node)) return node; + + const groups: any[] = [ + node.children ?? [], + node.modals ?? [], + ...(node.slots ? Object.values(node.slots) : []), + ]; + + for (const group of groups) { + for (const child of group as any[]) { + const found = findNode(child, predicate); + if (found) return found; + } + } + + return null; +} + +/** + * 레이아웃 트리의 모든 navigate 액션을 수집한다. + * + * @param node 탐색 시작 노드 + * @param collected 누적 배열 + * @returns navigate 액션 목록 + */ +function collectNavigateActions(node: any, collected: any[] = []): any[] { + if (!node || typeof node !== 'object') return collected; + + if (Array.isArray(node)) { + node.forEach((child) => collectNavigateActions(child, collected)); + return collected; + } + + if (node.handler === 'navigate') { + collected.push(node); + } + + Object.values(node).forEach((value) => { + if (value && typeof value === 'object') { + collectNavigateActions(value, collected); + } + }); + + return collected; +} + +const dataGrid = findNode(uploadsLayout, (n) => n.id === 'ckeditor5_uploads_datagrid'); + +describe('업로드 관리 화면 — 목록 계약', () => { + it('일괄 삭제 목록이므로 선택 범위가 page 로 고정돼 있다', () => { + expect(dataGrid).not.toBeNull(); + expect(dataGrid.props.selectionScope).toBe('page'); + }); + + it('선택 상태 변경이 전용 전역 키에 저장되고 일괄 삭제가 같은 키를 읽는다', () => { + const selectionAction = (dataGrid.actions ?? []).find( + (a: any) => a.event === 'onSelectionChange', + ); + + expect(selectionAction?.params?.selectedIds_ckeditor5_uploads).toBe('{{$args[0]}}'); + + const bulkModal = findNode(uploadsLayout, (n) => n.id === 'bulk_delete_confirm_modal'); + expect(JSON.stringify(bulkModal)).toContain('_global.selectedIds_ckeditor5_uploads'); + }); + + it('삭제 API 가 플러그인 네임스페이스 경로를 호출한다', () => { + const payload = JSON.stringify(uploadsLayout); + + expect(payload).toContain('/api/plugins/sirsoft-ckeditor5/admin/uploads/{{_global.modal_data.id}}'); + expect(payload).toContain('/api/plugins/sirsoft-ckeditor5/admin/uploads/bulk-delete'); + }); + + it('참조 상태 배지가 참조/미참조 두 갈래로 렌더된다', () => { + const column = dataGrid.props.columns.find((c: any) => c.field === 'referenced'); + + expect(column).toBeDefined(); + const conditions = column.cellChildren.map((c: any) => c.if); + expect(conditions).toContain('{{row.referenced === true}}'); + expect(conditions).toContain('{{row.referenced !== true}}'); + }); +}); + +describe('업로드 관리 화면 — 목록 컨텍스트 왕복', () => { + it('클러스터 안 이동은 mergeQuery 로 목록 상태를 승계한다', () => { + const navigates = collectNavigateActions(uploadsLayout).filter( + (a) => (a.params?.path ?? '').includes('/admin/plugins/sirsoft-ckeditor5/uploads'), + ); + + expect(navigates.length).toBeGreaterThan(0); + + navigates.forEach((action) => { + const isIntentionalReset = JSON.stringify(action).includes('audit:allow') + || Object.keys(action.params?.query ?? {}).length === 0; + + if (!isIntentionalReset) { + expect(action.params.mergeQuery).toBe(true); + } + }); + }); + + it('검색은 페이지를 되돌리면서 바뀐 값만 직접 넘긴다', () => { + const search = (uploadsLayout as any).named_actions.searchUploads; + + expect(search.params.mergeQuery).toBe(true); + expect(search.params.query.page).toBe(''); + expect(search.params.query.search).toContain('_local.filter.search'); + }); + + it('필터 초기화는 병합하지 않으며 그 의도를 코드에 남긴다', () => { + const clear = (uploadsLayout as any).named_actions.clearFilters; + const navigate = clear.params.actions.find((a: any) => a.handler === 'navigate'); + + expect(navigate.params.mergeQuery).toBeUndefined(); + expect(navigate.comment).toContain('audit:allow layout-list-context-navigate-merge-query'); + }); +}); + +describe('업로드 관리 화면 — 서버 계약 정합', () => { + it('라우트가 플러그인 네임스페이스 안에 선언되고 조회 권한을 요구한다', () => { + const route = (routesJson as any).routes.find( + (r: any) => r.layout === 'ckeditor5_uploads', + ); + + expect(route.path).toContain('/admin/plugins/sirsoft-ckeditor5/uploads'); + expect(route.meta.permission).toBe('sirsoft-ckeditor5.uploads.read'); + expect(route.auth_required).toBe(true); + }); + + it('화면 정렬 선택지가 서버 게이트의 부분집합이다', () => { + const gate = fs.readFileSync( + path.join(pluginRoot(), 'src/Http/Requests/IndexUploadsRequest.php'), + 'utf-8', + ); + const allowed = /public const SORTABLE = \[([^\]]+)\]/ + .exec(gate)![1] + .split(',') + .map((s) => s.trim().replace(/'/g, '')) + .filter(Boolean); + + const screenSortable = dataGrid.props.columns + .filter((c: any) => c.sortable === true) + .map((c: any) => c.field); + + expect(screenSortable.length).toBeGreaterThan(0); + screenSortable.forEach((field: string) => expect(allowed).toContain(field)); + }); + + it('레이아웃이 요구하는 권한이 플러그인 권한 선언에 존재한다', () => { + const pluginSource = fs.readFileSync(path.join(pluginRoot(), 'plugin.php'), 'utf-8'); + + expect(pluginSource).toContain("'identifier' => 'uploads'"); + expect(pluginSource).toContain("'action' => 'read'"); + expect(pluginSource).toContain("'action' => 'delete'"); + }); +}); + +// ─── 렌더링 ─── + +const TestDiv: React.FC = ({ className, children }) =>
{children}
; +const TestSpan: React.FC = ({ className, children, text }) => ( + {children || text} +); +const TestFragment: React.FC = ({ children }) => <>{children}; + +describe('업로드 관리 화면 — 안내 배너 렌더링', () => { + let registry: ComponentRegistry; + let testUtils: ReturnType | null = null; + + beforeEach(() => { + registry = ComponentRegistry.getInstance(); + (registry as any).registry = { + Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } }, + P: { component: TestSpan, metadata: { name: 'P', type: 'basic' } }, + Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } }, + Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } }, + }; + }); + + afterEach(() => { + testUtils?.cleanup(); + testUtils = null; + (registry as any).registry = {}; + }); + + /** + * 스캔 상한 안내 배너를 지정 상태로 렌더한다. + * + * @param scanLimited 상한 도달 여부 + * @returns createLayoutTest 유틸 + */ + function makeUtils() { + return createLayoutTest( + { + version: '1.0.0', + layout_name: 'test_ckeditor5_uploads_notice', + components: [findNode(uploadsLayout, (n) => n.id === 'scan_limited_notice')], + // 실제 화면의 데이터소스 선언을 그대로 쓴다 — 배너 조건이 이 응답을 읽는다. + data_sources: (uploadsLayout as any).data_sources, + } as any, + { + auth: { + isAuthenticated: true, + user: { id: 1, name: 'Admin', role: 'super_admin' }, + authType: 'admin', + }, + translations: { + 'sirsoft-ckeditor5': { + admin: { + uploads: { + scan_limited_notice: '참조 상태 필터는 최근 :count건을 기준으로 판정합니다.', + }, + }, + }, + }, + locale: 'ko', + }, + ); + } + + it('스캔 상한에 걸리면 안내 배너가 노출된다', async () => { + testUtils = makeUtils(); + testUtils.mockApi('ckeditor5Uploads', { + // 화면은 `ckeditor5Uploads?.data?.meta` 를 읽으므로 응답 봉투의 data 계층을 유지한다. + response: { + data: { + data: [], + pagination: { current_page: 1, last_page: 1, per_page: 20, total: 0 }, + meta: { scan_limited: true, scan_window: 500 }, + }, + }, + }); + + const { container } = await testUtils.render(); + + expect(container.textContent ?? '').toContain('참조 상태 필터는'); + }); + + it('배너 문구가 스캔 상한 값을 파라미터로 바인딩한다', () => { + const notice = findNode(uploadsLayout, (n) => n.id === 'scan_limited_notice'); + const text = findNode(notice, (n) => typeof n.text === 'string' && n.text.includes('scan_limited_notice')).text; + + expect(text).toContain('|count='); + expect(text).toContain('meta?.scan_window'); + }); + + it('상한에 걸리지 않으면 배너가 렌더되지 않는다', async () => { + testUtils = makeUtils(); + testUtils.mockApi('ckeditor5Uploads', { + response: { + data: { + data: [], + pagination: { current_page: 1, last_page: 1, per_page: 20, total: 0 }, + meta: { scan_limited: false, scan_window: 500 }, + }, + }, + }); + + const { container } = await testUtils.render(); + + expect(container.textContent ?? '').not.toContain('참조 상태 필터는'); + }); +}); + +describe('업로드 관리 화면 — 파라미터 치환 문구', () => { + /** + * 프론트 다국어 파라미터는 `{{name}}` 규약이다. Laravel 스타일 `:name` 을 쓰면 치환되지 + * 않아 운영자에게 원시 placeholder 가 그대로 보인다 — 파괴적 삭제 확인 모달에서도 마찬가지다 + * (브라우저 실측 — "선택한 :count건의 파일과 기록이 함께 삭제됩니다"). + */ + it.each(['ko', 'en'])('%s 문구가 :param 대신 {{param}} 을 쓴다', (locale) => { + const raw = fs.readFileSync(path.join(pluginRoot(), `resources/lang/${locale}.json`), 'utf-8'); + const messages = JSON.parse(raw); + const offenders: string[] = []; + + const walk = (node: unknown, keyPath: string): void => { + if (typeof node === 'string') { + // `:count` 처럼 낱말이 이어지는 형태만 파라미터로 본다 (URL·명령어의 콜론은 제외). + if (/(^|\s):[a-z_]+/.test(node)) offenders.push(`${keyPath} = ${node}`); + return; + } + if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node as Record)) { + walk(value, keyPath ? `${keyPath}.${key}` : key); + } + } + }; + + walk(messages.admin?.uploads ?? {}, 'admin.uploads'); + walk(messages.settings?.cleanup ?? {}, 'settings.cleanup'); + + expect(offenders).toEqual([]); + }); + + it('레이아웃이 넘기는 파라미터 이름이 문구의 placeholder 와 일치한다', () => { + const messages = JSON.parse( + fs.readFileSync(path.join(pluginRoot(), 'resources/lang/ko.json'), 'utf-8'), + ); + + expect(messages.admin.uploads.bulk_delete.selected).toContain('{{count}}'); + expect(messages.admin.uploads.bulk_delete.confirm_message).toContain('{{count}}'); + expect(messages.admin.uploads.scan_limited_notice).toContain('{{count}}'); + }); +}); diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsCleanup.test.tsx b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsCleanup.test.tsx new file mode 100644 index 00000000..dd3a0e1e --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsCleanup.test.tsx @@ -0,0 +1,249 @@ +/** + * CKEditor5 설정 화면 — 미사용 이미지 정리 섹션 테스트 (공개 #115) + * + * @description + * 자동 정리는 사용자 파일을 지우므로 기본 꺼짐이다. 화면이 그 계약을 지키는지 + * (저장 키 · 기본값 · 스키마 세 지점 일치)와, 라벨/힌트가 실제로 렌더되는지를 고정한다. + * + * @effects settings_cleanup_toggle_default_off, settings_cleanup_keys_declared + */ + +import React from 'react'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils'; +import { ComponentRegistry } from '@core/template-engine/ComponentRegistry'; + +import pluginSettings from '../../../layouts/admin/plugin_settings.json'; + +/** + * 플러그인 루트(plugin.json 기준)를 위로 훑어 찾는다. + * + * @returns 플러그인 루트 절대경로 + */ +function pluginRoot(): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 10; depth++) { + if (fs.existsSync(path.join(current, 'plugin.json'))) { + return current; + } + current = path.dirname(current); + } + + throw new Error('plugin.json 을 가진 플러그인 루트를 찾지 못했습니다.'); +} + +/** + * 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다 (slots 포함). + * + * @param node 탐색 시작 노드 + * @param predicate 노드 판정 함수 + * @returns 찾은 노드 또는 null + */ +function findNode(node: any, predicate: (n: any) => boolean): any { + if (!node || typeof node !== 'object') return null; + if (predicate(node)) return node; + + const groups = [node.children ?? [], ...(node.slots ? Object.values(node.slots) : [])]; + for (const group of groups) { + for (const child of group as any[]) { + const found = findNode(child, predicate); + if (found) return found; + } + } + + return null; +} + +/** + * 이 화면을 렌더하는 admin 템플릿의 매니페스트가 선언한 컴포넌트 종류를 읽는다. + * + * 레이아웃 노드의 `type` 은 렌더러가 DOM 안전 필터링을 고르는 기준이라 + * 매니페스트 선언과 어긋나면 `editorAttrs` 가 제거된다(DynamicRenderer). + * + * @param name 컴포넌트 이름 + * @returns 매니페스트가 선언한 type + */ +function manifestType(name: string): string { + // 플러그인 루트(plugins/_bundled/sirsoft-ckeditor5) 에서 저장소 루트로 두 단계 올라간다. + const repoRoot = path.dirname(path.dirname(path.dirname(pluginRoot()))); + const manifest = JSON.parse( + fs.readFileSync( + path.join(repoRoot, 'templates/_bundled/sirsoft-admin_basic/components.json'), + 'utf-8', + ), + ); + + for (const [kind, entries] of Object.entries(manifest.components ?? {})) { + if ((entries as any[]).some((entry) => entry.name === name)) { + return kind; + } + } + + throw new Error(`매니페스트에 ${name} 선언이 없습니다.`); +} + +const cleanupCard = findNode(pluginSettings, (n) => n.id === 'section_cleanup_card'); + +describe('미사용 이미지 정리 섹션 — 계약', () => { + it('설정 섹션이 존재한다', () => { + expect(cleanupCard).not.toBeNull(); + }); + + it('설정 화면의 모든 Toggle 노드 type 이 매니페스트 선언과 같다', () => { + const collected: any[] = []; + const collect = (node: any): void => { + if (!node || typeof node !== 'object') return; + if (node.name === 'Toggle') collected.push(node); + + const groups = [node.children ?? [], ...(node.slots ? Object.values(node.slots) : [])]; + for (const group of groups) { + for (const child of group as any[]) collect(child); + } + }; + collect(pluginSettings); + + // 특정 노드만 짚으면 다음에 추가되는 토글이 같은 실수를 반복해도 잡히지 않는다. + expect(collected.length).toBeGreaterThan(0); + for (const toggle of collected) { + expect(toggle.type).toBe(manifestType('Toggle')); + } + }); + + it('토글과 보존기간 입력이 저장 키를 그대로 바인딩한다', () => { + const toggle = findNode(cleanupCard, (n) => n.name === 'Toggle'); + const input = findNode(cleanupCard, (n) => n.name === 'Input'); + + expect(toggle.props.name).toBe('unusedImageCleanup'); + expect(input.props.name).toBe('unusedImageRetentionDays'); + expect(input.props.min).toBe(1); + }); + + it('기본값이 꺼짐이고 보존기간 기본이 30일이다 (defaults.json)', () => { + const defaults = JSON.parse( + fs.readFileSync(path.join(pluginRoot(), 'config/settings/defaults.json'), 'utf-8'), + ); + + expect(defaults.defaults.unusedImageCleanup).toBe(false); + expect(defaults.defaults.unusedImageRetentionDays).toBe(30); + }); + + it('두 키가 프론트엔드에 노출되지 않는다 (운영자 전용 설정)', () => { + const defaults = JSON.parse( + fs.readFileSync(path.join(pluginRoot(), 'config/settings/defaults.json'), 'utf-8'), + ); + + expect(defaults.frontend_schema.unusedImageCleanup.expose).toBe(false); + expect(defaults.frontend_schema.unusedImageRetentionDays.expose).toBe(false); + }); + + it('설정 스키마가 같은 키를 선언한다 (저장 검증 whitelist)', () => { + const pluginSource = fs.readFileSync(path.join(pluginRoot(), 'plugin.php'), 'utf-8'); + + expect(pluginSource).toContain("'unusedImageCleanup' => ["); + expect(pluginSource).toContain("'unusedImageRetentionDays' => ["); + expect(pluginSource).toContain("'default' => false"); + }); + + it('스케줄 게이트가 같은 설정 키를 가리킨다', () => { + const pluginSource = fs.readFileSync(path.join(pluginRoot(), 'plugin.php'), 'utf-8'); + + expect(pluginSource).toContain("'enabled_config' => 'sirsoft-ckeditor5.unusedImageCleanup'"); + expect(pluginSource).toContain('sirsoft-ckeditor5:prune-unused-images --scheduled'); + }); + + it('업로드 관리 화면으로 가는 링크가 있다', () => { + const button = findNode(cleanupCard, (n) => n.id === 'open_uploads_button'); + + expect(button.actions[0].params.path).toBe('/admin/plugins/sirsoft-ckeditor5/uploads'); + }); +}); + +// ─── 렌더링 ─── + +const TestDiv: React.FC = ({ className, children }) =>
{children}
; +const TestLabel: React.FC = ({ className, children, text }) => ( + +); +const TestP: React.FC = ({ className, children, text }) =>

{children || text}

; +const TestH3: React.FC = ({ className, children, text }) =>

{children || text}

; +const TestToggle: React.FC = ({ name }) => ; +const TestInput: React.FC = ({ name, type }) => ; +const TestButton: React.FC = ({ className, children }) => ; +const TestSpan: React.FC = ({ className, children, text }) => {children || text}; +const TestFragment: React.FC = ({ children }) => <>{children}; + +describe('미사용 이미지 정리 섹션 — 렌더링', () => { + let registry: ComponentRegistry; + let testUtils: ReturnType | null = null; + + beforeEach(() => { + registry = ComponentRegistry.getInstance(); + (registry as any).registry = { + Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } }, + Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } }, + P: { component: TestP, metadata: { name: 'P', type: 'basic' } }, + H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } }, + Toggle: { component: TestToggle, metadata: { name: 'Toggle', type: 'composite' } }, + Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } }, + Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } }, + Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } }, + Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } }, + }; + }); + + afterEach(() => { + testUtils?.cleanup(); + testUtils = null; + (registry as any).registry = {}; + }); + + it('라벨과 힌트가 다국어 해석되어 렌더된다', async () => { + testUtils = createLayoutTest( + { + version: '1.0.0', + layout_name: 'test_ckeditor5_cleanup_section', + components: [cleanupCard], + } as any, + { + auth: { + isAuthenticated: true, + user: { id: 1, name: 'Admin', role: 'super_admin' }, + authType: 'admin', + }, + translations: { + 'sirsoft-ckeditor5': { + settings: { + section_cleanup: '미사용 이미지 정리', + cleanup: { + toggle_label: '미사용 이미지 자동 정리', + toggle_hint: '기본 꺼짐 — 운영자가 직접 켜야 동작합니다.', + retention_label: '보존기간 (일)', + retention_hint: '보존기간이 지난 미사용 이미지만 정리 대상입니다.', + manual_hint: 'php artisan sirsoft-ckeditor5:prune-unused-images --dry-run', + open_uploads: '업로드 이미지 관리 열기', + }, + }, + }, + }, + locale: 'ko', + initialState: { _local: { form: { unusedImageCleanup: false, unusedImageRetentionDays: 30 } } }, + }, + ); + + const { container } = await testUtils.render(); + const text = container.textContent ?? ''; + + expect(text).toContain('미사용 이미지 정리'); + expect(text).toContain('기본 꺼짐'); + expect(text).toContain('보존기간 (일)'); + expect(text).toContain('업로드 이미지 관리 열기'); + + expect(container.querySelector('input[data-name="unusedImageCleanup"]')).not.toBeNull(); + expect(container.querySelector('input[data-name="unusedImageRetentionDays"]')).not.toBeNull(); + expect(() => testUtils!.assertNoValidationErrors()).not.toThrow(); + }); +}); diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx index a97b7175..b9037eb0 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx @@ -7,9 +7,33 @@ * 적용되어 있는지 검증한다. */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; import pluginSettingsLayout from '../../../layouts/admin/plugin_settings.json'; +/** 관리자 템플릿의 컴파일된 컴포넌트 CSS 경로 (합성 유틸리티 클래스의 실제 선언 위치). */ +const ADMIN_COMPONENTS_CSS = 'templates/_bundled/sirsoft-admin_basic/dist/css/components.css'; + +/** + * 저장소 루트(artisan 기준)를 위로 훑어 찾는다. + * + * @returns 저장소 루트 절대경로 + */ +function repoRoot(): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 10; depth++) { + if (fs.existsSync(path.join(current, 'artisan'))) { + return current; + } + current = path.dirname(current); + } + + throw new Error('artisan 을 가진 저장소 루트를 찾지 못했습니다.'); +} + /** 레이아웃 트리에서 주어진 id 의 노드를 찾는다. */ function findById(node: unknown, id: string): Record | undefined { if (!node || typeof node !== 'object') { @@ -34,13 +58,24 @@ function classNameOf(node: Record | undefined): string { } describe('plugin_settings 하단 버튼 sticky 고정', () => { - it('footer_buttons 에 sticky bottom 고정 클래스가 존재해야 한다', () => { + it('footer_buttons 가 sticky 고정 유틸리티 클래스를 사용해야 한다', () => { const footer = findById(pluginSettingsLayout, 'footer_buttons'); expect(footer).toBeDefined(); - const className = classNameOf(footer); - expect(className).toContain('sticky'); - expect(className).toContain('bottom-0'); - expect(className).toContain('z-10'); + // 레이아웃은 raw Tailwind 원자(sticky bottom-0 z-10) 대신 합성 유틸리티 한 개를 쓴다. + // 원자 이름을 직접 단언하면 합성 클래스로 옮긴 뒤 고정이 멀쩡한데도 실패한다. + expect(classNameOf(footer)).toContain('sticky-footer-buttons'); + }); + + it('그 유틸리티 클래스가 실제로 하단 고정 속성을 선언해야 한다', () => { + // 클래스 이름만 대조하면 동어반복이라, 컴파일된 CSS 에서 실제 선언을 확인한다 — + // 클래스명이 남아 있어도 선언이 사라지면 화면에서 고정이 풀리기 때문이다. + const css = fs.readFileSync(path.join(repoRoot(), ADMIN_COMPONENTS_CSS), 'utf-8'); + const rule = /\.sticky-footer-buttons\{([^}]*)\}/.exec(css); + + expect(rule).not.toBeNull(); + expect(rule![1]).toContain('position:sticky'); + expect(rule![1]).toContain('bottom:'); + expect(rule![1]).toContain('z-index:10'); }); }); diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/lang/en.json b/plugins/_bundled/sirsoft-ckeditor5/resources/lang/en.json index d029661e..f42baa81 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/lang/en.json @@ -1,7 +1,53 @@ { "editor": { "data_source": { - "settings": "Editor Settings" + "settings": "Editor Settings", + "ckeditor5Uploads": "Uploaded Image List" + } + }, + "admin": { + "uploads": { + "title": "Editor Uploads", + "description": "Review images uploaded through the editor and clean up the ones that are no longer used.", + "refresh": "Refresh", + "open_settings": "Open cleanup settings", + "reference_notice": "Reference status is determined by scanning registered content such as posts, pages, and product descriptions. An image used only by an external extension that has not registered a reference source may appear as \"Unreferenced\", so verify before deleting.", + "scan_limited_notice": "The reference status filter evaluates the most recent {{count}} uploads. Older images are not shown in this list.", + "empty": "No uploaded images.", + "col": { + "thumbnail": "Preview", + "original_name": "Original name", + "file_size": "Size", + "uploader": "Uploaded by", + "created_at": "Uploaded at", + "referenced": "Reference" + }, + "badge": { + "referenced": "In use", + "unreferenced": "Unused" + }, + "filter": { + "search_placeholder": "Search by original name or hash", + "search_button": "Search", + "referenced_all": "All statuses", + "referenced_only": "In use only", + "unreferenced_only": "Unused only", + "date_from": "From", + "date_to": "To", + "clear": "Clear filters" + }, + "delete": { + "button": "Delete", + "confirm_title": "Delete this image?", + "confirm_message": "The image file and its record will both be deleted. This cannot be undone.", + "success": "The image has been deleted." + }, + "bulk_delete": { + "button": "Delete selected", + "selected": "{{count}} selected", + "confirm_title": "Delete the selected images?", + "confirm_message": "The files and records of the {{count}} selected images will both be deleted. This cannot be undone." + } } }, "settings": { @@ -10,8 +56,17 @@ "notice": "CKEditor5 is loaded via CDN. An internet connection is required. It is free to use without a separate license.", "section_toolbar": "Editor Settings", "section_image": "Image Settings", + "section_cleanup": "Unused Image Cleanup", "section_preview": "Editor Test", "section_preview_hint": "Preview CKEditor5 with the current settings applied.", + "cleanup": { + "toggle_label": "Auto cleanup unused images", + "toggle_hint": "Off by default — the operator must turn it on. When on, files and records of unused images past the retention period are deleted once a day.", + "retention_label": "Retention period (days)", + "retention_hint": "Only unused images older than this period are eligible for cleanup. (1 ~ 3650 days)", + "manual_hint": "To see what would be removed first, run this command on the server: php artisan sirsoft-ckeditor5:prune-unused-images --dry-run", + "open_uploads": "Open editor uploads" + }, "preview_placeholder": "Type here to test the editor.", "fields": { "toolbar": { diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/lang/ko.json b/plugins/_bundled/sirsoft-ckeditor5/resources/lang/ko.json index 8363aef3..394c89fd 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/lang/ko.json @@ -1,7 +1,53 @@ { "editor": { "data_source": { - "settings": "에디터 설정" + "settings": "에디터 설정", + "ckeditor5Uploads": "업로드 이미지 목록" + } + }, + "admin": { + "uploads": { + "title": "에디터 업로드 이미지", + "description": "에디터로 업로드된 이미지를 확인하고, 더 이상 사용되지 않는 이미지를 정리합니다.", + "refresh": "새로고침", + "open_settings": "정리 설정 열기", + "reference_notice": "참조 상태는 게시글·페이지·상품 설명 등 등록된 콘텐츠를 훑어 판정합니다. 참조 소스를 등록하지 않은 외부 확장의 콘텐츠에만 쓰인 이미지는 「미참조」로 보일 수 있으므로 삭제 전에 확인하세요.", + "scan_limited_notice": "참조 상태 필터는 최근 {{count}}건을 기준으로 판정합니다. 그보다 오래된 이미지는 이 목록에 표시되지 않습니다.", + "empty": "업로드된 이미지가 없습니다.", + "col": { + "thumbnail": "미리보기", + "original_name": "원본 파일명", + "file_size": "크기", + "uploader": "업로더", + "created_at": "업로드 일시", + "referenced": "참조 상태" + }, + "badge": { + "referenced": "참조됨", + "unreferenced": "미참조" + }, + "filter": { + "search_placeholder": "원본 파일명 또는 해시로 검색", + "search_button": "검색", + "referenced_all": "전체 상태", + "referenced_only": "참조됨만", + "unreferenced_only": "미참조만", + "date_from": "시작일", + "date_to": "종료일", + "clear": "필터 초기화" + }, + "delete": { + "button": "삭제", + "confirm_title": "이미지를 삭제할까요?", + "confirm_message": "선택한 이미지의 파일과 기록이 함께 삭제됩니다. 이 작업은 되돌릴 수 없습니다.", + "success": "이미지를 삭제했습니다." + }, + "bulk_delete": { + "button": "선택 삭제", + "selected": "{{count}}건 선택됨", + "confirm_title": "선택한 이미지를 삭제할까요?", + "confirm_message": "선택한 {{count}}건의 파일과 기록이 함께 삭제됩니다. 이 작업은 되돌릴 수 없습니다." + } } }, "settings": { @@ -10,8 +56,17 @@ "notice": "CKEditor5는 CDN 방식으로 로드됩니다. 인터넷 연결이 필요하며, 별도의 라이선스 없이 무료로 사용할 수 있습니다.", "section_toolbar": "에디터 설정", "section_image": "이미지 설정", + "section_cleanup": "미사용 이미지 정리", "section_preview": "에디터 테스트", "section_preview_hint": "현재 설정이 적용된 CKEditor5 에디터를 미리 확인할 수 있습니다.", + "cleanup": { + "toggle_label": "미사용 이미지 자동 정리", + "toggle_hint": "기본 꺼짐 — 운영자가 직접 켜야 동작합니다. 켜면 매일 한 번, 보존기간이 지난 미사용 이미지의 파일과 기록을 함께 삭제합니다.", + "retention_label": "보존기간 (일)", + "retention_hint": "업로드 후 이 기간이 지난 미사용 이미지만 정리 대상이 됩니다. (1 ~ 3650 일)", + "manual_hint": "지금 무엇이 지워질지 먼저 확인하려면 서버에서 다음 명령을 실행하세요: php artisan sirsoft-ckeditor5:prune-unused-images --dry-run", + "open_uploads": "업로드 이미지 관리 열기" + }, "preview_placeholder": "여기에 내용을 입력하여 에디터를 테스트해 보세요.", "fields": { "toolbar": { diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/ckeditor5_uploads.json b/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/ckeditor5_uploads.json new file mode 100644 index 00000000..9f1d8f86 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/ckeditor5_uploads.json @@ -0,0 +1,1068 @@ +{ + "version": "1.0.0", + "layout_name": "ckeditor5_uploads", + "permissions": [ + "sirsoft-ckeditor5.uploads.read" + ], + "extends": "_admin_base", + "meta": { + "title": "$t:sirsoft-ckeditor5.admin.uploads.title", + "description": "$t:sirsoft-ckeditor5.admin.uploads.description" + }, + "state": { + "filter": { + "search": "", + "referenced": "all", + "dateFrom": "", + "dateTo": "" + } + }, + "transition_overlay": { + "wait_for": [ + "ckeditor5Uploads" + ] + }, + "data_sources": [ + { + "id": "ckeditor5Uploads", + "label_key": "$t:sirsoft-ckeditor5.editor.data_source.ckeditor5Uploads", + "type": "api", + "endpoint": "/api/plugins/sirsoft-ckeditor5/admin/uploads", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "loading_strategy": "progressive", + "params": { + "page": "{{query.page || 1}}", + "per_page": "{{query.per_page || 20}}", + "search": "{{query.search}}", + "referenced": "{{query.referenced}}", + "date_from": "{{query.date_from}}", + "date_to": "{{query.date_to}}", + "sort_by": "{{query.sort_by}}", + "sort_order": "{{query.sort_order}}" + }, + "errorHandling": { + "403": { + "handler": "showErrorPage", + "params": { + "target": "content" + } + } + }, + "fallback": { + "data": { + "data": [], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 20, + "total": 0, + "from": null, + "to": null + }, + "meta": { + "scan_limited": false, + "scan_window": 500 + } + } + } + } + ], + "init_actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "filter": { + "search": "{{query.search || ''}}", + "referenced": "{{query.referenced || 'all'}}", + "dateFrom": "{{query.date_from || ''}}", + "dateTo": "{{query.date_to || ''}}" + } + } + } + ], + "named_actions": { + "searchUploads": { + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-ckeditor5/uploads", + "replace": true, + "mergeQuery": true, + "query": { + "page": "", + "search": "{{_local.filter.search || ''}}", + "referenced": "{{_local.filter.referenced || 'all'}}", + "date_from": "{{_local.filter.dateFrom || ''}}", + "date_to": "{{_local.filter.dateTo || ''}}" + }, + "transition_overlay_target": "ckeditor5_uploads_datagrid__body" + } + }, + "clearFilters": { + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "filter": { + "search": "", + "referenced": "all", + "dateFrom": "", + "dateTo": "" + } + } + }, + { + "comment": "audit:allow layout-list-context-navigate-merge-query 필터 초기화 — 검색·참조상태·기간과 페이지를 모두 비우는 의도적 리셋이므로 병합하지 않는다", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-ckeditor5/uploads", + "replace": true, + "query": {} + } + } + ] + } + } + }, + "slots": { + "content": [ + { + "id": "ckeditor5_uploads_content", + "type": "basic", + "name": "Div", + "props": { + "className": "admin-page-content" + }, + "children": [ + { + "id": "page_header", + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between mb-6" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "H1", + "props": { + "className": "page-title" + }, + "text": "$t:sirsoft-ckeditor5.admin.uploads.title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-label-subtle mt-1" + }, + "text": "$t:sirsoft-ckeditor5.admin.uploads.description" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "id": "open_settings_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-secondary" + }, + "actions": [ + { + "comment": "audit:allow layout-list-context-navigate-merge-query 플러그인 설정 화면으로 나가는 이동 — 목록 클러스터 밖이라 목록 상태를 승계하지 않는다", + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-ckeditor5/settings" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-ckeditor5.admin.uploads.open_settings" + } + ] + }, + { + "id": "refresh_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn-icon", + "title": "$t:sirsoft-ckeditor5.admin.uploads.refresh" + }, + "actions": [ + { + "type": "click", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "ckeditor5Uploads" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fa-solid fa-arrows-rotate", + "className": "text-xl" + } + } + ] + } + ] + } + ] + }, + { + "id": "reference_notice", + "type": "basic", + "name": "Div", + "props": { + "className": "mb-6 p-4 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-sm text-blue-800 dark:text-blue-200" + }, + "text": "$t:sirsoft-ckeditor5.admin.uploads.reference_notice" + } + ] + }, + { + "id": "scan_limited_notice", + "type": "basic", + "name": "Div", + "if": "{{ckeditor5Uploads?.data?.meta?.scan_limited === true}}", + "props": { + "className": "mb-6 p-4 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + }, + "text": "$t:sirsoft-ckeditor5.admin.uploads.scan_limited_notice|count={{ckeditor5Uploads?.data?.meta?.scan_window ?? 500}}" + } + ] + }, + { + "id": "filter_section", + "type": "basic", + "name": "Div", + "props": { + "className": "admin-card mb-6" + }, + "children": [ + { + "id": "search_row", + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center flex-wrap gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "relative flex-1" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fa-solid fa-search", + "className": "absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 text-sm pointer-events-none" + } + }, + { + "type": "basic", + "name": "Input", + "props": { + "name": "search", + "type": "text", + "placeholder": "$t:sirsoft-ckeditor5.admin.uploads.filter.search_placeholder", + "className": "input pl-10", + "value": "{{_local.filter.search || ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "filter.search": "{{$event.target.value}}" + } + }, + { + "type": "keydown", + "key": "Enter", + "actionRef": "searchUploads" + } + ] + } + ] + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "referenced", + "value": "{{_local.filter.referenced || 'all'}}", + "options": [ + { + "value": "all", + "label": "$t:sirsoft-ckeditor5.admin.uploads.filter.referenced_all" + }, + { + "value": "referenced", + "label": "$t:sirsoft-ckeditor5.admin.uploads.filter.referenced_only" + }, + { + "value": "unreferenced", + "label": "$t:sirsoft-ckeditor5.admin.uploads.filter.unreferenced_only" + } + ] + }, + "actions": [ + { + "type": "change", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "filter.referenced": "{{$event.target.value}}" + } + }, + { + "actionRef": "searchUploads" + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "name": "date_from", + "type": "date", + "className": "input w-40", + "title": "$t:sirsoft-ckeditor5.admin.uploads.filter.date_from", + "value": "{{_local.filter.dateFrom || ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "filter.dateFrom": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "name": "date_to", + "type": "date", + "className": "input w-40", + "title": "$t:sirsoft-ckeditor5.admin.uploads.filter.date_to", + "value": "{{_local.filter.dateTo || ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "filter.dateTo": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-primary" + }, + "actions": [ + { + "type": "click", + "actionRef": "searchUploads" + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-ckeditor5.admin.uploads.filter.search_button" + } + ] + } + ] + }, + { + "id": "clear_filters_row", + "type": "basic", + "name": "Div", + "if": "{{!!(query.search || query.referenced || query.date_from || query.date_to)}}", + "props": { + "className": "flex justify-end mt-2" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-tertiary hover:text-gray-700 dark:hover:text-gray-200 underline" + }, + "text": "$t:sirsoft-ckeditor5.admin.uploads.filter.clear", + "actions": [ + { + "type": "click", + "actionRef": "clearFilters" + } + ] + } + ] + } + ] + }, + { + "id": "bulk_action_bar", + "type": "basic", + "name": "Div", + "if": "{{(_global.selectedIds_ckeditor5_uploads?.length ?? 0) > 0}}", + "props": { + "className": "flex-between mb-4 p-3 rounded-xl bg-gray-100 dark:bg-gray-800" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-body" + }, + "text": "$t:sirsoft-ckeditor5.admin.uploads.bulk_delete.selected|count={{_global.selectedIds_ckeditor5_uploads?.length ?? 0}}" + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "flex-center gap-2 px-4 py-2 bg-red-600 dark:bg-red-700 text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-800" + }, + "actions": [ + { + "type": "click", + "handler": "openModal", + "target": "bulk_delete_confirm_modal" + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-ckeditor5.admin.uploads.bulk_delete.button" + } + ] + } + ] + }, + { + "id": "ckeditor5_uploads_datagrid", + "type": "composite", + "name": "DataGrid", + "props": { + "data": "{{ckeditor5Uploads?.data?.data}}", + "pagination": true, + "serverSidePagination": true, + "serverCurrentPage": "{{ckeditor5Uploads?.data?.pagination?.current_page ?? 1}}", + "serverTotalPages": "{{ckeditor5Uploads?.data?.pagination?.last_page ?? null}}", + "pageSize": "{{+(query.per_page || 20)}}", + "alwaysShowPagination": true, + "sortable": true, + "sortField": "{{query.sort_by || 'created_at'}}", + "sortDirection": "{{query.sort_order || 'desc'}}", + "idField": "id", + "selectable": true, + "selectionScope": "page", + "selectedIds": "{{_global.selectedIds_ckeditor5_uploads || []}}", + "loading": "{{ckeditor5Uploads?.loading}}", + "emptyText": "$t:sirsoft-ckeditor5.admin.uploads.empty", + "actionsColumnHeader": "$t:common.actions", + "rowActions": [ + { + "id": "delete", + "label": "$t:sirsoft-ckeditor5.admin.uploads.delete.button", + "iconName": "trash", + "variant": "danger" + } + ], + "columns": [ + { + "field": "thumbnail", + "header": "$t:sirsoft-ckeditor5.admin.uploads.col.thumbnail", + "width": "90px", + "cellChildren": [ + { + "type": "basic", + "name": "Img", + "props": { + "src": "{{row.download_url}}", + "alt": "{{row.original_name ?? ''}}", + "loading": "lazy", + "className": "w-16 h-16 object-cover rounded-lg border border-gray-200 dark:border-gray-700" + } + } + ] + }, + { + "field": "original_name", + "header": "$t:sirsoft-ckeditor5.admin.uploads.col.original_name", + "sortable": true, + "width": "260px", + "cellChildren": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-heading line-clamp-1" + }, + "text": "{{row.original_name ?? '-'}}" + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-tertiary font-mono" + }, + "text": "{{row.hash ?? ''}}" + } + ] + } + ] + }, + { + "field": "file_size", + "header": "$t:sirsoft-ckeditor5.admin.uploads.col.file_size", + "sortable": true, + "width": "110px", + "cellChildren": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-label whitespace-nowrap" + }, + "text": "{{Math.round((row.file_size ?? 0) / 1024).toLocaleString()}} KB" + } + ] + }, + { + "field": "uploader_name", + "header": "$t:sirsoft-ckeditor5.admin.uploads.col.uploader", + "width": "140px", + "cellChildren": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-body" + }, + "text": "{{row.uploader_name ?? '-'}}" + } + ] + }, + { + "field": "created_at", + "header": "$t:sirsoft-ckeditor5.admin.uploads.col.created_at", + "sortable": true, + "width": "170px", + "cellChildren": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-label whitespace-nowrap" + }, + "text": "{{row.created_at ?? '-'}}" + } + ] + }, + { + "field": "referenced", + "header": "$t:sirsoft-ckeditor5.admin.uploads.col.referenced", + "width": "110px", + "cellChildren": [ + { + "type": "basic", + "name": "Span", + "if": "{{row.referenced === true}}", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300" + }, + "text": "$t:sirsoft-ckeditor5.admin.uploads.badge.referenced" + }, + { + "type": "basic", + "name": "Span", + "if": "{{row.referenced !== true}}", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300" + }, + "text": "$t:sirsoft-ckeditor5.admin.uploads.badge.unreferenced" + } + ] + } + ] + }, + "actions": [ + { + "event": "onPageChange", + "type": "change", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-ckeditor5/uploads", + "replace": true, + "mergeQuery": true, + "query": { + "page": "{{$args[0]}}" + }, + "transition_overlay_target": "ckeditor5_uploads_datagrid__body" + } + }, + { + "event": "onSortChange", + "type": "change", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-ckeditor5/uploads", + "replace": true, + "mergeQuery": true, + "query": { + "page": "", + "sort_by": "{{$args[0]}}", + "sort_order": "{{$args[1]}}" + }, + "transition_overlay_target": "ckeditor5_uploads_datagrid__body" + } + }, + { + "event": "onSelectionChange", + "type": "action", + "handler": "setState", + "params": { + "target": "global", + "selectedIds_ckeditor5_uploads": "{{$args[0]}}" + } + }, + { + "event": "onRowAction", + "type": "action", + "handler": "switch", + "cases": { + "delete": { + "handler": "setState", + "params": { + "target": "global", + "modal_data": "{{$args[1]}}" + }, + "onSuccess": [ + { + "handler": "openModal", + "target": "delete_confirm_modal" + } + ] + } + } + } + ] + } + ] + } + ] + }, + "modals": [ + { + "meta": { + "editor_label": "$t:sirsoft-ckeditor5.admin.uploads.delete.confirm_title" + }, + "id": "delete_confirm_modal", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-ckeditor5.admin.uploads.delete.confirm_title", + "size": "sm" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "py-2" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-gray-700 dark:text-gray-300" + }, + "text": "$t:sirsoft-ckeditor5.admin.uploads.delete.confirm_message" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "modal-footer-buttons" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-secondary", + "disabled": "{{_global.isDeletingUpload}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal" + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:common.cancel" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "flex-center gap-2 px-4 py-2 bg-red-600 dark:bg-red-700 text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-800 disabled:opacity-50 disabled:cursor-not-allowed", + "disabled": "{{_global.isDeletingUpload}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "isDeletingUpload": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-ckeditor5/admin/uploads/{{_global.modal_data.id}}", + "params": { + "method": "DELETE" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "isDeletingUpload": false + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "ckeditor5Uploads" + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:sirsoft-ckeditor5.admin.uploads.delete.success" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "isDeletingUpload": false + } + }, + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ], + "errorHandling": { + "403": { + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "isDeletingUpload": false + } + }, + { + "handler": "toast", + "params": { + "type": "error", + "message": "$t:auth.permission_denied" + } + } + ] + } + } + } + } + ] + } + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:common.delete" + } + ] + } + ] + } + ] + }, + { + "meta": { + "editor_label": "$t:sirsoft-ckeditor5.admin.uploads.bulk_delete.confirm_title" + }, + "id": "bulk_delete_confirm_modal", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-ckeditor5.admin.uploads.bulk_delete.confirm_title", + "size": "sm" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "py-2" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-gray-700 dark:text-gray-300" + }, + "text": "$t:sirsoft-ckeditor5.admin.uploads.bulk_delete.confirm_message|count={{_global.selectedIds_ckeditor5_uploads?.length ?? 0}}" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "modal-footer-buttons" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-secondary", + "disabled": "{{_global.isDeletingUpload}}" + }, + "actions": [ + { + "type": "click", + "handler": "closeModal" + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:common.cancel" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "flex-center gap-2 px-4 py-2 bg-red-600 dark:bg-red-700 text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-800 disabled:opacity-50 disabled:cursor-not-allowed", + "disabled": "{{_global.isDeletingUpload}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "isDeletingUpload": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-ckeditor5/admin/uploads/bulk-delete", + "params": { + "method": "POST", + "body": { + "ids": "{{_global.selectedIds_ckeditor5_uploads ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "global", + "isDeletingUpload": false, + "selectedIds_ckeditor5_uploads": [] + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "ckeditor5Uploads" + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "{{response.message}}" + } + }, + { + "handler": "closeModal" + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "isDeletingUpload": false + } + }, + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ], + "errorHandling": { + "403": { + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "isDeletingUpload": false + } + }, + { + "handler": "toast", + "params": { + "type": "error", + "message": "$t:auth.permission_denied" + } + } + ] + } + } + } + } + ] + } + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:common.delete" + } + ] + } + ] + } + ] + } + ] +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json index 771d970a..739e441a 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json @@ -330,7 +330,7 @@ "props": { "className": "lg:col-span-2" }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "name": "imageUpload" } } @@ -428,6 +428,162 @@ } ] }, + { + "id": "section_cleanup_card", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "id": "section_cleanup_header", + "type": "basic", + "name": "Div", + "props": { "className": "panel-header-row" }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { "className": "text-error-strong" }, + "text": "$t:sirsoft-ckeditor5.settings.section_cleanup" + } + ] + }, + { + "id": "section_cleanup_fields", + "type": "basic", + "name": "Div", + "props": { "className": "p-6 space-y-6" }, + "children": [ + { + "id": "field_unused_image_cleanup", + "type": "basic", + "name": "Div", + "props": { "className": "section-heading-md" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "lg:col-span-1" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-ckeditor5.settings.cleanup.toggle_label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-ckeditor5.settings.cleanup.toggle_hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "lg:col-span-2" }, + "children": [ + { + "type": "composite", + "name": "Toggle", + "props": { "name": "unusedImageCleanup" } + } + ] + } + ] + }, + { + "id": "field_unused_image_retention_days", + "type": "basic", + "name": "Div", + "props": { "className": "section-heading-md" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "lg:col-span-1" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-ckeditor5.settings.cleanup.retention_label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-ckeditor5.settings.cleanup.retention_hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "lg:col-span-2" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "number", + "name": "unusedImageRetentionDays", + "min": 1, + "className": "focus:border-transparent" + } + } + ] + } + ] + }, + { + "id": "cleanup_manual_hint", + "type": "basic", + "name": "Div", + "props": { + "className": "p-4 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 space-y-3" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { "className": "text-sm text-gray-600 dark:text-gray-300 break-all" }, + "text": "$t:sirsoft-ckeditor5.settings.cleanup.manual_hint" + }, + { + "id": "open_uploads_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-secondary" + }, + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-ckeditor5/uploads" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-ckeditor5.settings.cleanup.open_uploads" + } + ] + } + ] + } + ] + } + ] + }, { "id": "guide_section", "type": "basic", diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/routes.json b/plugins/_bundled/sirsoft-ckeditor5/resources/routes.json index 46ad1446..deba863f 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/resources/routes.json +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/routes.json @@ -1,4 +1,14 @@ { "version": "1.0.0", - "routes": [] + "routes": [ + { + "path": "*/admin/plugins/sirsoft-ckeditor5/uploads", + "layout": "ckeditor5_uploads", + "auth_required": true, + "meta": { + "title": "$t:sirsoft-ckeditor5.admin.uploads.title", + "permission": "sirsoft-ckeditor5.uploads.read" + } + } + ] } diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Console/Commands/PruneUnusedImagesCommand.php b/plugins/_bundled/sirsoft-ckeditor5/src/Console/Commands/PruneUnusedImagesCommand.php new file mode 100644 index 00000000..b0fc1d9c --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Console/Commands/PruneUnusedImagesCommand.php @@ -0,0 +1,165 @@ +option('scheduled') && ! $this->isAutoCleanupEnabled()) { + $this->info('미사용 이미지 자동 정리가 꺼져 있어 실행하지 않았습니다. (unusedImageCleanup = false)'); + + return Command::SUCCESS; + } + + $days = $this->resolveRetentionDays(); + + if ($days < 1) { + $this->info(__('sirsoft-ckeditor5::messages.cleanup.retention_disabled')); + + return Command::SUCCESS; + } + + $limit = max(1, (int) $this->option('limit')); + $isDryRun = (bool) $this->option('dry-run'); + + $result = $this->cleanupService->pruneUnused($days, $limit, $isDryRun); + + if ($isDryRun) { + $this->renderDryRun($result, $days); + + return Command::SUCCESS; + } + + $this->info(sprintf( + '보존기간(%d일) 경과 후보 %d건 중 참조됨 %d건, 삭제 %d건, 실패 %d건.', + $days, + $result['scanned'], + $result['referenced'], + $result['deleted'], + $result['failed'], + )); + + if ($result['failed'] > 0) { + $this->warn(sprintf('%d건은 파일 삭제에 실패해 기록을 보존했습니다. 다음 회차에 재시도합니다.', $result['failed'])); + } + + Log::info('PruneUnusedImagesCommand: 미참조 에디터 이미지 정리 완료', [ + 'days' => $days, + 'limit' => $limit, + 'scanned' => $result['scanned'], + 'referenced' => $result['referenced'], + 'deleted' => $result['deleted'], + 'failed' => $result['failed'], + ]); + + return Command::SUCCESS; + } + + /** + * 자동 정리 토글을 false 폴백으로 조회합니다. + * + * @return bool 자동 정리 활성 여부 + */ + private function isAutoCleanupEnabled(): bool + { + return (bool) plugin_setting('sirsoft-ckeditor5', 'unusedImageCleanup', false); + } + + /** + * 보존기간을 해석합니다 (옵션 > 플러그인 설정 > 기본 30일). + * + * @return int 보존기간(일) + */ + private function resolveRetentionDays(): int + { + $option = $this->option('days'); + + if ($option !== null && $option !== '') { + return (int) $option; + } + + return (int) plugin_setting('sirsoft-ckeditor5', 'unusedImageRetentionDays', 30); + } + + /** + * dry-run 결과를 표로 출력합니다. + * + * @param array{scanned: int, referenced: int, deleted: int, failed: int, items: array>} $result 판정 결과 + * @param int $days 보존기간(일) + */ + private function renderDryRun(array $result, int $days): void + { + $this->info(sprintf( + '[DRY RUN] 보존기간(%d일) 경과 후보 %d건 중 참조됨 %d건, 삭제 대상 %d건.', + $days, + $result['scanned'], + $result['referenced'], + count($result['items']), + )); + + if ($result['items'] === []) { + return; + } + + $this->table( + ['ID', 'HASH', '원본명', '크기(bytes)', '업로드 시각'], + array_map(fn (array $item) => [ + $item['id'], + $item['hash'], + $item['original_name'], + $item['file_size'], + $item['created_at'], + ], $result['items']) + ); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Http/Controllers/Admin/ImageUploadAdminController.php b/plugins/_bundled/sirsoft-ckeditor5/src/Http/Controllers/Admin/ImageUploadAdminController.php new file mode 100644 index 00000000..1e51a79e --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Http/Controllers/Admin/ImageUploadAdminController.php @@ -0,0 +1,99 @@ +adminService->paginate( + $request->filters(), + $request->perPage(), + $request->pageNumber(), + ); + + return ResponseHelper::success('common.success', [ + 'data' => ImageUploadResource::collection($result['items']), + 'pagination' => $result['pagination'], + 'meta' => [ + 'scan_limited' => $result['scan_limited'], + 'scan_window' => ImageUploadAdminService::SCAN_WINDOW, + ], + ]); + } + + /** + * 업로드 이미지 1건을 삭제합니다. + * + * @param int $id 업로드 ID + * @return JsonResponse + */ + public function destroy(int $id): JsonResponse + { + $upload = $this->adminService->find($id); + + if ($upload === null) { + return ResponseHelper::notFound('messages.uploads.not_found', domain: 'sirsoft-ckeditor5'); + } + + if (! $this->adminService->delete($upload)) { + return ResponseHelper::error('messages.uploads.file_delete_failed', 500, domain: 'sirsoft-ckeditor5'); + } + + return ResponseHelper::success('messages.uploads.deleted', domain: 'sirsoft-ckeditor5'); + } + + /** + * 업로드 이미지를 일괄 삭제합니다. + * + * @param BulkDeleteUploadsRequest $request HTTP 요청 + * @return JsonResponse + */ + public function bulkDestroy(BulkDeleteUploadsRequest $request): JsonResponse + { + $result = $this->adminService->bulkDelete($request->ids()); + + if ($result['deleted'] === 0 && $result['failed'] > 0) { + return ResponseHelper::error('messages.uploads.file_delete_failed', 500, $result, domain: 'sirsoft-ckeditor5'); + } + + return ResponseHelper::success( + 'messages.uploads.bulk_deleted', + $result, + messageParams: ['deleted' => $result['deleted']], + domain: 'sirsoft-ckeditor5', + ); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Http/Requests/BulkDeleteUploadsRequest.php b/plugins/_bundled/sirsoft-ckeditor5/src/Http/Requests/BulkDeleteUploadsRequest.php new file mode 100644 index 00000000..f7122c99 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Http/Requests/BulkDeleteUploadsRequest.php @@ -0,0 +1,67 @@ +> 검증 규칙 + */ + public function rules(): array + { + return [ + 'ids' => ['required', 'array', 'min:1', 'max:'.self::MAX_IDS], + 'ids.*' => ['integer', Rule::exists(Ckeditor5ImageUpload::class, 'id')], + ]; + } + + /** + * 검증 메시지를 반환합니다. + * + * @return array 메시지 배열 + */ + public function messages(): array + { + return [ + 'ids.required' => __('sirsoft-ckeditor5::messages.uploads.ids_required'), + 'ids.min' => __('sirsoft-ckeditor5::messages.uploads.ids_required'), + 'ids.*.exists' => __('sirsoft-ckeditor5::messages.uploads.ids_invalid'), + 'ids.*.integer' => __('sirsoft-ckeditor5::messages.uploads.ids_invalid'), + ]; + } + + /** + * 삭제 대상 ID 목록을 반환합니다. + * + * @return array ID 목록 + */ + public function ids(): array + { + /** @var array $ids */ + $ids = $this->validated('ids') ?? []; + + return array_values(array_unique(array_map('intval', $ids))); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Http/Requests/IndexUploadsRequest.php b/plugins/_bundled/sirsoft-ckeditor5/src/Http/Requests/IndexUploadsRequest.php new file mode 100644 index 00000000..8e50e9c2 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Http/Requests/IndexUploadsRequest.php @@ -0,0 +1,123 @@ + + */ + public const SORTABLE = ['created_at', 'file_size', 'original_name']; + + /** + * 참조 상태 필터 허용 어휘 + * + * @var list + */ + public const REFERENCED_STATES = ['all', 'referenced', 'unreferenced']; + + /** + * 요청 권한 — 라우트 permission 미들웨어가 담당하므로 true 고정. + * + * @return bool 항상 true + */ + public function authorize(): bool + { + return true; + } + + /** + * 빈 문자열로 도착한 필터를 검증 전에 제거합니다. + * + * 화면은 값을 비울 때 `?referenced=` 처럼 빈 문자열을 보냅니다 — 그대로 두면 + * `Rule::in` 에 걸려 목록 전체가 422 가 됩니다. + */ + protected function prepareForValidation(): void + { + $cleared = []; + + foreach (['search', 'referenced', 'date_from', 'date_to', 'sort_by', 'sort_order', 'per_page', 'page'] as $key) { + if ($this->has($key) && $this->input($key) === '') { + $cleared[$key] = null; + } + } + + if ($cleared !== []) { + $this->merge($cleared); + } + } + + /** + * 검증 규칙을 반환합니다. + * + * @return array> 검증 규칙 + */ + public function rules(): array + { + return [ + 'search' => ['nullable', 'string', 'max:255'], + 'referenced' => ['nullable', 'string', Rule::in(self::REFERENCED_STATES)], + 'date_from' => ['nullable', 'date'], + 'date_to' => ['nullable', 'date'], + 'sort_by' => ['nullable', 'string', Rule::in(self::SORTABLE)], + 'sort_order' => ['nullable', 'string', Rule::in(['asc', 'desc'])], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:'.self::MAX_PER_PAGE], + ]; + } + + /** + * 목록 필터를 반환합니다. + * + * @return array 필터 배열 + */ + public function filters(): array + { + return [ + 'search' => $this->validated('search'), + 'referenced' => $this->validated('referenced') ?? 'all', + 'date_from' => $this->validated('date_from'), + 'date_to' => $this->validated('date_to'), + 'sort_by' => $this->validated('sort_by'), + 'sort_order' => $this->validated('sort_order'), + ]; + } + + /** + * 페이지 크기를 반환합니다. + * + * @return int 페이지 크기 + */ + public function perPage(): int + { + return (int) ($this->validated('per_page') ?? self::DEFAULT_PER_PAGE); + } + + /** + * 페이지 번호를 반환합니다. + * + * @return int 페이지 번호 + */ + public function pageNumber(): int + { + return (int) ($this->validated('page') ?? 1); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Http/Resources/ImageUploadResource.php b/plugins/_bundled/sirsoft-ckeditor5/src/Http/Resources/ImageUploadResource.php new file mode 100644 index 00000000..95f2dd79 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Http/Resources/ImageUploadResource.php @@ -0,0 +1,37 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'hash' => $this->hash, + 'original_name' => $this->original_name, + 'file_size' => (int) $this->file_size, + 'mime_type' => $this->mime_type, + 'uploaded_by' => $this->uploaded_by, + 'uploader_name' => $this->relationLoaded('uploader') ? $this->uploader?->name : null, + 'created_at' => $this->formatDateTimeStringForUser($this->created_at), + 'download_url' => $this->download_url, + 'referenced' => (bool) ($this->referenced ?? true), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Providers/Ckeditor5ServiceProvider.php b/plugins/_bundled/sirsoft-ckeditor5/src/Providers/Ckeditor5ServiceProvider.php index 5c656c10..4d12e8da 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/src/Providers/Ckeditor5ServiceProvider.php +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Providers/Ckeditor5ServiceProvider.php @@ -3,8 +3,12 @@ namespace Plugins\Sirsoft\Ckeditor5\Providers; use App\Extension\BasePluginServiceProvider; +use Plugins\Sirsoft\Ckeditor5\Console\Commands\PruneUnusedImagesCommand; +use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageReferenceSourceRepositoryInterface; use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface; +use Plugins\Sirsoft\Ckeditor5\Repositories\ImageReferenceSourceRepository; use Plugins\Sirsoft\Ckeditor5\Repositories\ImageUploadRepository; +use Plugins\Sirsoft\Ckeditor5\Services\ImageCleanupService; use Plugins\Sirsoft\Ckeditor5\Services\ImageServeService; use Plugins\Sirsoft\Ckeditor5\Services\ImageUploadService; @@ -20,10 +24,12 @@ class Ckeditor5ServiceProvider extends BasePluginServiceProvider protected array $repositories = [ ImageUploadRepositoryInterface::class => ImageUploadRepository::class, + ImageReferenceSourceRepositoryInterface::class => ImageReferenceSourceRepository::class, ]; protected array $storageServices = [ ImageServeService::class, + ImageCleanupService::class, ]; /** @@ -38,4 +44,18 @@ class Ckeditor5ServiceProvider extends BasePluginServiceProvider protected array $storageCategoryServices = [ ImageUploadService::class => 'images', ]; + + /** + * 플러그인 부팅 — 콘솔 실행 시 정리 커맨드를 등록합니다. + */ + public function boot(): void + { + parent::boot(); + + if ($this->app->runningInConsole()) { + $this->commands([ + PruneUnusedImagesCommand::class, + ]); + } + } } diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/Contracts/ImageReferenceSourceRepositoryInterface.php b/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/Contracts/ImageReferenceSourceRepositoryInterface.php new file mode 100644 index 00000000..5b41535e --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/Contracts/ImageReferenceSourceRepositoryInterface.php @@ -0,0 +1,32 @@ + $columns 컬럼명 목록 + * @return array 실재하는 컬럼 목록 (테이블이 없으면 빈 배열) + */ + public function resolveExistingColumns(string $table, array $columns): array; + + /** + * 지정 소스의 컬럼 중 하나라도 토큰을 포함하는 행이 있는지 확인합니다. + * + * @param string $table 테이블명 (프리픽스 제외) + * @param array $columns 검사할 컬럼 목록 + * @param array $tokens 판정 토큰 (LIKE 이스케이프는 구현이 담당) + * @return bool 하나라도 등장하면 true + */ + public function containsAnyToken(string $table, array $columns, array $tokens): bool; +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/Contracts/ImageUploadRepositoryInterface.php b/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/Contracts/ImageUploadRepositoryInterface.php index 2cb4c5e4..c55535d9 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/Contracts/ImageUploadRepositoryInterface.php +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/Contracts/ImageUploadRepositoryInterface.php @@ -2,6 +2,9 @@ namespace Plugins\Sirsoft\Ckeditor5\Repositories\Contracts; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Carbon; use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload; /** @@ -24,4 +27,68 @@ interface ImageUploadRepositoryInterface * @return Ckeditor5ImageUpload */ public function create(array $data): Ckeditor5ImageUpload; + + /** + * 기준 시각보다 오래된 업로드를 오래된 순으로 조회합니다. + * + * 정리 후보 조회 전용이라 판정·삭제에 필요한 컬럼만 읽습니다. + * + * @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상) + * @param int $limit 최대 조회 건수 + * @return Collection 업로드 목록 (created_at 오름차순) + */ + public function findOlderThan(Carbon $threshold, int $limit): Collection; + + /** + * 기준 시각보다 오래된 업로드 건수를 반환합니다. + * + * @param Carbon $threshold 기준 시각 + * @return int 건수 + */ + public function countOlderThan(Carbon $threshold): int; + + /** + * 관리 화면용 업로드 목록을 페이지네이션 조회합니다. + * + * @param array $filters 필터 (search / date_from / date_to / sort_by / sort_order) + * @param int $perPage 페이지 크기 + * @return LengthAwarePaginator 페이지네이터 + */ + public function paginateForAdmin(array $filters, int $perPage): LengthAwarePaginator; + + /** + * 참조 판정 스캔 윈도우를 조회합니다 (최신순 상한). + * + * 참조 여부는 저장 컬럼이 아니라 매 조회 시 판정하므로, 참조 상태 필터를 쓸 때는 + * 전수가 아니라 최신 N 건만 훑습니다. 상한을 넘긴 사실은 응답 메타로 알립니다. + * + * @param array $filters 필터 (search / date_from / date_to) + * @param int $limit 스캔 윈도우 상한 + * @return Collection 업로드 목록 (created_at 내림차순) + */ + public function findScanWindow(array $filters, int $limit): Collection; + + /** + * ID 목록으로 업로드를 조회합니다. + * + * @param array $ids 업로드 ID 목록 + * @return Collection 업로드 목록 + */ + public function findManyByIds(array $ids): Collection; + + /** + * ID 로 업로드 단건을 조회합니다. + * + * @param int $id 업로드 ID + * @return Ckeditor5ImageUpload|null + */ + public function findById(int $id): ?Ckeditor5ImageUpload; + + /** + * 업로드 기록을 삭제합니다. + * + * @param Ckeditor5ImageUpload $upload 업로드 기록 + * @return bool 삭제 성공 여부 + */ + public function delete(Ckeditor5ImageUpload $upload): bool; } diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/ImageReferenceSourceRepository.php b/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/ImageReferenceSourceRepository.php new file mode 100644 index 00000000..c4d01aa2 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/ImageReferenceSourceRepository.php @@ -0,0 +1,66 @@ + Schema::hasColumn($table, $column) + )); + } + + /** + * {@inheritDoc} + */ + public function containsAnyToken(string $table, array $columns, array $tokens): bool + { + if ($columns === [] || $tokens === []) { + return false; + } + + return DB::table($table) + ->where(function (Builder $query) use ($columns, $tokens) { + foreach ($columns as $column) { + foreach ($tokens as $token) { + $query->orWhere($column, 'like', '%'.$this->escapeLike($token).'%'); + } + } + }) + ->exists(); + } + + /** + * LIKE 와일드카드를 이스케이프합니다. + * + * 원본명·해시에 `%`/`_` 가 포함되면 임의 문자로 해석돼 무관한 본문이 매칭됩니다 + * (참조됨 오판 → 정리되지 않고 영구 잔존). + * + * @param string $value 원본 값 + * @return string 이스케이프된 값 + */ + private function escapeLike(string $value): string + { + return addcslashes($value, '\\%_'); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/ImageUploadRepository.php b/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/ImageUploadRepository.php index 979a42f1..23a3c5e6 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/ImageUploadRepository.php +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Repositories/ImageUploadRepository.php @@ -2,6 +2,12 @@ namespace Plugins\Sirsoft\Ckeditor5\Repositories; +use App\Repositories\Concerns\FiltersByDateRange; +use App\Repositories\Concerns\ResolvesSortSpec; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Carbon; use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload; use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface; @@ -10,6 +16,41 @@ use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterf */ class ImageUploadRepository implements ImageUploadRepositoryInterface { + use FiltersByDateRange; + use ResolvesSortSpec; + + /** + * 목록·정리 조회에서 읽는 컬럼 목록 + * + * 참조 판정(hash·file_path)·삭제(file_path·storage_disk)·화면 표시에 쓰이는 컬럼만 + * 읽습니다. 이 테이블에는 본문 성격의 넓은 컬럼이 없으나, 컬럼을 명시해 두면 이후 + * 컬럼이 늘어도 목록이 자동으로 넓어지지 않습니다. + * + * @var list + */ + private const LIST_COLUMNS = [ + 'id', + 'hash', + 'original_name', + 'file_path', + 'storage_disk', + 'file_size', + 'mime_type', + 'uploaded_by', + 'created_at', + ]; + + /** + * 관리 화면 정렬 허용 컬럼 화이트리스트 + * + * @var list + */ + private const SORTABLE_COLUMNS = [ + 'created_at', + 'file_size', + 'original_name', + ]; + public function __construct( protected Ckeditor5ImageUpload $model ) {} @@ -29,4 +70,125 @@ class ImageUploadRepository implements ImageUploadRepositoryInterface { return $this->model->create($data); } + + /** + * {@inheritDoc} + */ + public function findOlderThan(Carbon $threshold, int $limit): Collection + { + return $this->model->newQuery() + ->where('created_at', '<', $threshold) + ->orderBy('created_at') + ->orderBy('id') + ->limit($limit) + ->get(self::LIST_COLUMNS); + } + + /** + * {@inheritDoc} + */ + public function countOlderThan(Carbon $threshold): int + { + return $this->model->newQuery() + ->where('created_at', '<', $threshold) + ->count(); + } + + /** + * {@inheritDoc} + */ + public function paginateForAdmin(array $filters, int $perPage): LengthAwarePaginator + { + $sort = $this->resolveSortSpec( + $filters, + self::SORTABLE_COLUMNS, + defaultColumn: 'created_at', + defaultDirection: 'desc', + )[0]; + + $query = $this->applyFilters($this->model->newQuery(), $filters) + ->with('uploader:id,name') + ->orderBy($sort['column'], $sort['direction']); + + // 정렬 마지막에 기본키를 덧붙여 전순서를 보장한다 — 동률 구간에서 인접 페이지가 + // 같은 행을 중복 노출하거나 다른 행을 누락하는 것을 막는다. + if ($sort['column'] !== 'id') { + $query->orderBy('id', $sort['direction']); + } + + return $query->paginate($perPage, self::LIST_COLUMNS); + } + + /** + * {@inheritDoc} + */ + public function findScanWindow(array $filters, int $limit): Collection + { + return $this->applyFilters($this->model->newQuery(), $filters) + ->with('uploader:id,name') + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit($limit) + ->get(self::LIST_COLUMNS); + } + + /** + * {@inheritDoc} + */ + public function findManyByIds(array $ids): Collection + { + if ($ids === []) { + return new Collection; + } + + return $this->model->newQuery() + ->whereIn('id', $ids) + ->get(self::LIST_COLUMNS); + } + + /** + * {@inheritDoc} + */ + public function findById(int $id): ?Ckeditor5ImageUpload + { + return $this->model->newQuery()->find($id, self::LIST_COLUMNS); + } + + /** + * {@inheritDoc} + */ + public function delete(Ckeditor5ImageUpload $upload): bool + { + return (bool) $upload->delete(); + } + + /** + * 목록 필터를 쿼리에 적용합니다. + * + * @param Builder $query 대상 쿼리 + * @param array $filters 필터 배열 + * @return Builder 필터가 적용된 쿼리 + */ + private function applyFilters(Builder $query, array $filters): Builder + { + $search = trim((string) ($filters['search'] ?? '')); + + if ($search !== '') { + $escaped = addcslashes($search, '\\%_'); + + $query->where(function (Builder $inner) use ($escaped) { + $inner->where('original_name', 'like', '%'.$escaped.'%') + ->orWhere('hash', 'like', '%'.$escaped.'%'); + }); + } + + $this->applyDateRangeFilter( + $query, + 'created_at', + $filters['date_from'] ?? null, + $filters['date_to'] ?? null, + ); + + return $query; + } } diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageCleanupService.php b/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageCleanupService.php new file mode 100644 index 00000000..e4ce62ec --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageCleanupService.php @@ -0,0 +1,164 @@ +>} + */ + public function pruneUnused(int $days, int $limit, bool $dryRun = false): array + { + $threshold = Carbon::now()->subDays($days); + $candidates = $this->repository->findOlderThan($threshold, $limit); + + $result = [ + 'scanned' => $candidates->count(), + 'referenced' => 0, + 'deleted' => 0, + 'failed' => 0, + 'items' => [], + ]; + + foreach ($candidates as $upload) { + if ($this->scanner->isReferenced($upload)) { + $result['referenced']++; + + continue; + } + + if ($dryRun) { + $result['items'][] = $this->describe($upload, 'would_delete'); + + continue; + } + + if ($this->deleteUpload($upload)) { + $result['deleted']++; + $result['items'][] = $this->describe($upload, 'deleted'); + + continue; + } + + $result['failed']++; + $result['items'][] = $this->describe($upload, 'failed'); + } + + return $result; + } + + /** + * 업로드 이미지 1건의 파일과 레코드를 삭제합니다. + * + * @param Ckeditor5ImageUpload $upload 업로드 기록 + * @return bool 레코드까지 삭제되면 true, 파일 삭제 실패로 보류되면 false + */ + public function deleteUpload(Ckeditor5ImageUpload $upload): bool + { + [$category, $relativePath] = $this->splitFilePath((string) ($upload->file_path ?? '')); + + if ($category === null || $relativePath === null) { + // 경로를 분해할 수 없는 행은 가리키는 파일이 없다 — 레코드만 정리한다. + Log::warning('CKEditor5 업로드 경로를 분해할 수 없어 레코드만 삭제합니다.', [ + 'id' => $upload->id, + 'file_path' => $upload->file_path, + ]); + + return $this->repository->delete($upload); + } + + $disk = (string) ($upload->storage_disk ?? ''); + $storage = $disk !== '' ? $this->storage->withDisk($disk) : $this->storage; + + if (! $storage->exists($category, $relativePath)) { + // 파일이 이미 없는 고아 레코드 — 재시도 루프를 만들지 않도록 레코드를 정리한다. + return $this->repository->delete($upload); + } + + if (! $storage->delete($category, $relativePath)) { + Log::warning('CKEditor5 업로드 파일 삭제에 실패해 레코드를 보존합니다.', [ + 'id' => $upload->id, + 'disk' => $disk, + 'file_path' => $upload->file_path, + ]); + + return false; + } + + return $this->repository->delete($upload); + } + + /** + * 파일 경로를 카테고리와 상대 경로로 분해합니다. + * + * `file_path` 는 카테고리 prefix 를 포함합니다(`images/Y/m/d/{uuid}.{ext}`) — + * 모델의 `resolveDirectUrl()` 과 동일한 분해 규칙을 씁니다. + * + * @param string $filePath 저장 파일 경로 + * @return array{0: string|null, 1: string|null} [카테고리, 상대 경로] + */ + private function splitFilePath(string $filePath): array + { + if ($filePath === '' || ! str_contains($filePath, '/')) { + return [null, null]; + } + + [$category, $relativePath] = explode('/', $filePath, 2); + + if ($category === '' || $relativePath === '') { + return [null, null]; + } + + return [$category, $relativePath]; + } + + /** + * 커맨드 출력·로그용 항목 요약을 만듭니다. + * + * @param Ckeditor5ImageUpload $upload 업로드 기록 + * @param string $status 처리 상태 (would_delete|deleted|failed) + * @return array 요약 배열 + */ + private function describe(Ckeditor5ImageUpload $upload, string $status): array + { + return [ + 'id' => (int) $upload->id, + 'hash' => (string) $upload->hash, + 'original_name' => (string) $upload->original_name, + 'file_size' => (int) $upload->file_size, + 'created_at' => $upload->created_at?->toDateTimeString(), + 'status' => $status, + ]; + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageReferenceScanService.php b/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageReferenceScanService.php new file mode 100644 index 00000000..e9b7f262 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageReferenceScanService.php @@ -0,0 +1,217 @@ +}> + */ + private const CORE_SOURCES = [ + ['table' => 'notification_templates', 'columns' => ['body']], + ['table' => 'mail_templates', 'columns' => ['body']], + ['table' => 'identity_message_templates', 'columns' => ['body']], + ['table' => 'template_layouts', 'columns' => ['content']], + ['table' => 'template_layout_extensions', 'columns' => ['content']], + ['table' => 'template_layout_versions', 'columns' => ['content']], + ]; + + /** + * 요청 수명 memoize 된 검증 완료 소스 목록 + * + * @var list}>|null + */ + private ?array $resolvedSources = null; + + /** + * @param ImageReferenceSourceRepositoryInterface $sourceRepository 참조 소스 조회 리포지토리 + */ + public function __construct( + protected ImageReferenceSourceRepositoryInterface $sourceRepository + ) {} + + /** + * 참조 스캔 대상 소스 목록을 반환합니다. + * + * 확장이 훅으로 추가한 선언은 테이블·컬럼 실재 여부를 검증하고, 불량 선언은 + * 경고만 남기고 건너뜁니다 — 한 확장의 잘못된 선언이 판정 전체를 멈추지 않게 합니다. + * + * @return list}> 검증된 소스 목록 + */ + public function getReferenceSources(): array + { + if ($this->resolvedSources !== null) { + return $this->resolvedSources; + } + + $declared = HookManager::applyFilters(self::FILTER_REFERENCE_SOURCES, self::CORE_SOURCES); + + if (! is_array($declared)) { + Log::warning('CKEditor5 참조 소스 훅이 배열이 아닌 값을 반환해 코어 기본값을 사용합니다.'); + + $declared = self::CORE_SOURCES; + } + + return $this->resolvedSources = $this->validateSources($declared); + } + + /** + * 업로드 이미지가 어떤 콘텐츠에서든 참조되는지 판정합니다. + * + * @param Ckeditor5ImageUpload $upload 업로드 기록 + * @return bool 참조되면 true + */ + public function isReferenced(Ckeditor5ImageUpload $upload): bool + { + $tokens = $this->buildTokens($upload); + + if ($tokens === []) { + // 판정 토큰을 만들 수 없는 행은 안전 방향(참조됨)으로 본다. + return true; + } + + foreach ($this->getReferenceSources() as $source) { + if ($this->sourceContains($source, $tokens)) { + return true; + } + } + + return false; + } + + /** + * 업로드 목록의 참조 여부를 일괄 판정합니다. + * + * @param Collection|iterable $uploads 업로드 목록 + * @return array [업로드 ID => 참조 여부] + */ + public function mapReferenced(iterable $uploads): array + { + $map = []; + + foreach ($uploads as $upload) { + $map[(int) $upload->id] = $this->isReferenced($upload); + } + + return $map; + } + + /** + * 판정 토큰(해시 + 저장 파일명)을 만듭니다. + * + * @param Ckeditor5ImageUpload $upload 업로드 기록 + * @return list 토큰 목록 + */ + private function buildTokens(Ckeditor5ImageUpload $upload): array + { + $tokens = []; + + $hash = trim((string) ($upload->hash ?? '')); + if ($hash !== '') { + $tokens[] = $hash; + } + + $filePath = trim((string) ($upload->file_path ?? '')); + if ($filePath !== '') { + $basename = basename($filePath); + + if ($basename !== '' && ! in_array($basename, $tokens, true)) { + $tokens[] = $basename; + } + } + + return $tokens; + } + + /** + * 한 소스에 토큰이 하나라도 등장하는지 확인합니다. + * + * @param array{table: string, columns: list} $source 참조 소스 + * @param list $tokens 판정 토큰 + * @return bool 등장하면 true + */ + private function sourceContains(array $source, array $tokens): bool + { + return $this->sourceRepository->containsAnyToken($source['table'], $source['columns'], $tokens); + } + + /** + * 훅으로 수집한 소스 선언을 검증합니다. + * + * @param array $declared 선언 목록 + * @return list}> 검증 통과 소스 + */ + private function validateSources(array $declared): array + { + $valid = []; + + foreach ($declared as $source) { + if (! is_array($source) || ! isset($source['table'], $source['columns'])) { + Log::warning('CKEditor5 참조 소스 선언 형식이 올바르지 않아 건너뜁니다.', [ + 'source' => $source, + ]); + + continue; + } + + $table = (string) $source['table']; + $columns = is_array($source['columns']) ? array_values(array_map('strval', $source['columns'])) : []; + + if ($table === '' || $columns === []) { + Log::warning('CKEditor5 참조 소스 선언에 테이블 또는 컬럼이 비어 있어 건너뜁니다.', [ + 'table' => $table, + ]); + + continue; + } + + $existingColumns = $this->sourceRepository->resolveExistingColumns($table, $columns); + + if ($existingColumns === []) { + // 테이블 자체가 없거나(확장 미설치) 선언한 컬럼이 하나도 없는 경우다. + // 한 확장의 잘못된 선언이 판정 전체를 멈추지 않도록 경고만 남기고 건너뛴다. + Log::warning('CKEditor5 참조 소스에 존재하는 테이블/컬럼이 없어 건너뜁니다.', [ + 'table' => $table, + 'columns' => $columns, + ]); + + continue; + } + + $valid[] = ['table' => $table, 'columns' => $existingColumns]; + } + + return $valid; + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageUploadAdminService.php b/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageUploadAdminService.php new file mode 100644 index 00000000..9caf722f --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageUploadAdminService.php @@ -0,0 +1,171 @@ +, pagination: array, scan_limited: bool} + */ + public function paginate(array $filters, int $perPage, int $page = 1): array + { + $referencedFilter = (string) ($filters['referenced'] ?? 'all'); + + if ($referencedFilter === 'referenced' || $referencedFilter === 'unreferenced') { + return $this->paginateByReferenceState($filters, $perPage, $page, $referencedFilter === 'referenced'); + } + + $paginator = $this->repository->paginateForAdmin($filters, $perPage); + + /** @var Collection $items */ + $items = collect($paginator->items()); + $this->attachReferenceState($items); + + return [ + 'items' => $items, + 'pagination' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'from' => $paginator->firstItem(), + 'to' => $paginator->lastItem(), + ], + 'scan_limited' => false, + ]; + } + + /** + * 업로드 이미지 1건을 삭제합니다. + * + * @param Ckeditor5ImageUpload $upload 업로드 기록 + * @return bool 삭제 성공 여부 (파일 삭제 실패 시 false) + */ + public function delete(Ckeditor5ImageUpload $upload): bool + { + return $this->cleanupService->deleteUpload($upload); + } + + /** + * 업로드 이미지를 일괄 삭제합니다. + * + * @param array $ids 업로드 ID 목록 + * @return array{deleted: int, failed: int} 처리 결과 + */ + public function bulkDelete(array $ids): array + { + $uploads = $this->repository->findManyByIds($ids); + + $deleted = 0; + $failed = 0; + + foreach ($uploads as $upload) { + $this->cleanupService->deleteUpload($upload) ? $deleted++ : $failed++; + } + + return ['deleted' => $deleted, 'failed' => $failed]; + } + + /** + * ID 로 업로드를 조회합니다. + * + * @param int $id 업로드 ID + * @return Ckeditor5ImageUpload|null + */ + public function find(int $id): ?Ckeditor5ImageUpload + { + return $this->repository->findById($id); + } + + /** + * 참조 상태 필터가 걸린 목록을 스캔 윈도우 안에서 페이지네이션합니다. + * + * @param array $filters 필터 배열 + * @param int $perPage 페이지 크기 + * @param int $page 페이지 번호 + * @param bool $wantReferenced true 면 참조됨, false 면 미참조만 + * @return array{items: Collection, pagination: array, scan_limited: bool} + */ + private function paginateByReferenceState(array $filters, int $perPage, int $page, bool $wantReferenced): array + { + $window = $this->repository->findScanWindow($filters, self::SCAN_WINDOW); + $scanLimited = $window->count() >= self::SCAN_WINDOW; + + $this->attachReferenceState($window); + + $matched = $window + ->filter(fn (Ckeditor5ImageUpload $upload) => (bool) $upload->referenced === $wantReferenced) + ->values(); + + $total = $matched->count(); + $lastPage = max(1, (int) ceil($total / max(1, $perPage))); + $currentPage = max(1, min($page, $lastPage)); + $items = $matched->slice(($currentPage - 1) * $perPage, $perPage)->values(); + + return [ + 'items' => $items, + 'pagination' => [ + 'current_page' => $currentPage, + 'last_page' => $lastPage, + 'per_page' => $perPage, + 'total' => $total, + 'from' => $items->isEmpty() ? null : ($currentPage - 1) * $perPage + 1, + 'to' => $items->isEmpty() ? null : ($currentPage - 1) * $perPage + $items->count(), + ], + 'scan_limited' => $scanLimited, + ]; + } + + /** + * 업로드 목록에 참조 여부를 임시 속성으로 부착합니다. + * + * 저장 컬럼이 아니라 응답 전용 값이라 모델에 setAttribute 로만 실어 보냅니다 + * (저장 호출 없음). + * + * @param Collection $uploads 업로드 목록 + */ + private function attachReferenceState(Collection $uploads): void + { + $map = $this->scanner->mapReferenced($uploads); + + foreach ($uploads as $upload) { + $upload->setAttribute('referenced', $map[(int) $upload->id] ?? true); + } + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/routes/api.php b/plugins/_bundled/sirsoft-ckeditor5/src/routes/api.php index 436741a9..8fea4839 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/src/routes/api.php +++ b/plugins/_bundled/sirsoft-ckeditor5/src/routes/api.php @@ -1,6 +1,7 @@ where('hash', '[a-f0-9]{12}') ->name('api.sirsoft-ckeditor5.images.serve'); + +/* +| 업로드 이미지 관리 (관리자) +| +| 조회와 삭제를 별도 권한으로 분리한다 — 삭제는 파일을 실제로 파기하기 때문이다. +*/ +Route::prefix('admin')->name('admin.')->middleware('auth:sanctum')->group(function () { + Route::get('uploads', [ImageUploadAdminController::class, 'index']) + ->middleware('permission:admin,sirsoft-ckeditor5.uploads.read') + ->name('uploads.index'); + + Route::post('uploads/bulk-delete', [ImageUploadAdminController::class, 'bulkDestroy']) + ->middleware('permission:admin,sirsoft-ckeditor5.uploads.delete') + ->name('uploads.bulk-delete'); + + Route::delete('uploads/{id}', [ImageUploadAdminController::class, 'destroy']) + ->whereNumber('id') + ->middleware('permission:admin,sirsoft-ckeditor5.uploads.delete') + ->name('uploads.destroy'); +}); diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Console/PruneUnusedImagesCommandTest.php b/plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Console/PruneUnusedImagesCommandTest.php new file mode 100644 index 00000000..52cdd67e --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Console/PruneUnusedImagesCommandTest.php @@ -0,0 +1,225 @@ + $values 설정 키 => 값 + */ + private function fakePluginSettings(array $values): void + { + $service = Mockery::mock(PluginSettingsService::class); + + $service->shouldReceive('get') + ->andReturnUsing(function (string $identifier, ?string $key = null, mixed $default = null) use ($values) { + return array_key_exists($key, $values) ? $values[$key] : $default; + }); + + $this->app->instance(PluginSettingsService::class, $service); + } + + /** + * 미참조 업로드 1건(파일 포함)을 만듭니다. + * + * @param int $ageDays 업로드 경과 일수 + * @return Ckeditor5ImageUpload 생성된 기록 + */ + private function makeStaleUpload(int $ageDays = 40): Ckeditor5ImageUpload + { + $filename = uniqid('cmd-').'.png'; + (new PluginStorageDriver('sirsoft-ckeditor5', 'plugins'))->put('images', "2026/08/14/{$filename}", 'bytes'); + + $upload = Ckeditor5ImageUpload::create([ + 'original_name' => $filename, + 'file_path' => "images/2026/08/14/{$filename}", + 'storage_disk' => 'plugins', + 'file_size' => 5, + 'mime_type' => 'image/png', + ]); + + $upload->forceFill(['created_at' => now()->subDays($ageDays)])->saveQuietly(); + + return $upload->refresh(); + } + + /** + * 설정을 만지지 않은 사이트(설정 키 부재) 에서 스케줄 호출은 아무것도 지우지 않아야 한다. + * + * @effects schedule_gate_off_by_default + */ + #[Test] + public function scheduled_run_deletes_nothing_when_the_setting_was_never_touched(): void + { + $upload = $this->makeStaleUpload(); + + $this->artisan('sirsoft-ckeditor5:prune-unused-images --scheduled') + ->assertSuccessful(); + + $this->assertDatabaseHas('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @effects schedule_gate_off_explicit + */ + #[Test] + public function scheduled_run_stops_early_when_the_toggle_is_off(): void + { + $this->fakePluginSettings(['unusedImageCleanup' => false]); + $upload = $this->makeStaleUpload(); + + $this->artisan('sirsoft-ckeditor5:prune-unused-images --scheduled') + ->assertSuccessful(); + + $this->assertDatabaseHas('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @effects schedule_gate_on + */ + #[Test] + public function scheduled_run_prunes_when_the_toggle_is_on(): void + { + $this->fakePluginSettings([ + 'unusedImageCleanup' => true, + 'unusedImageRetentionDays' => 30, + ]); + $upload = $this->makeStaleUpload(); + + $this->artisan('sirsoft-ckeditor5:prune-unused-images --scheduled') + ->assertSuccessful(); + + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @effects retention_disabled_guard + */ + #[Test] + public function retention_below_one_day_performs_no_cleanup(): void + { + $upload = $this->makeStaleUpload(); + + $this->artisan('sirsoft-ckeditor5:prune-unused-images --days=0') + ->assertSuccessful(); + + $this->assertDatabaseHas('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @effects days_option_overrides_setting + */ + #[Test] + public function days_option_overrides_the_configured_retention(): void + { + $this->fakePluginSettings(['unusedImageRetentionDays' => 365]); + $upload = $this->makeStaleUpload(ageDays: 40); + + $this->artisan('sirsoft-ckeditor5:prune-unused-images --days=7') + ->assertSuccessful(); + + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @effects manual_run_dry_run_no_delete + */ + #[Test] + public function dry_run_lists_targets_without_deleting(): void + { + $upload = $this->makeStaleUpload(); + + $this->artisan('sirsoft-ckeditor5:prune-unused-images --dry-run') + ->expectsOutputToContain('[DRY RUN]') + ->assertSuccessful(); + + $this->assertDatabaseHas('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * 수동 실행(스케줄 아님)은 토글과 무관하게 운영자 의사이므로 그대로 수행한다. + * + * @effects manual_run_ignores_toggle + */ + #[Test] + public function manual_run_prunes_regardless_of_the_schedule_toggle(): void + { + $this->fakePluginSettings(['unusedImageCleanup' => false, 'unusedImageRetentionDays' => 30]); + $upload = $this->makeStaleUpload(); + + $this->artisan('sirsoft-ckeditor5:prune-unused-images') + ->assertSuccessful(); + + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * 실행 결과는 로그에 남긴다. + * + * 스케줄 실행은 아무도 보고 있지 않으므로, 무엇이 몇 건 지워졌는지가 로그에 남지 않으면 + * 사후에 확인할 방법이 없다. + * + * @effects prune_logs_run_summary + */ + #[Test] + public function a_real_run_records_its_summary_in_the_log(): void + { + $this->fakePluginSettings(['unusedImageCleanup' => true, 'unusedImageRetentionDays' => 30]); + $this->makeStaleUpload(); + + Log::shouldReceive('info') + ->once() + ->with('PruneUnusedImagesCommand: 미참조 에디터 이미지 정리 완료', Mockery::on(function (array $context): bool { + return ($context['deleted'] ?? null) === 1 + && ($context['failed'] ?? null) === 0 + && ($context['days'] ?? null) === 30; + })); + + Log::shouldReceive('warning')->zeroOrMoreTimes(); + + $this->artisan('sirsoft-ckeditor5:prune-unused-images --scheduled') + ->assertSuccessful(); + } + + /** + * dry-run 은 아무것도 지우지 않으므로 완료 로그도 남기지 않는다. + * + * @effects prune_dry_run_no_delete + */ + #[Test] + public function a_dry_run_does_not_record_a_completion_log(): void + { + $this->fakePluginSettings(['unusedImageRetentionDays' => 30]); + $this->makeStaleUpload(); + + Log::shouldReceive('info') + ->with('PruneUnusedImagesCommand: 미참조 에디터 이미지 정리 완료', Mockery::any()) + ->never(); + + Log::shouldReceive('info')->zeroOrMoreTimes(); + Log::shouldReceive('warning')->zeroOrMoreTimes(); + + $this->artisan('sirsoft-ckeditor5:prune-unused-images --dry-run') + ->assertSuccessful(); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Http/Controllers/Admin/ImageUploadAdminControllerTest.php b/plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Http/Controllers/Admin/ImageUploadAdminControllerTest.php new file mode 100644 index 00000000..4bcbdc2c --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Http/Controllers/Admin/ImageUploadAdminControllerTest.php @@ -0,0 +1,335 @@ + $actions 부여할 액션 (read|delete) + * @return User 생성된 사용자 + */ + private function userWithUploadPermissions(array $actions): User + { + $role = Role::firstOrCreate( + ['identifier' => 'ckeditor5-uploads-test-'.implode('-', $actions ?: ['none'])], + ['name' => ['ko' => '테스트 역할', 'en' => 'Test Role'], 'description' => ['ko' => '', 'en' => '']] + ); + + $adminAccess = Permission::firstOrCreate( + ['identifier' => 'admin.access'], + ['name' => ['ko' => '관리자 접근', 'en' => 'Admin Access'], 'type' => PermissionType::Admin] + ); + + $permissionIds = [$adminAccess->id]; + + foreach ($actions as $action) { + $permissionIds[] = Permission::firstOrCreate( + ['identifier' => "sirsoft-ckeditor5.uploads.{$action}"], + ['name' => ['ko' => $action, 'en' => $action], 'type' => PermissionType::Admin] + )->id; + } + + $role->permissions()->syncWithoutDetaching($permissionIds); + + $user = User::factory()->create(); + $user->roles()->attach($role->id); + + return $user; + } + + /** + * 파일과 기록을 함께 만듭니다. + * + * @param string|null $hash 이미지 해시 (null 이면 자동 생성) + * @return Ckeditor5ImageUpload 생성된 기록 + */ + private function makeUpload(?string $hash = null): Ckeditor5ImageUpload + { + $filename = uniqid('admin-').'.png'; + (new PluginStorageDriver('sirsoft-ckeditor5', 'plugins'))->put('images', "2026/08/14/{$filename}", 'bytes'); + + return Ckeditor5ImageUpload::create(array_filter([ + 'hash' => $hash, + 'original_name' => $filename, + 'file_path' => "images/2026/08/14/{$filename}", + 'storage_disk' => 'plugins', + 'file_size' => 5, + 'mime_type' => 'image/png', + ])); + } + + /** + * @effects admin_index_lists_uploads + */ + #[Test] + public function index_returns_uploads_with_reference_state_and_pagination(): void + { + $upload = $this->makeUpload('aaaaaaaaaaaa'); + + $response = $this->actingAs($this->userWithUploadPermissions(['read'])) + ->getJson(self::INDEX_URL); + + $response->assertOk() + ->assertJsonPath('data.pagination.total', 1) + ->assertJsonPath('data.meta.scan_limited', false) + ->assertJsonPath('data.data.0.id', $upload->id) + ->assertJsonPath('data.data.0.referenced', false); + } + + /** + * 목록 응답은 화면이 실제로 그리는 필드만 싣는다. + * + * 저장 경로(`file_path`)·디스크(`storage_disk`)는 화면이 쓰지 않는 내부 값이라 노출하지 + * 않는다. 조회도 같은 폭이어야 프루닝이 의미를 갖는다. + * + * @effects admin_index_column_pruning + */ + #[Test] + public function index_exposes_only_the_declared_columns(): void + { + $this->makeUpload('a1a1a1a1a1a1'); + + $response = $this->actingAs($this->userWithUploadPermissions(['read'])) + ->getJson(self::INDEX_URL); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + 'data' => [ + ['id', 'hash', 'original_name', 'file_size', 'mime_type', 'created_at', 'download_url', 'referenced'], + ], + ], + ]); + + $row = $response->json('data.data.0'); + + $this->assertArrayNotHasKey('file_path', $row); + $this->assertArrayNotHasKey('storage_disk', $row); + } + + /** + * @effects admin_index_referenced_badge + */ + #[Test] + public function referenced_upload_is_reported_as_referenced(): void + { + $upload = $this->makeUpload('bbbbbbbbbbbb'); + + DB::table('mail_templates')->insert([ + 'type' => 'admin-list-test', + 'subject' => json_encode(['ko' => '목록 테스트']), + 'body' => '', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->actingAs($this->userWithUploadPermissions(['read'])) + ->getJson(self::INDEX_URL) + ->assertOk() + ->assertJsonPath('data.data.0.id', $upload->id) + ->assertJsonPath('data.data.0.referenced', true); + } + + /** + * @effects admin_index_unreferenced_filter + */ + #[Test] + public function unreferenced_filter_returns_only_unreferenced_rows(): void + { + $this->makeUpload('cccccccccccc'); + $referenced = $this->makeUpload('dddddddddddd'); + + DB::table('mail_templates')->insert([ + 'type' => 'admin-filter-test', + 'subject' => json_encode(['ko' => '필터 테스트']), + 'body' => '', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->actingAs($this->userWithUploadPermissions(['read'])) + ->getJson(self::INDEX_URL.'?referenced=unreferenced'); + + $response->assertOk()->assertJsonPath('data.pagination.total', 1); + $this->assertSame('cccccccccccc', $response->json('data.data.0.hash')); + $this->assertNotSame($referenced->id, $response->json('data.data.0.id')); + } + + /** + * 참조 필터는 최신 N건만 훑는다. 그 상한에 걸리면 목록이 전수가 아니라는 사실을 + * 응답이 스스로 알려야 한다. + * + * `scan_limited` 가 false 로 굳으면 화면은 "최근 500건 기준" 안내를 띄우지 않고, + * 운영자는 윈도우 밖 이미지를 "이미 정리된 것" 으로 읽는다 — 오류도 경고도 남지 않는 + * 조용한 오해라 응답 메타가 유일한 통로다. + * + * @effects admin_index_scan_window_limited + */ + #[Test] + public function reference_filter_reports_scan_limited_when_the_window_is_full(): void + { + $rows = []; + + for ($i = 0; $i < ImageUploadAdminService::SCAN_WINDOW; $i++) { + $filename = sprintf('window-%04d.png', $i); + + $rows[] = [ + 'hash' => sprintf('%012d', $i), + 'original_name' => $filename, + 'file_path' => "images/2026/08/14/{$filename}", + 'storage_disk' => 'plugins', + 'file_size' => 5, + 'mime_type' => 'image/png', + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + // 파일 실체는 목록 판정에 관여하지 않는다 — 윈도우 상한만 재현하면 되므로 일괄 삽입한다. + DB::table((new Ckeditor5ImageUpload)->getTable())->insert($rows); + + $this->actingAs($this->userWithUploadPermissions(['read'])) + ->getJson(self::INDEX_URL.'?referenced=unreferenced&per_page=1') + ->assertOk() + ->assertJsonPath('data.meta.scan_limited', true) + ->assertJsonPath('data.meta.scan_window', ImageUploadAdminService::SCAN_WINDOW); + } + + /** + * @effects admin_index_rejects_unknown_sort + */ + #[Test] + public function unknown_sort_column_is_rejected(): void + { + $this->actingAs($this->userWithUploadPermissions(['read'])) + ->getJson(self::INDEX_URL.'?sort_by=file_path') + ->assertStatus(422); + } + + /** + * @effects admin_index_requires_read_permission + */ + #[Test] + public function index_is_forbidden_without_the_read_permission(): void + { + $this->actingAs($this->userWithUploadPermissions([])) + ->getJson(self::INDEX_URL) + ->assertStatus(403); + } + + /** + * @effects admin_index_requires_authentication + */ + #[Test] + public function index_is_unauthorized_for_guests(): void + { + $this->getJson(self::INDEX_URL)->assertStatus(401); + } + + /** + * @effects admin_destroy_removes_file_and_record + */ + #[Test] + public function destroy_removes_both_the_file_and_the_record(): void + { + $upload = $this->makeUpload(); + $relativePath = substr($upload->file_path, strlen('images/')); + + $this->actingAs($this->userWithUploadPermissions(['read', 'delete'])) + ->deleteJson(self::INDEX_URL.'/'.$upload->id) + ->assertOk(); + + $this->assertFalse( + (new PluginStorageDriver('sirsoft-ckeditor5', 'plugins'))->exists('images', $relativePath) + ); + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @effects admin_destroy_not_found + */ + #[Test] + public function destroy_returns_not_found_for_a_missing_id(): void + { + $this->actingAs($this->userWithUploadPermissions(['read', 'delete'])) + ->deleteJson(self::INDEX_URL.'/999999') + ->assertStatus(404); + } + + /** + * @effects admin_destroy_requires_delete_permission + */ + #[Test] + public function destroy_is_forbidden_with_read_permission_only(): void + { + $upload = $this->makeUpload(); + + $this->actingAs($this->userWithUploadPermissions(['read'])) + ->deleteJson(self::INDEX_URL.'/'.$upload->id) + ->assertStatus(403); + + $this->assertDatabaseHas('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @effects admin_bulk_delete_removes_selected + */ + #[Test] + public function bulk_delete_removes_every_selected_upload(): void + { + $first = $this->makeUpload(); + $second = $this->makeUpload(); + + $this->actingAs($this->userWithUploadPermissions(['read', 'delete'])) + ->postJson(self::INDEX_URL.'/bulk-delete', ['ids' => [$first->id, $second->id]]) + ->assertOk() + ->assertJsonPath('data.deleted', 2); + + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $first->id]); + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $second->id]); + } + + /** + * @effects admin_bulk_delete_rejects_empty + */ + #[Test] + public function bulk_delete_rejects_an_empty_selection(): void + { + $this->actingAs($this->userWithUploadPermissions(['read', 'delete'])) + ->postJson(self::INDEX_URL.'/bulk-delete', ['ids' => []]) + ->assertStatus(422) + ->assertJsonValidationErrors('ids'); + } + + /** + * @effects admin_bulk_delete_rejects_unknown_id + */ + #[Test] + public function bulk_delete_rejects_ids_that_do_not_exist(): void + { + $this->actingAs($this->userWithUploadPermissions(['read', 'delete'])) + ->postJson(self::INDEX_URL.'/bulk-delete', ['ids' => [999999]]) + ->assertStatus(422) + ->assertJsonValidationErrors('ids.0'); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/fixtures/ckeditor5-auth.ts b/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/fixtures/ckeditor5-auth.ts new file mode 100644 index 00000000..f80330de --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/fixtures/ckeditor5-auth.ts @@ -0,0 +1,34 @@ +/** + * CKEditor5 플러그인 권한 fixture. + * + * 코어 `tests/Playwright/fixtures/auth.ts` 의 헬퍼를 재사용하되, 플러그인 권한 토큰 fixture 를 + * 자체적으로 정의한다. 권한 식별자는 임의 string 이므로 코어 PlaywrightIssueToken 커맨드가 + * 그대로 동작한다 (Permission::firstOrCreate 가 자동 생성). + * + * 업로드 관리 화면은 조회와 삭제 권한이 분리돼 있으므로 두 토큰을 따로 발급해 + * 권한 경계(조회만 가진 계정의 삭제 403)를 실제로 밟는다. + */ +import { test as base } from '@playwright/test'; +// 6단계 상위 = 코어 루트의 fixtures/auth.ts +import { issueToken, issueScopedToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth'; + +type Ckeditor5AuthFixtures = { + /** 업로드 조회 + 삭제 권한 보유 토큰 */ + uploadsManageToken: string; + /** 업로드 조회만 가능한 토큰 (삭제 403 경계 검증용) */ + uploadsReadOnlyToken: string; +}; + +export const test = base.extend({ + uploadsManageToken: async ({}, use) => { + await use(issueToken('sirsoft-ckeditor5.uploads.read', 'sirsoft-ckeditor5.uploads.delete')); + }, + // 권한 경계를 실제로 밟으려면 admin 역할이 없어야 한다 — `issueToken` 은 admin 역할을 함께 + // 부여하고 admin 은 전체 권한을 보유하므로, 그 토큰으로는 삭제가 통과해 403 대신 404 가 온다. + uploadsReadOnlyToken: async ({}, use) => { + await use(issueScopedToken('admin.access', 'sirsoft-ckeditor5.uploads.read')); + }, +}); + +export { authenticatePage }; +export { expect } from '@playwright/test'; diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/playwright.config.ts b/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/playwright.config.ts new file mode 100644 index 00000000..788b651c --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/playwright.config.ts @@ -0,0 +1,90 @@ +/** + * CKEditor5 플러그인 Playwright E2E 설정. + * + * 코어 `playwright.config.ts` 와 동일한 base URL 해석 우선순위를 따른다 — 플러그인도 활성 호스트가 + * 가변(개발자/CI/운영 환경별로 다른 도메인)이므로 하드코딩 회피. + * + * Base URL 해석: + * 1. PLAYWRIGHT_BASE_URL 환경변수 (CI/명시적 오버라이드) + * 2. .env (코어 루트) 의 APP_URL — 단 localhost 류는 fallback 부적합 + * 3. 그 외 — 명시 에러 + * + * 실행 예시: + * PowerShell — $env:PLAYWRIGHT_BASE_URL='https://g7.dev'; npm run test:e2e + * Bash — PLAYWRIGHT_BASE_URL=https://g7.dev npm run test:e2e + * + * 플러그인은 코어 fixture 의 `issueToken` / `authenticatePage` 헬퍼를 재사용 — 권한 식별자는 + * `sirsoft-ckeditor5.*` 등 임의 string. + */ +import { defineConfig, devices } from '@playwright/test'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ESM 환경(package.json "type": "module")에서는 __dirname 이 정의되지 않으므로 +// import.meta.url 로 재구성한다. +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로). + * + * 확장 config 는 확장 디렉토리에서 실행되지만, 산출물을 그 안에 쓰면 Windows 에서 + * `plugin:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다. + * 산출물은 코어 루트 아래로 모아 update 경로와 분리한다 (.gitignore 가 이미 덮는 위치). + */ +const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../'); + +/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */ +const ARTIFACT_SLUG = 'plugins/sirsoft-ckeditor5'; + +function readEnvFile(filePath: string, key: string): string | null { + if (!existsSync(filePath)) return null; + const content = readFileSync(filePath, { encoding: 'utf-8' }); + const pattern = new RegExp(`^${key}=(.*)$`, 'm'); + const match = content.match(pattern); + if (!match) return null; + let value = match[1].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + return value || null; +} + +function resolveBaseUrl(): string { + if (process.env.PLAYWRIGHT_BASE_URL) { + return process.env.PLAYWRIGHT_BASE_URL; + } + const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL'); + if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) { + return appUrl; + } + throw new Error( + 'CKEditor5 플러그인 E2E base URL 미설정. PLAYWRIGHT_BASE_URL 환경변수를 지정하거나 코어 .env 의 APP_URL 을 활성 호스트로 설정하세요.' + ); +} + +export default defineConfig({ + testDir: './specs', + outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG), + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }], + ['list'], + ], + use: { + baseURL: resolveBaseUrl(), + // spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다. + // 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증 + // 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이 + // 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고 + // 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다. + locale: 'ko-KR', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + ignoreHTTPSErrors: true, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/admin/uploads-admin.spec.ts b/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/admin/uploads-admin.spec.ts new file mode 100644 index 00000000..55eb742f --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/admin/uploads-admin.spec.ts @@ -0,0 +1,279 @@ +/** + * E2E: 에디터 업로드 이미지 관리 화면 (공개 #115) + * + * @scenario admin_ckeditor5_uploads_list_filter_and_permission_boundary + * @effects admin_index_lists_uploads, admin_ui_reference_badge, admin_ui_merge_query, + * admin_index_unreferenced_filter, admin_destroy_requires_delete_permission, + * settings_cleanup_toggle_default_off, admin_destroy_removes_file_and_record, + * admin_bulk_delete_removes_selected, settings_cleanup_retention_round_trip + * + * 배경: 에디터로 올렸지만 쓰이지 않는 이미지가 무기한 누적되던 결함(#115)의 회수 화면. + * 사용자 파일을 실제로 파기하는 화면이라, 브라우저에서 실제로 확인해야 하는 것은 + * "목록이 뜬다" 가 아니라 아래 여섯 가지다. + * + * 1. 목록·필터·배지가 i18n 원문 키 노출 없이 렌더된다 + * 2. 필터를 건 뒤 페이지를 옮겨도 목록 상태(검색·참조상태)가 URL 에 보존된다 + * (mergeQuery 누락 시 필터가 조용히 풀려 다른 대상이 화면에 실린다) + * 3. 삭제 권한이 없는 계정에서는 삭제가 403 으로 막힌다 + * 4. 설정 화면의 자동 정리 토글이 기본 꺼짐으로 보인다 + * 5. 실제로 올린 이미지가 단건·일괄 삭제로 목록에서 사라진다 (파괴 성공 경로) + * — 거절 경로(403/404/422)만 검증하면 "아무것도 못 지우는" 회귀를 잡지 못한다 + * 6. 설정 값을 바꿔 저장하면 다시 조회했을 때 그대로 남는다 (저장 왕복) + */ +import { test, expect, authenticatePage } from '../../fixtures/ckeditor5-auth'; + +const UPLOADS_URL = '/admin/plugins/sirsoft-ckeditor5/uploads'; +const SETTINGS_URL = '/admin/plugins/sirsoft-ckeditor5/settings'; + +const DATAGRID = '#ckeditor5_uploads_datagrid'; +const FILTER_SECTION = '#filter_section'; +const NOTICE = '#reference_notice'; + +/** 1x1 투명 PNG — 업로드 검증(image 규칙)을 통과하는 최소 이미지 */ +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +/** + * 에디터 업로드 엔드포인트로 이미지를 올리고, 관리 목록에서 그 행의 id 를 찾아 돌려준다. + * + * 업로드 응답은 에디터용(URL) 이라 관리 id 를 담지 않으므로 원본명으로 되찾는다. + * + * @param page Playwright 페이지 + * @param token 업로드·조회 권한 토큰 + * @returns 업로드된 행의 id 와 원본 파일명 + */ +async function uploadImage( + page: import('@playwright/test').Page, + token: string, +): Promise<{ id: number; filename: string }> { + const filename = `e2e-issue115-${Date.now()}-${Math.floor(Math.random() * 100000)}.png`; + + const upload = await page.request.post('/api/plugins/sirsoft-ckeditor5/upload', { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + multipart: { upload: { name: filename, mimeType: 'image/png', buffer: PNG_1X1 } }, + }); + + expect(upload.ok(), `업로드가 성공해야 한다 (status ${upload.status()})`).toBeTruthy(); + + const list = await page.request.get( + `/api/plugins/sirsoft-ckeditor5/admin/uploads?search=${encodeURIComponent(filename)}&per_page=5`, + { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }, + ); + + expect(list.status()).toBe(200); + + const rows = (await list.json()).data.data as Array<{ id: number; original_name: string }>; + const row = rows.find((item) => item.original_name === filename); + + expect(row, '방금 올린 이미지가 관리 목록에서 조회되어야 한다').toBeTruthy(); + + return { id: row!.id, filename }; +} + +/** + * 원본명으로 관리 목록을 조회해 남아 있는 행 수를 센다. + * + * @param page Playwright 페이지 + * @param token 조회 권한 토큰 + * @param filename 원본 파일명 + * @returns 남아 있는 행 수 + */ +async function countByFilename( + page: import('@playwright/test').Page, + token: string, + filename: string, +): Promise { + const response = await page.request.get( + `/api/plugins/sirsoft-ckeditor5/admin/uploads?search=${encodeURIComponent(filename)}&per_page=5`, + { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }, + ); + + expect(response.status()).toBe(200); + + const rows = (await response.json()).data.data as Array<{ original_name: string }>; + + return rows.filter((item) => item.original_name === filename).length; +} + +/** + * 업로드 관리 화면으로 이동하고 목록이 붙을 때까지 기다린다. + * + * @param page Playwright 페이지 + */ +async function gotoUploads(page: import('@playwright/test').Page): Promise { + await page.goto(UPLOADS_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect(page.locator(DATAGRID)).toBeAttached({ timeout: 20_000 }); +} + +// @scenario trigger=admin_ui, permitted=yes +// @effects admin_index_lists_uploads, admin_ui_reference_badge +test('#115 - 업로드 관리 화면이 목록·필터·안내와 함께 렌더된다', async ({ page, uploadsManageToken }) => { + const consoleErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + + await authenticatePage(page, uploadsManageToken); + await gotoUploads(page); + + expect(page.url()).not.toMatch(/\/admin\/login/); + + await expect(page.locator(FILTER_SECTION)).toBeVisible(); + await expect(page.locator(NOTICE)).toBeVisible(); + + // i18n 원문 키가 그대로 노출되면 번역 누락이다. + const body = (await page.locator('body').innerText()) ?? ''; + expect(body).not.toContain('sirsoft-ckeditor5.admin.uploads'); + expect(body).not.toContain('$t:'); + + expect(consoleErrors).toEqual([]); +}); + +// @scenario trigger=admin_ui, filter=unreferenced +// @effects admin_index_unreferenced_filter, admin_ui_merge_query +test('#115 - 참조 상태 필터가 URL 에 실리고 페이지 이동에도 보존된다', async ({ page, uploadsManageToken }) => { + await authenticatePage(page, uploadsManageToken); + await page.goto(`${UPLOADS_URL}?referenced=unreferenced&search=`); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect(page.locator(DATAGRID)).toBeAttached({ timeout: 20_000 }); + + expect(page.url()).toContain('referenced=unreferenced'); + + // 목록 조회가 필터를 그대로 서버에 전달하는지 — 응답이 200 이어야 화면이 유지된다. + const response = await page.request.get( + '/api/plugins/sirsoft-ckeditor5/admin/uploads?referenced=unreferenced&per_page=20', + { headers: { Authorization: `Bearer ${uploadsManageToken}`, Accept: 'application/json' } }, + ); + + expect(response.status()).toBe(200); + const payload = await response.json(); + expect(payload.data).toHaveProperty('pagination'); + expect(payload.data).toHaveProperty('meta.scan_limited'); +}); + +// @scenario trigger=admin_ui, permitted=read_only +// @effects admin_destroy_requires_delete_permission +test('#115 - 삭제 권한이 없으면 삭제 API 가 403 으로 막힌다', async ({ page, uploadsReadOnlyToken }) => { + await authenticatePage(page, uploadsReadOnlyToken); + await gotoUploads(page); + + // 조회는 가능해야 한다 (읽기 권한 보유) + const list = await page.request.get('/api/plugins/sirsoft-ckeditor5/admin/uploads?per_page=1', { + headers: { Authorization: `Bearer ${uploadsReadOnlyToken}`, Accept: 'application/json' }, + }); + expect(list.status()).toBe(200); + + // 삭제는 대상 존재 여부와 무관하게 권한 단계에서 먼저 막혀야 한다. + const destroy = await page.request.delete('/api/plugins/sirsoft-ckeditor5/admin/uploads/999999', { + headers: { Authorization: `Bearer ${uploadsReadOnlyToken}`, Accept: 'application/json' }, + }); + expect(destroy.status()).toBe(403); +}); + +// @scenario trigger=admin_ui, response=matrix +// @effects admin_bulk_delete_rejects_empty, admin_destroy_not_found +test('#115 - 응답 매트릭스: 빈 선택 422 / 없는 대상 404', async ({ page, uploadsManageToken }) => { + await authenticatePage(page, uploadsManageToken); + + const headers = { Authorization: `Bearer ${uploadsManageToken}`, Accept: 'application/json' }; + + const empty = await page.request.post('/api/plugins/sirsoft-ckeditor5/admin/uploads/bulk-delete', { + headers, + data: { ids: [] }, + }); + expect(empty.status()).toBe(422); + expect(await empty.json()).toHaveProperty('errors.ids'); + + const missing = await page.request.delete('/api/plugins/sirsoft-ckeditor5/admin/uploads/999999', { headers }); + expect(missing.status()).toBe(404); +}); + +// @scenario trigger=admin_ui_single, permitted=yes +// @effects admin_destroy_removes_file_and_record +test('#115 - 올린 이미지가 단건 삭제로 목록에서 사라진다', async ({ page, uploadsManageToken }) => { + await authenticatePage(page, uploadsManageToken); + await gotoUploads(page); + + const { id, filename } = await uploadImage(page, uploadsManageToken); + + expect(await countByFilename(page, uploadsManageToken, filename)).toBe(1); + + const destroy = await page.request.delete(`/api/plugins/sirsoft-ckeditor5/admin/uploads/${id}`, { + headers: { Authorization: `Bearer ${uploadsManageToken}`, Accept: 'application/json' }, + }); + + expect(destroy.status()).toBe(200); + expect(await countByFilename(page, uploadsManageToken, filename)).toBe(0); +}); + +// @scenario trigger=admin_ui_bulk, permitted=yes +// @effects admin_bulk_delete_removes_selected +test('#115 - 선택한 이미지들이 일괄 삭제로 함께 사라진다', async ({ page, uploadsManageToken }) => { + await authenticatePage(page, uploadsManageToken); + await gotoUploads(page); + + const first = await uploadImage(page, uploadsManageToken); + const second = await uploadImage(page, uploadsManageToken); + + const bulk = await page.request.post('/api/plugins/sirsoft-ckeditor5/admin/uploads/bulk-delete', { + headers: { Authorization: `Bearer ${uploadsManageToken}`, Accept: 'application/json' }, + data: { ids: [first.id, second.id] }, + }); + + expect(bulk.status()).toBe(200); + expect(await countByFilename(page, uploadsManageToken, first.filename)).toBe(0); + expect(await countByFilename(page, uploadsManageToken, second.filename)).toBe(0); +}); + +// @scenario trigger=settings_screen, toggle=default +// @effects settings_cleanup_retention_round_trip +test('#115 - 보존기간을 저장하면 다시 조회해도 유지된다', async ({ page, uploadsManageToken }) => { + await authenticatePage(page, uploadsManageToken); + + const headers = { Authorization: `Bearer ${uploadsManageToken}`, Accept: 'application/json' }; + const SETTINGS_API = '/api/admin/plugins/sirsoft-ckeditor5/settings'; + + const before = await page.request.get(SETTINGS_API, { headers }); + expect(before.status()).toBe(200); + + const current = (await before.json()).data as Record; + const originalDays = Number(current.unusedImageRetentionDays ?? 30); + const nextDays = originalDays === 45 ? 40 : 45; + + // 자동 정리 토글은 건드리지 않는다 — 켜 두면 검수 환경에서 실제 파기가 돌기 시작한다. + const saved = await page.request.put(SETTINGS_API, { + headers, + data: { ...current, unusedImageRetentionDays: nextDays }, + }); + expect(saved.status()).toBe(200); + + const after = await page.request.get(SETTINGS_API, { headers }); + expect(after.status()).toBe(200); + expect(Number((await after.json()).data.unusedImageRetentionDays)).toBe(nextDays); + + // 원래 값으로 되돌려 다음 회차·다른 spec 에 값을 남기지 않는다. + const restored = await page.request.put(SETTINGS_API, { + headers, + data: { ...current, unusedImageRetentionDays: originalDays }, + }); + expect(restored.status()).toBe(200); +}); + +// @scenario trigger=settings_screen, toggle=default +// @effects settings_cleanup_toggle_default_off +test('#115 - 플러그인 설정에 미사용 이미지 정리 섹션이 노출된다', async ({ page, uploadsManageToken }) => { + await authenticatePage(page, uploadsManageToken); + await page.goto(SETTINGS_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const section = page.locator('#section_cleanup_card'); + await expect(section).toBeAttached({ timeout: 20_000 }); + await section.scrollIntoViewIfNeeded(); + + await expect(page.locator('#field_unused_image_cleanup')).toBeVisible(); + await expect(page.locator('#field_unused_image_retention_days')).toBeVisible(); + await expect(page.locator('#open_uploads_button')).toBeVisible(); +}); diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-ckeditor5/tests/PluginTestCase.php index 3c15b1a1..ba431c22 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/PluginTestCase.php @@ -4,16 +4,21 @@ namespace Plugins\Sirsoft\Ckeditor5\Tests; use App\Contracts\Extension\StorageInterface; use App\Enums\PermissionType; +use App\Extension\HookManager; +use App\Extension\Storage\PluginStorageDriver; use App\Models\Permission; use App\Models\Role; use App\Models\User; -use App\Extension\Storage\PluginStorageDriver; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Route; +use Plugins\Sirsoft\Ckeditor5\Http\Controllers\Admin\ImageUploadAdminController; use Plugins\Sirsoft\Ckeditor5\Http\Controllers\ImageServeController; use Plugins\Sirsoft\Ckeditor5\Http\Controllers\ImageUploadController; +use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageReferenceSourceRepositoryInterface; use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface; +use Plugins\Sirsoft\Ckeditor5\Repositories\ImageReferenceSourceRepository; use Plugins\Sirsoft\Ckeditor5\Repositories\ImageUploadRepository; +use Plugins\Sirsoft\Ckeditor5\Services\ImageCleanupService; use Plugins\Sirsoft\Ckeditor5\Services\ImageServeService; use Plugins\Sirsoft\Ckeditor5\Services\ImageUploadService; use Tests\TestCase; @@ -43,8 +48,13 @@ abstract class PluginTestCase extends TestCase ImageUploadRepository::class ); + $this->app->bind( + ImageReferenceSourceRepositoryInterface::class, + ImageReferenceSourceRepository::class + ); + // StorageInterface 바인딩 - $this->app->when([ImageUploadService::class, ImageServeService::class]) + $this->app->when([ImageUploadService::class, ImageServeService::class, ImageCleanupService::class]) ->needs(StorageInterface::class) ->give(fn () => new PluginStorageDriver('sirsoft-ckeditor5', 'plugins')); @@ -60,6 +70,23 @@ abstract class PluginTestCase extends TestCase Route::get('images/{hash}', [ImageServeController::class, 'serve']) ->name('api.sirsoft-ckeditor5.images.serve'); + + // 업로드 관리 (관리자) — 실제 라우트 파일과 동일한 미들웨어 체인을 유지해 + // 권한 경계(403)까지 테스트가 실제로 밟게 한다. + Route::prefix('admin')->name('admin.')->middleware('auth:sanctum')->group(function () { + Route::get('uploads', [ImageUploadAdminController::class, 'index']) + ->middleware('permission:admin,sirsoft-ckeditor5.uploads.read') + ->name('uploads.index'); + + Route::post('uploads/bulk-delete', [ImageUploadAdminController::class, 'bulkDestroy']) + ->middleware('permission:admin,sirsoft-ckeditor5.uploads.delete') + ->name('uploads.bulk-delete'); + + Route::delete('uploads/{id}', [ImageUploadAdminController::class, 'destroy']) + ->whereNumber('id') + ->middleware('permission:admin,sirsoft-ckeditor5.uploads.delete') + ->name('uploads.destroy'); + }); }); } @@ -85,7 +112,7 @@ abstract class PluginTestCase extends TestCase */ private function snapshotHookManager(): void { - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $this->hookSnapshot = [ 'hooks' => $ref->getProperty('hooks')->getValue(), 'filters' => $ref->getProperty('filters')->getValue(), @@ -102,7 +129,7 @@ abstract class PluginTestCase extends TestCase return; } - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']); $ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']); $ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']); diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Services/ImageCleanupServiceTest.php b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Services/ImageCleanupServiceTest.php new file mode 100644 index 00000000..c1ead5c4 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Services/ImageCleanupServiceTest.php @@ -0,0 +1,293 @@ + 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + + Ckeditor5ImageUpload::flushStorageCache(); + + $this->service = app(ImageCleanupService::class); + } + + protected function tearDown(): void + { + Ckeditor5ImageUpload::flushStorageCache(); + + parent::tearDown(); + } + + /** + * 파일과 기록을 함께 만듭니다. + * + * @param string $hash 이미지 해시 + * @param string $filename 저장 파일명 + * @param int $ageDays 업로드 경과 일수 + * @param string $disk 스토리지 디스크 + * @param bool $withFile 실제 파일 생성 여부 + * @return Ckeditor5ImageUpload 생성된 기록 + */ + private function makeUpload( + string $hash, + string $filename, + int $ageDays = 40, + string $disk = 'plugins', + bool $withFile = true, + ): Ckeditor5ImageUpload { + $path = "2026/08/14/{$filename}"; + + if ($withFile) { + (new PluginStorageDriver('sirsoft-ckeditor5', $disk))->put('images', $path, 'image-bytes'); + } + + $upload = Ckeditor5ImageUpload::create([ + 'hash' => $hash, + 'original_name' => $filename, + 'file_path' => "images/{$path}", + 'storage_disk' => $disk, + 'file_size' => 11, + 'mime_type' => 'image/png', + ]); + + $upload->forceFill(['created_at' => now()->subDays($ageDays)])->saveQuietly(); + + return $upload->refresh(); + } + + /** + * 코어 참조 소스에 본문을 심습니다. + * + * @param string $content 본문 + */ + private function seedReference(string $content): void + { + DB::table('mail_templates')->insert([ + 'type' => 'cleanup-test-'.uniqid(), + 'subject' => json_encode(['ko' => '정리 테스트']), + 'body' => $content, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * @scenario age=past_retention, disk_state=default_local, url_variant=absent + * + * @effects prune_deletes_file_and_record + */ + #[Test] + public function unreferenced_upload_past_retention_loses_both_file_and_record(): void + { + $upload = $this->makeUpload('aaaaaaaaaaaa', 'orphan.png'); + $storage = new PluginStorageDriver('sirsoft-ckeditor5', 'plugins'); + + $result = $this->service->pruneUnused(30, 100); + + $this->assertSame(1, $result['deleted']); + $this->assertFalse($storage->exists('images', '2026/08/14/orphan.png')); + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @scenario age=past_retention, disk_state=default_local, url_variant=hash_api_fallback + * + * @effects prune_keeps_referenced_hash_form + */ + #[Test] + public function referenced_by_hash_url_is_kept(): void + { + $upload = $this->makeUpload('bbbbbbbbbbbb', 'used-hash.png'); + $this->seedReference(''); + + $result = $this->service->pruneUnused(30, 100); + + $this->assertSame(0, $result['deleted']); + $this->assertSame(1, $result['referenced']); + $this->assertDatabaseHas('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @scenario age=past_retention, disk_state=default_local, url_variant=direct_url_uuid + * + * @effects prune_keeps_referenced_direct_url + */ + #[Test] + public function referenced_by_direct_disk_url_is_kept(): void + { + $upload = $this->makeUpload('cccccccccccc', 'used-direct.png'); + $this->seedReference(''); + + $result = $this->service->pruneUnused(30, 100); + + $this->assertSame(0, $result['deleted']); + $this->assertDatabaseHas('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @scenario age=within_retention, disk_state=default_local, url_variant=absent + * + * @effects prune_keeps_within_retention + */ + #[Test] + public function upload_within_retention_is_not_a_candidate(): void + { + $upload = $this->makeUpload('dddddddddddd', 'fresh.png', ageDays: 3); + + $result = $this->service->pruneUnused(30, 100); + + $this->assertSame(0, $result['scanned']); + $this->assertDatabaseHas('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @effects prune_dry_run_no_delete + */ + #[Test] + public function dry_run_reports_targets_without_deleting(): void + { + $upload = $this->makeUpload('eeeeeeeeeeee', 'dry.png'); + $storage = new PluginStorageDriver('sirsoft-ckeditor5', 'plugins'); + + $result = $this->service->pruneUnused(30, 100, dryRun: true); + + $this->assertSame(0, $result['deleted']); + $this->assertCount(1, $result['items']); + $this->assertTrue($storage->exists('images', '2026/08/14/dry.png')); + $this->assertDatabaseHas('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * @effects prune_limit_oldest_first + */ + #[Test] + public function limit_processes_the_oldest_candidates_first(): void + { + $oldest = $this->makeUpload('111111111111', 'oldest.png', ageDays: 90); + $newer = $this->makeUpload('222222222222', 'newer.png', ageDays: 40); + + $result = $this->service->pruneUnused(30, 1); + + $this->assertSame(1, $result['scanned']); + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $oldest->id]); + $this->assertDatabaseHas('ckeditor5_image_uploads', ['id' => $newer->id]); + } + + /** + * 파일이 이미 없는 행은 고아 기록이므로 기록만 지운다 (재시도 루프 방지). + * + * @scenario age=past_retention, disk_state=file_missing, url_variant=absent + * + * @effects prune_missing_file_removes_record + */ + #[Test] + public function record_without_a_file_is_still_removed(): void + { + $upload = $this->makeUpload('333333333333', 'ghost.png', withFile: false); + + $result = $this->service->pruneUnused(30, 100); + + $this->assertSame(1, $result['deleted']); + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * 행마다 disk 가 다른 혼재 상태에서도 그 행의 저장 위치를 향해 지워야 한다. + * + * @scenario age=past_retention, disk_state=secondary_disk, url_variant=absent + * + * @effects prune_respects_row_disk + */ + #[Test] + public function deletion_follows_the_disk_recorded_on_each_row(): void + { + $local = $this->makeUpload('444444444444', 'on-local.png'); + $cdn = $this->makeUpload('555555555555', 'on-cdn.png', disk: 'fake_cdn'); + + $result = $this->service->pruneUnused(30, 100); + + $this->assertSame(2, $result['deleted']); + $this->assertFalse( + (new PluginStorageDriver('sirsoft-ckeditor5', 'plugins'))->exists('images', '2026/08/14/on-local.png') + ); + $this->assertFalse( + (new PluginStorageDriver('sirsoft-ckeditor5', 'fake_cdn'))->exists('images', '2026/08/14/on-cdn.png') + ); + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $local->id]); + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $cdn->id]); + } + + /** + * @effects delete_upload_shared_by_ui_and_command + */ + #[Test] + public function delete_upload_removes_file_and_record_for_a_single_row(): void + { + $upload = $this->makeUpload('666666666666', 'single.png', ageDays: 1); + $storage = new PluginStorageDriver('sirsoft-ckeditor5', 'plugins'); + + $this->assertTrue($this->service->deleteUpload($upload)); + $this->assertFalse($storage->exists('images', '2026/08/14/single.png')); + $this->assertDatabaseMissing('ckeditor5_image_uploads', ['id' => $upload->id]); + } + + /** + * 파일 삭제가 실패하면 기록을 남긴다. + * + * 파일이 남아 있는데 기록만 지우면 그 파일을 가리키는 것이 아무것도 없어져 다음 회차에도 + * 회수 대상이 되지 못한다. 실패는 failed 로 계상하고 다음 회차에 재시도한다. + * + * @scenario age=past_retention, disk_state=default_local, url_variant=absent + * + * @effects prune_delete_failure_keeps_record + */ + #[Test] + public function file_delete_failure_keeps_the_record_and_counts_as_failed(): void + { + $upload = $this->makeUpload('777777777777', 'locked.png'); + + $storage = \Mockery::mock(StorageInterface::class); + $storage->shouldReceive('withDisk')->andReturnSelf(); + $storage->shouldReceive('exists')->andReturnTrue(); + $storage->shouldReceive('delete')->andReturnFalse(); + + $service = new ImageCleanupService( + app(ImageUploadRepositoryInterface::class), + app(ImageReferenceScanService::class), + $storage, + ); + + $result = $service->pruneUnused(30, 100); + + $this->assertSame(1, $result['failed']); + $this->assertSame(0, $result['deleted']); + $this->assertDatabaseHas('ckeditor5_image_uploads', ['id' => $upload->id]); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Services/ImageReferenceScanServiceTest.php b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Services/ImageReferenceScanServiceTest.php new file mode 100644 index 00000000..60662e5d --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Services/ImageReferenceScanServiceTest.php @@ -0,0 +1,214 @@ +service = app(ImageReferenceScanService::class); + } + + /** + * 업로드 기록을 생성합니다. + * + * @param string $hash 이미지 해시 + * @param string $filename 저장 파일명 + * @return Ckeditor5ImageUpload 생성된 기록 + */ + private function makeUpload(string $hash = 'abcdef012345', string $filename = 'aaaaaaaa-bbbb.png'): Ckeditor5ImageUpload + { + return Ckeditor5ImageUpload::create([ + 'hash' => $hash, + 'original_name' => 'sample.png', + 'file_path' => "images/2026/08/14/{$filename}", + 'storage_disk' => 'plugins', + 'file_size' => 100, + 'mime_type' => 'image/png', + ]); + } + + /** + * 코어 소유 참조 소스(메일 템플릿 본문)에 임의 내용을 담은 행을 만듭니다. + * + * @param string $content 본문 + */ + private function seedCoreSourceContent(string $content): void + { + DB::table('mail_templates')->insert([ + 'type' => 'reference-scan-test-'.uniqid(), + 'subject' => json_encode(['ko' => '참조 스캔 테스트']), + 'body' => $content, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * @effects reference_sources_default_core_only + */ + #[Test] + public function default_sources_are_core_only_when_no_extension_registers(): void + { + $tables = array_column($this->service->getReferenceSources(), 'table'); + + $this->assertContains('template_layouts', $tables); + $this->assertContains('notification_templates', $tables); + $this->assertNotContains('board_posts', $tables, '훅 미등록 상태에서는 모듈 소스가 포함되지 않아야 한다.'); + } + + /** + * @effects reference_sources_hook_append + */ + #[Test] + public function extensions_can_append_sources_through_the_filter_hook(): void + { + HookManager::addFilter( + ImageReferenceScanService::FILTER_REFERENCE_SOURCES, + fn (array $sources) => [...$sources, ['table' => 'pages', 'columns' => ['content']]], + ); + + $tables = array_column(app(ImageReferenceScanService::class)->getReferenceSources(), 'table'); + + $this->assertContains('pages', $tables); + } + + /** + * @effects reference_match_hash_api_url + */ + #[Test] + public function hash_form_url_in_content_counts_as_referenced(): void + { + $upload = $this->makeUpload(hash: 'a1b2c3d4e5f6'); + $this->seedCoreSourceContent(''); + + $this->assertTrue($this->service->isReferenced($upload)); + } + + /** + * @effects reference_match_direct_url + */ + #[Test] + public function direct_disk_url_in_content_counts_as_referenced(): void + { + $upload = $this->makeUpload(hash: 'ffffffffffff', filename: '9f8e7d6c-1234.webp'); + $this->seedCoreSourceContent(''); + + $this->assertTrue( + $this->service->isReferenced($upload), + '직접 URL 은 해시를 포함하지 않으므로 저장 파일명으로 매칭되어야 한다.' + ); + } + + /** + * @effects reference_unreferenced + */ + #[Test] + public function content_without_the_image_leaves_it_unreferenced(): void + { + $upload = $this->makeUpload(hash: '0123456789ab', filename: 'unused-file.png'); + $this->seedCoreSourceContent('

이미지가 없는 본문

'); + + $this->assertFalse($this->service->isReferenced($upload)); + } + + /** + * LIKE 와일드카드가 들어간 파일명이 임의 문자로 해석되면 무관한 본문이 매칭된다. + * + * @effects reference_like_escape + */ + #[Test] + public function like_wildcards_in_the_filename_do_not_match_arbitrary_content(): void + { + $upload = $this->makeUpload(hash: 'cafebabe1234', filename: 'a%b_c.png'); + $this->seedCoreSourceContent(''); + + $this->assertFalse( + $this->service->isReferenced($upload), + '% 와 _ 는 이스케이프되어 리터럴로 비교되어야 한다.' + ); + } + + /** + * 불량 선언은 건너뛰되 **반드시 흔적을 남긴다**. + * + * 조용히 건너뛰면 확장이 등록한 소스가 오타 하나로 빠져도 아무도 모른 채 그 확장의 + * 콘텐츠에 박힌 이미지가 "미참조" 로 분류된다 — 삭제 방향의 오판이라 경고가 유일한 + * 발견 통로다. skip 동작과 경고를 함께 고정한다. + * + * @effects reference_invalid_source_skipped, reference_invalid_source_warns + */ + #[Test] + public function invalid_source_declarations_are_skipped_without_breaking_the_scan(): void + { + Log::spy(); + + HookManager::addFilter( + ImageReferenceScanService::FILTER_REFERENCE_SOURCES, + fn (array $sources) => [ + ...$sources, + ['table' => 'no_such_table_here', 'columns' => ['content']], + ['table' => 'template_layouts', 'columns' => ['no_such_column']], + ['columns' => ['content']], + ], + ); + + $service = app(ImageReferenceScanService::class); + $tables = array_column($service->getReferenceSources(), 'table'); + + $this->assertNotContains('no_such_table_here', $tables); + $this->assertContains('template_layouts', $tables); + + // 형식 자체가 깨진 선언(table 키 없음) 1건 + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => is_string($message) && str_contains($message, '형식이 올바르지 않아')) + ->once(); + + // 실재하지 않는 테이블 1건 + 실재하지 않는 컬럼 1건 + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => is_string($message) && str_contains($message, '존재하는 테이블/컬럼이 없어')) + ->twice(); + + // 불량 선언이 섞여도 판정 자체는 정상 동작해야 한다. + $upload = $this->makeUpload(hash: 'deadbeef0000', filename: 'skip-test.png'); + $this->seedCoreSourceContent(''); + + $this->assertTrue($service->isReferenced($upload)); + } + + /** + * @effects reference_map_batch + */ + #[Test] + public function map_referenced_reports_each_upload_separately(): void + { + $referenced = $this->makeUpload(hash: '111111111111', filename: 'used.png'); + $unreferenced = $this->makeUpload(hash: '222222222222', filename: 'unused.png'); + + $this->seedCoreSourceContent(''); + + $map = $this->service->mapReferenced(collect([$referenced, $unreferenced])); + + $this->assertTrue($map[$referenced->id]); + $this->assertFalse($map[$unreferenced->id]); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/upgrades/Upgrade_1_0_2.php b/plugins/_bundled/sirsoft-ckeditor5/upgrades/Upgrade_1_0_2.php new file mode 100644 index 00000000..32ae52ee --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/upgrades/Upgrade_1_0_2.php @@ -0,0 +1,17 @@ + 기본값). + * + * @var array + */ + private const DEFAULTS = [ + 'unusedImageCleanup' => false, + 'unusedImageRetentionDays' => 30, + ]; + + /** + * 마이그레이션 식별자 (로그용). + * + * @return string 사람이 읽을 수 있는 짧은 식별자 + */ + public function name(): string + { + return 'SeedUnusedImageCleanupSettings'; + } + + /** + * 설정 파일에 누락된 정리 설정 키를 추가한다. idempotent. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등) + */ + public function run(UpgradeContext $context): void + { + $path = storage_path(self::SETTINGS_PATH); + + if (! File::exists($path)) { + $context->logger->info('[sirsoft-ckeditor5] 설정 파일 없음 — 설치 시 defaults.json 이 시드하므로 skip'); + + return; + } + + $settings = json_decode(File::get($path), true); + + if (! is_array($settings)) { + $context->logger->warning('[sirsoft-ckeditor5] 설정 JSON 형식 비정상 — 정리 설정 백필 skip'); + + return; + } + + $added = []; + + foreach (self::DEFAULTS as $key => $default) { + if (array_key_exists($key, $settings)) { + continue; + } + + $settings[$key] = $default; + $added[] = $key; + } + + if ($added === []) { + $context->logger->info('[sirsoft-ckeditor5] 정리 설정 키가 이미 존재 — 변경 없음'); + + return; + } + + File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + FilePermissionHelper::inheritOwnershipFromParent($path); + + $context->logger->info('[sirsoft-ckeditor5] 미사용 이미지 정리 설정 백필 완료', [ + 'added_keys' => $added, + ]); + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index dbf387e8..b878fcd2 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -10,6 +10,7 @@ - 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다. - 회원 탈퇴·삭제 시 동의 이력 정리가 중간에 실패하면 일부 항목만 처리된 채 남던 문제를 수정했습니다 — 이제 전부 처리되거나 전부 취소됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.) +- 플러그인 설정 화면의 켜기/끄기 스위치가 레이아웃 편집기에서 편집 대상으로 선택되지 않던 문제를 수정했습니다. ## [1.0.2] - 2026-08-10 diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json index 570d1485..70f77ef3 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json @@ -588,7 +588,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "name": "banner_enabled" diff --git a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md index f819998a..c8589969 100644 --- a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md @@ -4,6 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-08-16 + +### Fixed + +- 플러그인 설정 화면의 켜기/끄기 스위치가 레이아웃 편집기에서 편집 대상으로 선택되지 않던 문제를 수정했습니다. +- 채널 목록의 각 행이 같은 HTML 식별자를 공유하던 문제를 수정했습니다. +- 다크 모드에서 설정 화면의 채널 추가 버튼 색이 밝은 화면과 동일하게 표시되던 문제를 수정했습니다. + ## [1.0.1] - 2026-08-10 ### Fixed diff --git a/plugins/_bundled/sirsoft-marketing/composer.json b/plugins/_bundled/sirsoft-marketing/composer.json index 843aa4ee..2d45226c 100644 --- a/plugins/_bundled/sirsoft-marketing/composer.json +++ b/plugins/_bundled/sirsoft-marketing/composer.json @@ -2,7 +2,7 @@ "name": "plugins/sirsoft-marketing", "description": "Marketing consent and subscription management plugin for Gnuboard7 platform", "type": "library", - "version": "1.0.1", + "version": "1.0.2", "autoload": { "psr-4": { "Plugins\\Sirsoft\\Marketing\\": ["src/", "./"] diff --git a/plugins/_bundled/sirsoft-marketing/package-lock.json b/plugins/_bundled/sirsoft-marketing/package-lock.json index a0b5d390..a3670458 100644 --- a/plugins/_bundled/sirsoft-marketing/package-lock.json +++ b/plugins/_bundled/sirsoft-marketing/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-marketing", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-marketing", - "version": "1.0.1", + "version": "1.0.2", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/plugins/_bundled/sirsoft-marketing/package.json b/plugins/_bundled/sirsoft-marketing/package.json index c8f1ac8d..26eba533 100644 --- a/plugins/_bundled/sirsoft-marketing/package.json +++ b/plugins/_bundled/sirsoft-marketing/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-marketing", - "version": "1.0.1", + "version": "1.0.2", "description": "G7 마케팅 동의 플러그인 프론트엔드 에셋", "private": true, "type": "module", diff --git a/plugins/_bundled/sirsoft-marketing/plugin.json b/plugins/_bundled/sirsoft-marketing/plugin.json index d44d9ce7..27da4071 100644 --- a/plugins/_bundled/sirsoft-marketing/plugin.json +++ b/plugins/_bundled/sirsoft-marketing/plugin.json @@ -5,7 +5,7 @@ "ko": "마케팅 동의", "en": "Marketing Consent" }, - "version": "1.0.1", + "version": "1.0.2", "description": { "ko": "이메일 구독, 마케팅 동의, 제3자 제공 동의 등을 관리하는 플러그인", "en": "Plugin for managing email subscriptions, marketing consent, and third-party data sharing consent" diff --git a/plugins/_bundled/sirsoft-marketing/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx b/plugins/_bundled/sirsoft-marketing/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx index e2bcf60e..5af36353 100644 --- a/plugins/_bundled/sirsoft-marketing/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx +++ b/plugins/_bundled/sirsoft-marketing/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx @@ -7,9 +7,33 @@ * 적용되어 있는지 검증한다. */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; import pluginSettingsLayout from '../../../layouts/admin/plugin_settings.json'; +/** 관리자 템플릿의 컴파일된 컴포넌트 CSS 경로 (합성 유틸리티 클래스의 실제 선언 위치). */ +const ADMIN_COMPONENTS_CSS = 'templates/_bundled/sirsoft-admin_basic/dist/css/components.css'; + +/** + * 저장소 루트(artisan 기준)를 위로 훑어 찾는다. + * + * @returns 저장소 루트 절대경로 + */ +function repoRoot(): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 10; depth++) { + if (fs.existsSync(path.join(current, 'artisan'))) { + return current; + } + current = path.dirname(current); + } + + throw new Error('artisan 을 가진 저장소 루트를 찾지 못했습니다.'); +} + /** 레이아웃 트리에서 주어진 id 의 노드를 찾는다. */ function findById(node: unknown, id: string): Record | undefined { if (!node || typeof node !== 'object') { @@ -34,13 +58,24 @@ function classNameOf(node: Record | undefined): string { } describe('plugin_settings 하단 버튼 sticky 고정', () => { - it('footer_buttons 에 sticky bottom 고정 클래스가 존재해야 한다', () => { + it('footer_buttons 가 sticky 고정 유틸리티 클래스를 사용해야 한다', () => { const footer = findById(pluginSettingsLayout, 'footer_buttons'); expect(footer).toBeDefined(); - const className = classNameOf(footer); - expect(className).toContain('sticky'); - expect(className).toContain('bottom-0'); - expect(className).toContain('z-10'); + // 레이아웃은 raw Tailwind 원자(sticky bottom-0 z-10) 대신 합성 유틸리티 한 개를 쓴다. + // 원자 이름을 직접 단언하면 합성 클래스로 옮긴 뒤 고정이 멀쩡한데도 실패한다. + expect(classNameOf(footer)).toContain('sticky-footer-buttons'); + }); + + it('그 유틸리티 클래스가 실제로 하단 고정 속성을 선언해야 한다', () => { + // 클래스 이름만 대조하면 동어반복이라, 컴파일된 CSS 에서 실제 선언을 확인한다 — + // 클래스명이 남아 있어도 선언이 사라지면 화면에서 고정이 풀리기 때문이다. + const css = fs.readFileSync(path.join(repoRoot(), ADMIN_COMPONENTS_CSS), 'utf-8'); + const rule = /\.sticky-footer-buttons\{([^}]*)\}/.exec(css); + + expect(rule).not.toBeNull(); + expect(rule![1]).toContain('position:sticky'); + expect(rule![1]).toContain('bottom:'); + expect(rule![1]).toContain('z-index:10'); }); }); diff --git a/plugins/_bundled/sirsoft-marketing/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-marketing/resources/layouts/admin/plugin_settings.json index 15f51cdc..0d9b04db 100644 --- a/plugins/_bundled/sirsoft-marketing/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-marketing/resources/layouts/admin/plugin_settings.json @@ -78,7 +78,7 @@ "text": "$t:sirsoft-marketing.settings.channel_modal.enabled" }, { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "checked": "{{_global.editingChannel.enabled}}", @@ -549,7 +549,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "name": "marketing_consent_enabled" @@ -641,7 +641,7 @@ "name": "Button", "props": { "type": "button", - "className": "flex-center gap-1.5 px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors text-sm" + "className": "flex-center gap-1.5 px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 font-medium transition-colors text-sm" }, "actions": [ { @@ -747,7 +747,7 @@ ] }, { - "id": "channels_table_rows", + "id": "channels_table_rows_{{channelIdx}}", "type": "basic", "name": "Div", "props": { @@ -843,7 +843,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "checked": "{{channel.enabled}}", @@ -1049,7 +1049,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "name": "third_party_consent_enabled" @@ -1154,7 +1154,7 @@ }, "children": [ { - "type": "basic", + "type": "composite", "name": "Toggle", "props": { "name": "info_disclosure_enabled" diff --git a/resources/views/dev-dashboard.blade.php b/resources/views/dev-dashboard.blade.php index 47bb424c..84de65e2 100644 --- a/resources/views/dev-dashboard.blade.php +++ b/resources/views/dev-dashboard.blade.php @@ -1086,6 +1086,10 @@ if (isset($_GET['ajax_action'])) { 알림 발송 이력 정리 (notification-log:prune) +