feat(ckeditor5,board,ecommerce,page,core,admin_basic): 미참조 에디터 업로드 이미지 정리 신설 + 업로드 고아 동종 결함 7건 전수 해소
공개 (@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종
This commit is contained in:
1 parent
1d895a6428
commit
cfc2caba1b
170 files changed
+11142
-209
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 마이그레이션 실행
|
||||
*
|
||||
* 미참조 이미지 정리는 "보존기간이 지난 오래된 업로드" 를 created_at 오름차순으로
|
||||
* 훑으므로 해당 컬럼에 인덱스를 둔다. 관리 화면의 기본 정렬(최신순)도 같은 인덱스를 쓴다.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('ckeditor5_image_uploads')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('ckeditor5_image_uploads', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -4,12 +4,13 @@
|
||||
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
- **문서 수**: 2 · **엔드포인트 수**: 2
|
||||
- **문서 수**: 3 · **엔드포인트 수**: 5
|
||||
|
||||
| 문서 | 도메인 | 엔드포인트 |
|
||||
| --- | --- | --- |
|
||||
| [images.md](images.md) | `images` | 1 |
|
||||
| [upload.md](upload.md) | `upload` | 1 |
|
||||
| [uploads.md](uploads.md) | `uploads` | 3 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -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
|
||||
|
||||
<!-- @generated:start:api.plugins.sirsoft-ckeditor5.admin.uploads.index -->
|
||||
|
||||
- **라우트명**: `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` 범위 초과, 날짜 형식 오류 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
에디터로 업로드된 이미지 목록을 관리 화면에 제공하는 엔드포인트다.
|
||||
|
||||
- **참조 상태는 저장하지 않는다.** 참조 관계는 콘텐츠가 편집될 때마다 바뀌므로 스냅샷을 저장하면 "저장 당시엔 미참조였던" 이미지를 이후에 오판 삭제할 수 있다. 대신 판정 비용에 상한을 둔다 — 기본 목록은 현재 페이지(≤100행)만 판정하고, 참조 상태 필터를 쓸 때만 최신 `meta.scan_window` 건을 훑는다.
|
||||
- **`meta.scan_limited` 가 true 면 목록이 전수가 아니다.** 화면은 이 값을 근거로 "최근 N건 기준" 안내를 표시해야 한다. 이 값을 무시하면 운영자가 "미참조 이미지가 이게 전부" 로 오해한다.
|
||||
- 판정 토큰은 `hash` 와 저장 파일명 둘 다이다. 본문에 박히는 URL 이 API 폴백형(hash 포함)과 디스크 직접 URL(저장 파일명 포함) 두 형태이기 때문이며, 한쪽만 검사하면 다른 형태로 저장된 이미지를 미참조로 오판한다.
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/plugins/sirsoft-ckeditor5/admin/uploads/{id}
|
||||
|
||||
<!-- @generated:start:api.plugins.sirsoft-ckeditor5.admin.uploads.destroy -->
|
||||
|
||||
- **라우트명**: `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 | 물리 파일이 존재하는데 삭제에 실패한 경우 — 기록은 보존되며 다음 시도에서 재시도할 수 있다 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
업로드 이미지 1건을 파일과 기록까지 삭제한다.
|
||||
|
||||
- **삭제 순서는 파일 먼저, 기록 나중이다.** 반대로 하면 기록이 사라진 파일은 어떤 경로로도 다시 찾을 수 없어 영구 고아가 된다.
|
||||
- 파일이 이미 없는 행은 고아 기록으로 보고 기록만 지운다. 그러지 않으면 매 회차 같은 행을 재시도하는 루프가 된다.
|
||||
- 행마다 `storage_disk` 가 다를 수 있으므로 삭제는 그 행에 기록된 디스크를 향해 수행된다(디스크 전환 이후 혼재 대응).
|
||||
- 이 경로는 정리 커맨드(`sirsoft-ckeditor5:prune-unused-images`)와 **같은 삭제 구현을 공유**한다 — 화면 삭제와 자동 정리의 동작이 갈리지 않게 하기 위함이다.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/plugins/sirsoft-ckeditor5/admin/uploads/bulk-delete
|
||||
|
||||
<!-- @generated:start: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` 로 보고) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
관리 화면에서 체크한 이미지를 한 번에 삭제한다.
|
||||
|
||||
- 선택 범위는 **화면에 보이는 페이지**로 한정된다(화면 계약). 검색·필터·페이지 이동으로 목록에서 빠진 행의 선택이 남아 대상에 실리면, 운영자가 보지도 체크하지도 않은 파일이 지워지기 때문이다.
|
||||
- 일부만 실패한 경우 요청 자체는 성공(200)으로 응답하고 `failed` 로 보고한다. 전부 실패한 경우에만 500 이다 — 부분 성공을 실패로 되돌릴 방법이 없기 때문이다.
|
||||
@@ -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.',
|
||||
],
|
||||
];
|
||||
@@ -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일 미만으로 설정되어 정리를 수행하지 않았습니다.',
|
||||
],
|
||||
];
|
||||
@@ -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",
|
||||
|
||||
@@ -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<array{table: string, columns: list<string>}>',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 권한 목록 반환 (계층 구조)
|
||||
*
|
||||
* 업로드 이미지 관리 화면(조회/삭제)의 접근을 분리한다 — 조회는 감사·현황 파악,
|
||||
* 삭제는 파일을 실제로 파기하므로 별도 권한으로 둔다.
|
||||
*
|
||||
* @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<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array<string, string>> 스케줄 정의 목록
|
||||
*/
|
||||
public function getSchedules(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'command' => 'sirsoft-ckeditor5:prune-unused-images --scheduled',
|
||||
'schedule' => 'daily',
|
||||
'description' => '미참조 에디터 업로드 이미지 정리',
|
||||
'enabled_config' => 'sirsoft-ckeditor5.unusedImageCleanup',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+358
@@ -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<any> = ({ className, children }) => <div className={className}>{children}</div>;
|
||||
const TestSpan: React.FC<any> = ({ className, children, text }) => (
|
||||
<span className={className}>{children || text}</span>
|
||||
);
|
||||
const TestFragment: React.FC<any> = ({ children }) => <>{children}</>;
|
||||
|
||||
describe('업로드 관리 화면 — 안내 배너 렌더링', () => {
|
||||
let registry: ComponentRegistry;
|
||||
let testUtils: ReturnType<typeof createLayoutTest> | 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<string, unknown>)) {
|
||||
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}}');
|
||||
});
|
||||
});
|
||||
+249
@@ -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<any>(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<any> = ({ className, children }) => <div className={className}>{children}</div>;
|
||||
const TestLabel: React.FC<any> = ({ className, children, text }) => (
|
||||
<label className={className}>{children || text}</label>
|
||||
);
|
||||
const TestP: React.FC<any> = ({ className, children, text }) => <p className={className}>{children || text}</p>;
|
||||
const TestH3: React.FC<any> = ({ className, children, text }) => <h3 className={className}>{children || text}</h3>;
|
||||
const TestToggle: React.FC<any> = ({ name }) => <input type="checkbox" data-name={name} readOnly />;
|
||||
const TestInput: React.FC<any> = ({ name, type }) => <input type={type} data-name={name} readOnly />;
|
||||
const TestButton: React.FC<any> = ({ className, children }) => <button type="button" className={className}>{children}</button>;
|
||||
const TestSpan: React.FC<any> = ({ className, children, text }) => <span className={className}>{children || text}</span>;
|
||||
const TestFragment: React.FC<any> = ({ children }) => <>{children}</>;
|
||||
|
||||
describe('미사용 이미지 정리 섹션 — 렌더링', () => {
|
||||
let registry: ComponentRegistry;
|
||||
let testUtils: ReturnType<typeof createLayoutTest> | 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();
|
||||
});
|
||||
});
|
||||
+40
-5
@@ -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<string, unknown> | undefined {
|
||||
if (!node || typeof node !== 'object') {
|
||||
@@ -34,13 +58,24 @@ function classNameOf(node: Record<string, unknown> | 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');
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Plugins\Sirsoft\Ckeditor5\Services\ImageCleanupService;
|
||||
|
||||
/**
|
||||
* 미참조 CKEditor5 업로드 이미지 정리 커맨드
|
||||
*
|
||||
* 에디터로 올렸지만 어떤 콘텐츠에서도 쓰이지 않는 이미지를 파일·기록 함께 정리합니다.
|
||||
*
|
||||
* 자동 정리는 사용자 파일을 지우므로 기본 꺼짐(운영자 옵트인)입니다. 스케줄러 호출
|
||||
* (`--scheduled`)은 커맨드 안에서 설정을 **false 폴백으로** 재확인합니다 — 스케줄 등록부의
|
||||
* `enabled_config` 게이트는 설정 조회가 실패하면 true 로 폴백하므로, 그것만으로는
|
||||
* "설정을 만지지 않은 사이트에서 자동 삭제 0" 을 보장할 수 없습니다.
|
||||
*
|
||||
* @example php artisan sirsoft-ckeditor5:prune-unused-images --dry-run
|
||||
* @example php artisan sirsoft-ckeditor5:prune-unused-images --days=7 --limit=50
|
||||
*/
|
||||
class PruneUnusedImagesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* 커맨드 이름 및 시그니처
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'sirsoft-ckeditor5:prune-unused-images
|
||||
{--dry-run : 실제 삭제 없이 판정 결과만 확인}
|
||||
{--limit=200 : 한 번에 스캔할 최대 후보 수}
|
||||
{--days= : 보존기간(일) 재정의 — 미지정 시 플러그인 설정값}
|
||||
{--scheduled : 스케줄러 호출 표시 — 자동 정리 토글이 꺼져 있으면 조기 종료}';
|
||||
|
||||
/**
|
||||
* 커맨드 설명
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '어떤 콘텐츠에서도 사용되지 않는 에디터 업로드 이미지를 정리합니다.';
|
||||
|
||||
/**
|
||||
* @param ImageCleanupService $cleanupService 이미지 정리 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
protected ImageCleanupService $cleanupService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드 실행
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->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<int, array<string, mixed>>} $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'])
|
||||
);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Http\Controllers\Admin;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Plugins\Sirsoft\Ckeditor5\Http\Requests\BulkDeleteUploadsRequest;
|
||||
use Plugins\Sirsoft\Ckeditor5\Http\Requests\IndexUploadsRequest;
|
||||
use Plugins\Sirsoft\Ckeditor5\Http\Resources\ImageUploadResource;
|
||||
use Plugins\Sirsoft\Ckeditor5\Services\ImageUploadAdminService;
|
||||
|
||||
/**
|
||||
* CKEditor5 업로드 이미지 관리 컨트롤러
|
||||
*
|
||||
* GET /api/plugins/sirsoft-ckeditor5/admin/uploads
|
||||
* DELETE /api/plugins/sirsoft-ckeditor5/admin/uploads/{id}
|
||||
* POST /api/plugins/sirsoft-ckeditor5/admin/uploads/bulk-delete
|
||||
*
|
||||
* 권한은 라우트 미들웨어(`sirsoft-ckeditor5.uploads.read` / `.delete`)가 검증합니다.
|
||||
*/
|
||||
class ImageUploadAdminController extends AdminBaseController
|
||||
{
|
||||
/**
|
||||
* @param ImageUploadAdminService $adminService 업로드 관리 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ImageUploadAdminService $adminService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 업로드 이미지 목록을 반환합니다.
|
||||
*
|
||||
* @param IndexUploadsRequest $request HTTP 요청
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function index(IndexUploadsRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
|
||||
|
||||
/**
|
||||
* 관리자 업로드 이미지 일괄 삭제 요청 검증
|
||||
*/
|
||||
class BulkDeleteUploadsRequest extends FormRequest
|
||||
{
|
||||
/** 한 번에 삭제할 수 있는 최대 건수 */
|
||||
public const MAX_IDS = 100;
|
||||
|
||||
/**
|
||||
* 요청 권한 — 라우트 permission 미들웨어가 담당하므로 true 고정.
|
||||
*
|
||||
* @return bool 항상 true
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙을 반환합니다.
|
||||
*
|
||||
* @return array<string, array<int, mixed>> 검증 규칙
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ids' => ['required', 'array', 'min:1', 'max:'.self::MAX_IDS],
|
||||
'ids.*' => ['integer', Rule::exists(Ckeditor5ImageUpload::class, 'id')],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 메시지를 반환합니다.
|
||||
*
|
||||
* @return array<string, string> 메시지 배열
|
||||
*/
|
||||
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<int, int> ID 목록
|
||||
*/
|
||||
public function ids(): array
|
||||
{
|
||||
/** @var array<int, int|string> $ids */
|
||||
$ids = $this->validated('ids') ?? [];
|
||||
|
||||
return array_values(array_unique(array_map('intval', $ids)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 관리자 업로드 이미지 목록 조회 요청 검증
|
||||
*
|
||||
* 정렬 허용 어휘(`sort_by`)는 Repository 화이트리스트의 부분집합이어야 합니다 —
|
||||
* 게이트가 더 넓으면 저장소가 조용히 기본 정렬로 되돌리고, 화면 옵션이 더 넓으면
|
||||
* 그 옵션을 고를 때마다 목록 전체가 422 가 됩니다.
|
||||
*/
|
||||
class IndexUploadsRequest extends FormRequest
|
||||
{
|
||||
/** 기본 페이지 크기 */
|
||||
public const DEFAULT_PER_PAGE = 20;
|
||||
|
||||
/** 페이지 크기 상한 */
|
||||
public const MAX_PER_PAGE = 100;
|
||||
|
||||
/**
|
||||
* 정렬 허용 컬럼 (Repository 화이트리스트와 동일 어휘)
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const SORTABLE = ['created_at', 'file_size', 'original_name'];
|
||||
|
||||
/**
|
||||
* 참조 상태 필터 허용 어휘
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
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<string, array<int, mixed>> 검증 규칙
|
||||
*/
|
||||
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<string, mixed> 필터 배열
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Http\Resources;
|
||||
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* CKEditor5 업로드 이미지 관리 화면 API 리소스
|
||||
*
|
||||
* `referenced` 는 저장 컬럼이 아니라 조회 시 판정해 붙인 응답 전용 값입니다
|
||||
* (ImageUploadAdminService 참조). 판정하지 못한 행은 안전 방향인 "참조됨" 으로 봅니다.
|
||||
*/
|
||||
class ImageUploadResource extends BaseApiResource
|
||||
{
|
||||
/**
|
||||
* 리소스를 배열로 변환합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Repositories\Contracts;
|
||||
|
||||
/**
|
||||
* 에디터 이미지 참조 소스 조회 Repository 인터페이스
|
||||
*
|
||||
* 참조 소스는 확장이 훅으로 등록하는 "테이블명 + 컬럼명" 이라 전용 모델이 없습니다.
|
||||
* 그래서 이 Repository 는 특정 엔티티가 아니라 **선언된 소스에 대한 조회**를 소유합니다 —
|
||||
* Service 는 어느 소스를 볼지와 무엇을 찾을지만 정하고, 실제 데이터 접근은 여기로 모읍니다.
|
||||
*/
|
||||
interface ImageReferenceSourceRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* 소스 선언이 실제 스키마에 존재하는지 검증하고, 존재하는 컬럼만 남깁니다.
|
||||
*
|
||||
* @param string $table 테이블명 (프리픽스 제외)
|
||||
* @param array<int, string> $columns 컬럼명 목록
|
||||
* @return array<int, string> 실재하는 컬럼 목록 (테이블이 없으면 빈 배열)
|
||||
*/
|
||||
public function resolveExistingColumns(string $table, array $columns): array;
|
||||
|
||||
/**
|
||||
* 지정 소스의 컬럼 중 하나라도 토큰을 포함하는 행이 있는지 확인합니다.
|
||||
*
|
||||
* @param string $table 테이블명 (프리픽스 제외)
|
||||
* @param array<int, string> $columns 검사할 컬럼 목록
|
||||
* @param array<int, string> $tokens 판정 토큰 (LIKE 이스케이프는 구현이 담당)
|
||||
* @return bool 하나라도 등장하면 true
|
||||
*/
|
||||
public function containsAnyToken(string $table, array $columns, array $tokens): bool;
|
||||
}
|
||||
+67
@@ -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<int, Ckeditor5ImageUpload> 업로드 목록 (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<int, Ckeditor5ImageUpload> 업로드 목록 (created_at 내림차순)
|
||||
*/
|
||||
public function findScanWindow(array $filters, int $limit): Collection;
|
||||
|
||||
/**
|
||||
* ID 목록으로 업로드를 조회합니다.
|
||||
*
|
||||
* @param array<int, int> $ids 업로드 ID 목록
|
||||
* @return Collection<int, Ckeditor5ImageUpload> 업로드 목록
|
||||
*/
|
||||
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;
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Repositories;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageReferenceSourceRepositoryInterface;
|
||||
|
||||
/**
|
||||
* 에디터 이미지 참조 소스 조회 Repository 구현체
|
||||
*
|
||||
* 테이블명은 확장이 훅으로 선언한 값이라 런타임에 정해집니다. raw SQL 대신 쿼리빌더를
|
||||
* 쓰는 이유는 테이블 프리픽스를 빌더가 붙이고 값 바인딩도 빌더가 처리하기 때문입니다.
|
||||
*/
|
||||
class ImageReferenceSourceRepository implements ImageReferenceSourceRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function resolveExistingColumns(string $table, array $columns): array
|
||||
{
|
||||
if ($table === '' || $columns === [] || ! Schema::hasTable($table)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$columns,
|
||||
fn (string $column) => 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, '\\%_');
|
||||
}
|
||||
}
|
||||
@@ -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<string>
|
||||
*/
|
||||
private const LIST_COLUMNS = [
|
||||
'id',
|
||||
'hash',
|
||||
'original_name',
|
||||
'file_path',
|
||||
'storage_disk',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'uploaded_by',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 관리 화면 정렬 허용 컬럼 화이트리스트
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
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<Ckeditor5ImageUpload> $query 대상 쿼리
|
||||
* @param array $filters 필터 배열
|
||||
* @return Builder<Ckeditor5ImageUpload> 필터가 적용된 쿼리
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Services;
|
||||
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
|
||||
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface;
|
||||
|
||||
/**
|
||||
* CKEditor5 미참조 업로드 이미지 정리 서비스
|
||||
*
|
||||
* 관리 화면의 수동 삭제와 정리 커맨드가 같은 삭제 경로(deleteUpload)를 공유합니다.
|
||||
*
|
||||
* 삭제 순서는 "물리 파일 먼저, 레코드 나중" 입니다. 반대로 하면 레코드가 사라진 파일은
|
||||
* 어떤 경로로도 다시 찾을 수 없어 영구 고아가 됩니다. 파일이 이미 없는 행은 고아 레코드로
|
||||
* 보고 레코드만 지웁니다 — 그러지 않으면 매 회차 같은 행을 재시도하는 루프가 됩니다.
|
||||
*/
|
||||
class ImageCleanupService
|
||||
{
|
||||
/**
|
||||
* @param ImageUploadRepositoryInterface $repository 업로드 리포지토리
|
||||
* @param ImageReferenceScanService $scanner 참조 판정 서비스
|
||||
* @param StorageInterface $storage 플러그인 스토리지 드라이버
|
||||
*/
|
||||
public function __construct(
|
||||
protected ImageUploadRepositoryInterface $repository,
|
||||
protected ImageReferenceScanService $scanner,
|
||||
protected StorageInterface $storage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 보존기간이 지난 미참조 이미지를 정리합니다.
|
||||
*
|
||||
* @param int $days 보존기간(일)
|
||||
* @param int $limit 한 회차에 스캔할 최대 후보 수
|
||||
* @param bool $dryRun true 면 판정만 하고 삭제하지 않음
|
||||
* @return array{scanned: int, referenced: int, deleted: int, failed: int, items: array<int, array<string, mixed>>}
|
||||
*/
|
||||
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<string, mixed> 요약 배열
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
|
||||
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageReferenceSourceRepositoryInterface;
|
||||
|
||||
/**
|
||||
* CKEditor5 업로드 이미지의 참조 여부 판정 서비스
|
||||
*
|
||||
* 에디터로 올린 이미지가 어떤 콘텐츠에서도 더 이상 쓰이지 않는지를 판정합니다.
|
||||
*
|
||||
* 판정 토큰이 둘인 이유: 본문에 박히는 URL 이 두 형태이기 때문입니다.
|
||||
* - API 폴백형 `/api/plugins/sirsoft-ckeditor5/images/{12자hex}` → `hash` 가 들어간다
|
||||
* - 디스크 직접 URL(`url` 설정 디스크/CDN) → 저장 파일명(uuid.ext)이 들어간다
|
||||
* 한쪽만 검사하면 다른 형태로 저장된 이미지를 "미참조" 로 오판해 삭제하게 됩니다.
|
||||
* `core.storage.filter_url` 훅으로 URL 이 변형될 수 있으므로 두 토큰을 OR 로 모두 검사합니다.
|
||||
*
|
||||
* 로그 사본 테이블(`notification_logs.body`, `mail_send_logs.body`,
|
||||
* `boards_report_logs.snapshot`)과 미리보기(`template_layout_previews`)는 참조 소스에서
|
||||
* 제외합니다. 이들은 자체 보존기간으로 삭제되는 사본이라 소스로 삼으면 "로그가 지워지는
|
||||
* 순간 이미지가 고아가 되는" 역전이 생깁니다.
|
||||
*/
|
||||
class ImageReferenceScanService
|
||||
{
|
||||
/**
|
||||
* 참조 소스 확장 훅 이름
|
||||
*/
|
||||
public const FILTER_REFERENCE_SOURCES = 'sirsoft-ckeditor5.image.filter_reference_sources';
|
||||
|
||||
/**
|
||||
* 코어가 소유한 참조 소스 (테이블명은 프리픽스 제외 원시 이름)
|
||||
*
|
||||
* 모듈 콘텐츠(게시글·페이지·상품 설명 등)는 각 모듈이 훅으로 등록합니다.
|
||||
*
|
||||
* @var list<array{table: string, columns: list<string>}>
|
||||
*/
|
||||
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<array{table: string, columns: list<string>}>|null
|
||||
*/
|
||||
private ?array $resolvedSources = null;
|
||||
|
||||
/**
|
||||
* @param ImageReferenceSourceRepositoryInterface $sourceRepository 참조 소스 조회 리포지토리
|
||||
*/
|
||||
public function __construct(
|
||||
protected ImageReferenceSourceRepositoryInterface $sourceRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 참조 스캔 대상 소스 목록을 반환합니다.
|
||||
*
|
||||
* 확장이 훅으로 추가한 선언은 테이블·컬럼 실재 여부를 검증하고, 불량 선언은
|
||||
* 경고만 남기고 건너뜁니다 — 한 확장의 잘못된 선언이 판정 전체를 멈추지 않게 합니다.
|
||||
*
|
||||
* @return list<array{table: string, columns: list<string>}> 검증된 소스 목록
|
||||
*/
|
||||
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<int, Ckeditor5ImageUpload>|iterable<Ckeditor5ImageUpload> $uploads 업로드 목록
|
||||
* @return array<int, bool> [업로드 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<string> 토큰 목록
|
||||
*/
|
||||
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<string>} $source 참조 소스
|
||||
* @param list<string> $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<array{table: string, columns: list<string>}> 검증 통과 소스
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
|
||||
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface;
|
||||
|
||||
/**
|
||||
* CKEditor5 업로드 이미지 관리 화면 서비스
|
||||
*
|
||||
* 참조 여부는 컬럼에 저장하지 않고 조회할 때마다 판정합니다. 참조 관계는 콘텐츠가
|
||||
* 편집될 때마다 바뀌므로 스냅샷을 저장하면 "저장 당시엔 미참조였던" 이미지를 이후에
|
||||
* 오판 삭제할 수 있기 때문입니다. 대신 판정 비용에 상한을 둡니다.
|
||||
*
|
||||
* - 기본 목록: 현재 페이지(≤100행)만 판정 — 페이지 크기가 곧 비용 상한
|
||||
* - 참조 상태 필터: 최신순 스캔 윈도우(SCAN_WINDOW) 안에서만 판정하고, 상한에 걸린
|
||||
* 사실을 응답 메타(`scan_limited`)로 알려 화면이 "최근 N건 기준" 임을 표시하게 한다
|
||||
*/
|
||||
class ImageUploadAdminService
|
||||
{
|
||||
/**
|
||||
* 참조 상태 필터를 적용할 때 훑는 최신 업로드 건수 상한
|
||||
*/
|
||||
public const SCAN_WINDOW = 500;
|
||||
|
||||
/**
|
||||
* @param ImageUploadRepositoryInterface $repository 업로드 리포지토리
|
||||
* @param ImageReferenceScanService $scanner 참조 판정 서비스
|
||||
* @param ImageCleanupService $cleanupService 삭제 서비스 (커맨드와 동일 경로 공유)
|
||||
*/
|
||||
public function __construct(
|
||||
protected ImageUploadRepositoryInterface $repository,
|
||||
protected ImageReferenceScanService $scanner,
|
||||
protected ImageCleanupService $cleanupService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 관리 화면 목록을 조회합니다.
|
||||
*
|
||||
* @param array $filters 필터 (search / referenced / date_from / date_to / sort_by / sort_order)
|
||||
* @param int $perPage 페이지 크기
|
||||
* @param int $page 페이지 번호
|
||||
* @return array{items: Collection<int, Ckeditor5ImageUpload>, pagination: array<string, mixed>, 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<int, Ckeditor5ImageUpload> $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<int, int> $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<int, Ckeditor5ImageUpload>, pagination: array<string, mixed>, 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<int, Ckeditor5ImageUpload> $uploads 업로드 목록
|
||||
*/
|
||||
private function attachReferenceState(Collection $uploads): void
|
||||
{
|
||||
$map = $this->scanner->mapReferenced($uploads);
|
||||
|
||||
foreach ($uploads as $upload) {
|
||||
$upload->setAttribute('referenced', $map[(int) $upload->id] ?? true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
@@ -21,3 +22,23 @@ Route::post('upload', [ImageUploadController::class, 'upload'])
|
||||
Route::get('images/{hash}', [ImageServeController::class, 'serve'])
|
||||
->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');
|
||||
});
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Tests\Feature\Console;
|
||||
|
||||
use App\Extension\Storage\PluginStorageDriver;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
|
||||
use Plugins\Sirsoft\Ckeditor5\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 미참조 이미지 정리 커맨드 테스트
|
||||
*
|
||||
* 인수 기준은 "설정을 만지지 않은 사이트에서 자동 삭제 0" 입니다. 스케줄 게이트가
|
||||
* 설정 조회 실패 시 true 로 폴백하므로, 커맨드가 `--scheduled` 에서 false 폴백으로
|
||||
* 재확인하는지를 회귀로 고정합니다.
|
||||
*/
|
||||
class PruneUnusedImagesCommandTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* 플러그인 설정 조회를 지정 값으로 고정합니다.
|
||||
*
|
||||
* 테스트 환경에는 플러그인이 설치돼 있지 않아 PluginSettingsService 가 기본값을
|
||||
* 그대로 돌려주므로, 설정 분기를 밟으려면 조회 지점을 대체해야 합니다.
|
||||
*
|
||||
* @param array<string, mixed> $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();
|
||||
}
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Tests\Feature\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\PermissionType;
|
||||
use App\Extension\Storage\PluginStorageDriver;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
|
||||
use Plugins\Sirsoft\Ckeditor5\Services\ImageUploadAdminService;
|
||||
use Plugins\Sirsoft\Ckeditor5\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 업로드 이미지 관리 API 테스트
|
||||
*
|
||||
* 조회·삭제 권한이 분리돼 있으므로 권한 경계(403)와 게스트(401)를 함께 고정합니다.
|
||||
*/
|
||||
class ImageUploadAdminControllerTest extends PluginTestCase
|
||||
{
|
||||
private const INDEX_URL = '/api/plugins/sirsoft-ckeditor5/admin/uploads';
|
||||
|
||||
/**
|
||||
* 지정한 업로드 권한만 가진 사용자를 만듭니다.
|
||||
*
|
||||
* @param array<int, string> $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' => '<img src="/api/plugins/sirsoft-ckeditor5/images/bbbbbbbbbbbb">',
|
||||
'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' => '<img src="/api/plugins/sirsoft-ckeditor5/images/dddddddddddd">',
|
||||
'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');
|
||||
}
|
||||
}
|
||||
@@ -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<Ckeditor5AuthFixtures>({
|
||||
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';
|
||||
@@ -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'] } }],
|
||||
});
|
||||
+279
@@ -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<number> {
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
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();
|
||||
});
|
||||
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Tests\Unit\Services;
|
||||
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Extension\Storage\PluginStorageDriver;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
|
||||
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface;
|
||||
use Plugins\Sirsoft\Ckeditor5\Services\ImageCleanupService;
|
||||
use Plugins\Sirsoft\Ckeditor5\Services\ImageReferenceScanService;
|
||||
use Plugins\Sirsoft\Ckeditor5\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 미참조 업로드 이미지 정리 서비스 테스트
|
||||
*
|
||||
* "파일 먼저, 기록 나중" 순서와 행 disk 존중, 참조 보존(두 URL 변종), dry-run 무삭제를
|
||||
* 회귀로 고정합니다.
|
||||
*/
|
||||
class ImageCleanupServiceTest extends PluginTestCase
|
||||
{
|
||||
private ImageCleanupService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Config::set('filesystems.disks.fake_cdn', [
|
||||
'driver' => '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('<img src="/api/plugins/sirsoft-ckeditor5/images/bbbbbbbbbbbb">');
|
||||
|
||||
$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('<img src="https://cdn.test/assets/ckeditor5/images/2026/08/14/used-direct.png">');
|
||||
|
||||
$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]);
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Tests\Unit\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
|
||||
use Plugins\Sirsoft\Ckeditor5\Services\ImageReferenceScanService;
|
||||
use Plugins\Sirsoft\Ckeditor5\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 업로드 이미지 참조 판정 테스트
|
||||
*
|
||||
* 본문에 박히는 URL 이 두 형태(해시 API 폴백형 / 디스크 직접 URL)라 판정 토큰도 둘입니다.
|
||||
* 한 형태만 검사하면 다른 형태로 저장된 이미지를 "미참조" 로 오판해 삭제하게 되므로,
|
||||
* 두 변종 모두를 회귀로 고정합니다.
|
||||
*/
|
||||
class ImageReferenceScanServiceTest extends PluginTestCase
|
||||
{
|
||||
private ImageReferenceScanService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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('<img src="/api/plugins/sirsoft-ckeditor5/images/a1b2c3d4e5f6">');
|
||||
|
||||
$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('<img src="https://cdn.test/assets/ckeditor5/images/2026/08/14/9f8e7d6c-1234.webp">');
|
||||
|
||||
$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('<p>이미지가 없는 본문</p>');
|
||||
|
||||
$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('<img src="/files/aXXXbYc.png">');
|
||||
|
||||
$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('<img src="/api/plugins/sirsoft-ckeditor5/images/deadbeef0000">');
|
||||
|
||||
$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('<img src="/api/plugins/sirsoft-ckeditor5/images/111111111111">');
|
||||
|
||||
$map = $this->service->mapReferenced(collect([$referenced, $unreferenced]));
|
||||
|
||||
$this->assertTrue($map[$referenced->id]);
|
||||
$this->assertFalse($map[$unreferenced->id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* sirsoft-ckeditor5 플러그인 1.0.2 업그레이드 스텝
|
||||
*
|
||||
* 미사용 이미지 자동 정리 설정 두 키(`unusedImageCleanup`, `unusedImageRetentionDays`)를
|
||||
* 기설치본의 저장 설정 파일에 백필한다. defaults.json 은 설치 시점에만 저장 파일을 시드하므로
|
||||
* 이미 설치된 사이트에는 새 키가 생기지 않고, 그 상태에서는 스케줄 게이트가 설정을 찾지 못해
|
||||
* "설정을 만지지 않은 사이트에서 자동 삭제 0" 이 저장 파일 수준에서 보이지 않는다.
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.0.2/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*/
|
||||
class Upgrade_1_0_2 extends AbstractUpgradeStep {}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Plugins\SirsoftCkeditor5\V1_0_2\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 기설치본의 플러그인 설정 파일에 미사용 이미지 정리 설정 두 키를 백필한다.
|
||||
*
|
||||
* 배경:
|
||||
*
|
||||
* `config/settings/defaults.json` 은 플러그인 설치 시점에 저장 설정 파일을 한 번 시드할 뿐,
|
||||
* 이후 조회 경로(PluginSettingsService::get)의 폴백 체계에는 포함되지 않는다. 그래서 이미
|
||||
* 설치된 사이트에는 새 설정 키가 생기지 않는다.
|
||||
*
|
||||
* 자동 정리는 기본 꺼짐이어야 하므로 `unusedImageCleanup` 은 false 로 시드한다. 커맨드가
|
||||
* `--scheduled` 에서 false 폴백으로 다시 확인하므로 이 백필이 없어도 자동 삭제는 일어나지
|
||||
* 않지만, 관리자 설정 화면이 저장값을 그대로 보여주도록 파일에도 명시해 둔다.
|
||||
*
|
||||
* 멱등: 키가 이미 있으면 값을 덮어쓰지 않는다 (운영자가 켠 설정을 되돌리지 않기 위함).
|
||||
*
|
||||
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
|
||||
* - 파일 시스템 + FilePermissionHelper 만 사용
|
||||
* - Service / Manager / Repository 컨테이너 해석 없음
|
||||
*/
|
||||
final class SeedUnusedImageCleanupSettings implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 플러그인 설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/plugins/sirsoft-ckeditor5/settings/setting.json';
|
||||
|
||||
/**
|
||||
* 백필할 기본값 (키 => 기본값).
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
|
||||
- 회원 탈퇴·삭제 시 동의 이력 정리가 중간에 실패하면 일부 항목만 처리된 채 남던 문제를 수정했습니다 — 이제 전부 처리되거나 전부 취소됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 플러그인 설정 화면의 켜기/끄기 스위치가 레이아웃 편집기에서 편집 대상으로 선택되지 않던 문제를 수정했습니다.
|
||||
|
||||
## [1.0.2] - 2026-08-10
|
||||
|
||||
|
||||
@@ -588,7 +588,7 @@
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"type": "composite",
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"name": "banner_enabled"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/", "./"]
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-marketing",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "G7 마케팅 동의 플러그인 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+40
-5
@@ -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<string, unknown> | undefined {
|
||||
if (!node || typeof node !== 'object') {
|
||||
@@ -34,13 +58,24 @@ function classNameOf(node: Record<string, unknown> | 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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user