에디터 미디어 선택 로직 분리
This commit is contained in:
@@ -21,6 +21,13 @@ import {
|
||||
getEditorHistoryEntrySelection,
|
||||
getEditorHistoryEntryValue
|
||||
} from '../../lib/markdown-editor-history.js'
|
||||
import {
|
||||
createMediaBlockMarkdown,
|
||||
getMediaItemKind,
|
||||
getMediaPickerPreviewUrl,
|
||||
isMediaItemSelectableForTarget,
|
||||
isUploadFileAllowedForMediaTarget
|
||||
} from '../../lib/markdown-editor-media.js'
|
||||
import {
|
||||
filterSlashCommands,
|
||||
getSlashCommandFocusTarget,
|
||||
@@ -33,13 +40,6 @@ import {
|
||||
import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
|
||||
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
|
||||
import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js'
|
||||
import {
|
||||
getPostBodyPreviewThumbnailUrl,
|
||||
isPageCoverThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl,
|
||||
withGeneratedImageCacheVersion
|
||||
} from '../../lib/post-card-thumbnail-url.js'
|
||||
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
|
||||
import {
|
||||
buildDefaultUploadSizeLimits,
|
||||
@@ -1788,98 +1788,12 @@ const parseEditorImageLine = (line) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 크기를 표시용 문자열로 변환한다.
|
||||
* @param {number} size - 바이트 크기
|
||||
* @returns {string} 표시용 크기
|
||||
*/
|
||||
const formatMediaFileSize = (size) => {
|
||||
const value = Number(size || 0)
|
||||
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (value < 1024) {
|
||||
return `${value} B`
|
||||
}
|
||||
|
||||
if (value < 1024 * 1024) {
|
||||
return `${Math.round(value / 1024)} KB`
|
||||
}
|
||||
|
||||
return `${(value / 1024 / 1024).toFixed(1).replace(/\.0$/, '')} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목 종류를 반환한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
|
||||
*/
|
||||
const getMediaItemKind = (item) => {
|
||||
if (item?.kind) {
|
||||
return item.kind
|
||||
}
|
||||
|
||||
const source = `${item?.extension || ''} ${item?.url || ''}`.toLowerCase()
|
||||
|
||||
if (/\.(jpe?g|png|webp|gif)(\?|$)/i.test(source)) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
if (/\.(mp4|webm|mov)(\?|$)/i.test(source)) {
|
||||
return 'video'
|
||||
}
|
||||
|
||||
if (/\.(mp3|wav|ogg|m4a)(\?|$)/i.test(source)) {
|
||||
return 'audio'
|
||||
}
|
||||
|
||||
return 'file'
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목이 현재 선택 대상과 맞는지 확인한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 선택 가능 여부
|
||||
*/
|
||||
const isMediaItemSelectableForTarget = (item) => {
|
||||
if (isPostCardThumbnailUrl(item?.url) || isPostBodyThumbnailUrl(item?.url) || isPageCoverThumbnailUrl(item?.url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const kind = getMediaItemKind(item)
|
||||
|
||||
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
|
||||
return kind === 'image'
|
||||
}
|
||||
|
||||
return kind === mediaPickerTarget.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다.
|
||||
* 선택·삽입 값은 원본 URL을 유지하고, 화면 표시만 파생 이미지를 우선 사용한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {string} 미리보기 URL
|
||||
*/
|
||||
const getMediaPickerPreviewUrl = (item) => {
|
||||
const url = String(item?.url || '')
|
||||
|
||||
if (getMediaItemKind(item) !== 'image') {
|
||||
return url
|
||||
}
|
||||
|
||||
if (item?.bodyThumbnailStatus?.hasThumbnail) {
|
||||
return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(url), `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
}
|
||||
|
||||
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
|
||||
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
const isMediaItemSelectableForCurrentTarget = (item) => isMediaItemSelectableForTarget(item, mediaPickerTarget.value)
|
||||
|
||||
/**
|
||||
* 미디어 선택 모달 파생 이미지 로드 실패 시 원본 URL로 되돌린다.
|
||||
@@ -1904,47 +1818,7 @@ const onMediaPickerImageError = (event, item) => {
|
||||
* @returns {boolean} 허용 여부
|
||||
*/
|
||||
const isUploadFileAllowedForPicker = (file) => {
|
||||
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
|
||||
return file.type.startsWith('image/')
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'video') {
|
||||
return file.type.startsWith('video/')
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'audio') {
|
||||
return file.type.startsWith('audio/')
|
||||
}
|
||||
|
||||
return !file.type.startsWith('image/') && !file.type.startsWith('video/') && !file.type.startsWith('audio/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목으로 Prose 미디어 블록을 만든다.
|
||||
* @param {'video'|'audio'|'file'} blockType - 블록 유형
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {string[]} 마크다운 줄
|
||||
*/
|
||||
const createMediaBlockMarkdown = (blockType, item) => {
|
||||
const title = item.title || item.name || ''
|
||||
|
||||
if (blockType === 'video') {
|
||||
return [':::video', `url=${item.url}`, `title=${title}`, 'caption=', ':::']
|
||||
}
|
||||
|
||||
if (blockType === 'audio') {
|
||||
return [':::audio', `url=${item.url}`, `title=${title}`, 'description=', ':::']
|
||||
}
|
||||
|
||||
return [
|
||||
':::file',
|
||||
`url=${item.url}`,
|
||||
`title=${item.name || title}`,
|
||||
'description=',
|
||||
`name=${item.name || title}`,
|
||||
`size=${formatMediaFileSize(item.size)}`,
|
||||
':::'
|
||||
]
|
||||
return isUploadFileAllowedForMediaTarget(file, mediaPickerTarget.value)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3194,7 +3068,7 @@ const closeMediaPicker = () => {
|
||||
* 현재 미디어 선택 페이지의 표시 목록
|
||||
* @returns {Array<Object>} 표시할 미디어 목록
|
||||
*/
|
||||
const filteredMediaItems = computed(() => mediaItems.value.filter(isMediaItemSelectableForTarget))
|
||||
const filteredMediaItems = computed(() => mediaItems.value.filter(isMediaItemSelectableForCurrentTarget))
|
||||
|
||||
const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1))
|
||||
|
||||
@@ -3545,7 +3419,7 @@ const uploadFromMediaModal = async (files) => {
|
||||
try {
|
||||
const uploadedFiles = await uploadMediaFiles(files)
|
||||
const uploadedUrls = uploadedFiles
|
||||
.filter(isMediaItemSelectableForTarget)
|
||||
.filter(isMediaItemSelectableForCurrentTarget)
|
||||
.map((file) => file.url)
|
||||
selectedMediaItemMap.value = uploadedFiles.reduce((map, file) => {
|
||||
if (uploadedUrls.includes(file.url)) {
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
|
||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||
| lib/markdown-editor-history.js | 관리자 Markdown-first 에디터 undo/redo 히스토리 항목 생성, 레거시 문자열 항목 본문 추출, 선택 범위 보정 |
|
||||
| lib/markdown-editor-media.js | 관리자 Markdown-first 에디터 미디어 선택 모달의 미디어 종류 판별, 대상별 선택 가능 여부, 미리보기 URL, 업로드 MIME 허용, fenced 미디어 블록 생성 |
|
||||
| lib/markdown-block-parser.js | 공개 Markdown 본문 문자열을 제목·문단·이미지·갤러리·fenced 블록·인용·목록·코드·표 렌더링 블록으로 변환 |
|
||||
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·링크·iframe·HTML·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 인용 옵션 공통 파서 연결, 코드 fenced 블록 여닫는 펜스 쌍 판별 |
|
||||
| lib/markdown-iframe-embed.js | 게시물 본문 iframe 블록의 코드 판별과 안전 속성 추출, `allowfullscreen`을 `allow=fullscreen` 권한으로 정규화 |
|
||||
@@ -119,6 +120,7 @@
|
||||
| tests/content-image-urls.test.js | 공개 본문 이미지 파생 URL·srcset·갤러리 sizes 단위 회귀 테스트 |
|
||||
| tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 |
|
||||
| tests/markdown-editor-history.test.js | 관리자 Markdown 에디터 undo/redo 히스토리 항목과 선택 범위 보정 단위 회귀 테스트 |
|
||||
| tests/markdown-editor-media.test.js | 관리자 Markdown 에디터 미디어 선택 helper의 종류·선택 가능 여부·미리보기·블록 생성 단위 회귀 테스트 |
|
||||
| tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·미디어 action·포커스 대상·원문 줄 교체 삽입 단위 회귀 테스트 |
|
||||
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
|
||||
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.69
|
||||
|
||||
- 관리자 Markdown 에디터의 미디어 선택 모달 종류 판별·대상별 선택 가능 여부·미리보기 URL·업로드 MIME 판별·미디어 블록 생성 로직을 공통 helper로 분리.
|
||||
- 미디어 선택 helper의 파일 크기 표시·종류 판별·파생 썸네일 제외·fenced 미디어 블록 생성 단위 테스트 추가.
|
||||
|
||||
## v1.11.68
|
||||
|
||||
- 관리자 Markdown 에디터의 히스토리 항목 생성·본문 값 추출·선택 범위 보정 로직을 공통 helper로 분리.
|
||||
|
||||
153
lib/markdown-editor-media.js
Normal file
153
lib/markdown-editor-media.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
getPostBodyPreviewThumbnailUrl,
|
||||
isPageCoverThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl,
|
||||
withGeneratedImageCacheVersion
|
||||
} from './post-card-thumbnail-url.js'
|
||||
|
||||
/**
|
||||
* 파일 크기를 표시용 문자열로 변환한다.
|
||||
* @param {number} size - 바이트 크기
|
||||
* @returns {string} 표시용 크기
|
||||
*/
|
||||
export const formatMediaFileSize = (size) => {
|
||||
const value = Number(size || 0)
|
||||
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (value < 1024) {
|
||||
return `${value} B`
|
||||
}
|
||||
|
||||
if (value < 1024 * 1024) {
|
||||
return `${Math.round(value / 1024)} KB`
|
||||
}
|
||||
|
||||
return `${(value / 1024 / 1024).toFixed(1).replace(/\.0$/, '')} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목 종류를 반환한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
|
||||
*/
|
||||
export const getMediaItemKind = (item) => {
|
||||
if (item?.kind) {
|
||||
return item.kind
|
||||
}
|
||||
|
||||
const source = `${item?.extension || ''} ${item?.url || ''}`.toLowerCase()
|
||||
|
||||
if (/\.(jpe?g|png|webp|gif)(\?|$)/i.test(source)) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
if (/\.(mp4|webm|mov)(\?|$)/i.test(source)) {
|
||||
return 'video'
|
||||
}
|
||||
|
||||
if (/\.(mp3|wav|ogg|m4a)(\?|$)/i.test(source)) {
|
||||
return 'audio'
|
||||
}
|
||||
|
||||
return 'file'
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목이 현재 선택 대상과 맞는지 확인한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @param {string} target - 선택 대상
|
||||
* @returns {boolean} 선택 가능 여부
|
||||
*/
|
||||
export const isMediaItemSelectableForTarget = (item, target) => {
|
||||
if (isPostCardThumbnailUrl(item?.url) || isPostBodyThumbnailUrl(item?.url) || isPageCoverThumbnailUrl(item?.url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const kind = getMediaItemKind(item)
|
||||
|
||||
if (['image', 'gallery', 'active-gallery'].includes(target)) {
|
||||
return kind === 'image'
|
||||
}
|
||||
|
||||
return kind === target
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다.
|
||||
* 선택·삽입 값은 원본 URL을 유지하고, 화면 표시만 파생 이미지를 우선 사용한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {string} 미리보기 URL
|
||||
*/
|
||||
export const getMediaPickerPreviewUrl = (item) => {
|
||||
const url = String(item?.url || '')
|
||||
|
||||
if (getMediaItemKind(item) !== 'image') {
|
||||
return url
|
||||
}
|
||||
|
||||
if (item?.bodyThumbnailStatus?.hasThumbnail) {
|
||||
return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(url), `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
}
|
||||
|
||||
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
|
||||
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭된 파일이 현재 미디어 선택 대상에 맞는지 확인한다.
|
||||
* @param {{ type?: string }} file - 브라우저 파일
|
||||
* @param {string} target - 선택 대상
|
||||
* @returns {boolean} 허용 여부
|
||||
*/
|
||||
export const isUploadFileAllowedForMediaTarget = (file, target) => {
|
||||
const type = String(file?.type || '')
|
||||
|
||||
if (['image', 'gallery', 'active-gallery'].includes(target)) {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
|
||||
if (target === 'video') {
|
||||
return type.startsWith('video/')
|
||||
}
|
||||
|
||||
if (target === 'audio') {
|
||||
return type.startsWith('audio/')
|
||||
}
|
||||
|
||||
return !type.startsWith('image/') && !type.startsWith('video/') && !type.startsWith('audio/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목으로 Prose 미디어 블록을 만든다.
|
||||
* @param {'video'|'audio'|'file'} blockType - 블록 유형
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {string[]} 마크다운 줄
|
||||
*/
|
||||
export const createMediaBlockMarkdown = (blockType, item) => {
|
||||
const title = item.title || item.name || ''
|
||||
|
||||
if (blockType === 'video') {
|
||||
return [':::video', `url=${item.url}`, `title=${title}`, 'caption=', ':::']
|
||||
}
|
||||
|
||||
if (blockType === 'audio') {
|
||||
return [':::audio', `url=${item.url}`, `title=${title}`, 'description=', ':::']
|
||||
}
|
||||
|
||||
return [
|
||||
':::file',
|
||||
`url=${item.url}`,
|
||||
`title=${item.name || title}`,
|
||||
'description=',
|
||||
`name=${item.name || title}`,
|
||||
`size=${formatMediaFileSize(item.size)}`,
|
||||
':::'
|
||||
]
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.68",
|
||||
"version": "1.11.69",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.68",
|
||||
"version": "1.11.69",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.68",
|
||||
"version": "1.11.69",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
64
tests/markdown-editor-media.test.js
Normal file
64
tests/markdown-editor-media.test.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
createMediaBlockMarkdown,
|
||||
formatMediaFileSize,
|
||||
getMediaItemKind,
|
||||
getMediaPickerPreviewUrl,
|
||||
isMediaItemSelectableForTarget,
|
||||
isUploadFileAllowedForMediaTarget
|
||||
} from '../lib/markdown-editor-media.js'
|
||||
|
||||
test('미디어 파일 크기를 표시 문자열로 변환한다', () => {
|
||||
assert.equal(formatMediaFileSize(512), '512 B')
|
||||
assert.equal(formatMediaFileSize(1536), '2 KB')
|
||||
assert.equal(formatMediaFileSize(1024 * 1024 * 1.5), '1.5 MB')
|
||||
assert.equal(formatMediaFileSize(0), '')
|
||||
})
|
||||
|
||||
test('미디어 항목 kind가 없으면 확장자와 URL로 종류를 판별한다', () => {
|
||||
assert.equal(getMediaItemKind({ url: '/uploads/a.webp' }), 'image')
|
||||
assert.equal(getMediaItemKind({ url: '/uploads/a.mp4?x=1' }), 'video')
|
||||
assert.equal(getMediaItemKind({ url: '/uploads/a.m4a' }), 'audio')
|
||||
assert.equal(getMediaItemKind({ url: '/uploads/a.zip' }), 'file')
|
||||
})
|
||||
|
||||
test('선택 대상별로 허용 미디어를 판별하고 파생 썸네일은 제외한다', () => {
|
||||
assert.equal(isMediaItemSelectableForTarget({ url: '/uploads/posts/2026/07/a.png' }, 'image'), true)
|
||||
assert.equal(isMediaItemSelectableForTarget({ url: '/uploads/posts/2026/07/a.mp4' }, 'image'), false)
|
||||
assert.equal(isMediaItemSelectableForTarget({ url: '/uploads/posts/2026/07/a.mp4' }, 'video'), true)
|
||||
assert.equal(isMediaItemSelectableForTarget({ url: '/uploads/posts/2026/07/thumbs/a-card.webp' }, 'image'), false)
|
||||
assert.equal(isMediaItemSelectableForTarget({ url: '/uploads/posts/2026/07/thumbs/a-body-720.webp' }, 'gallery'), false)
|
||||
})
|
||||
|
||||
test('미디어 선택 미리보기는 본문 파생 썸네일을 우선한다', () => {
|
||||
assert.equal(getMediaPickerPreviewUrl({
|
||||
url: '/uploads/posts/2026/07/a.png',
|
||||
updatedAt: '2026-07-24T00:00:00.000Z',
|
||||
size: 100,
|
||||
bodyThumbnailStatus: {
|
||||
hasThumbnail: true
|
||||
}
|
||||
}), '/uploads/posts/2026/07/thumbs/a-body-720.webp?v=2026-07-24T00%3A00%3A00.000Z-100')
|
||||
})
|
||||
|
||||
test('업로드 파일은 선택 대상별 MIME 타입으로 허용 여부를 판별한다', () => {
|
||||
assert.equal(isUploadFileAllowedForMediaTarget({ type: 'image/png' }, 'gallery'), true)
|
||||
assert.equal(isUploadFileAllowedForMediaTarget({ type: 'video/mp4' }, 'video'), true)
|
||||
assert.equal(isUploadFileAllowedForMediaTarget({ type: 'audio/mpeg' }, 'audio'), true)
|
||||
assert.equal(isUploadFileAllowedForMediaTarget({ type: 'application/pdf' }, 'file'), true)
|
||||
assert.equal(isUploadFileAllowedForMediaTarget({ type: 'image/png' }, 'file'), false)
|
||||
})
|
||||
|
||||
test('미디어 항목으로 fenced 미디어 블록을 만든다', () => {
|
||||
assert.deepEqual(createMediaBlockMarkdown('video', {
|
||||
url: '/uploads/a.mp4',
|
||||
title: '영상'
|
||||
}), [':::video', 'url=/uploads/a.mp4', 'title=영상', 'caption=', ':::'])
|
||||
|
||||
assert.deepEqual(createMediaBlockMarkdown('file', {
|
||||
url: '/uploads/a.pdf',
|
||||
name: 'a.pdf',
|
||||
size: 2048
|
||||
}), [':::file', 'url=/uploads/a.pdf', 'title=a.pdf', 'description=', 'name=a.pdf', 'size=2 KB', ':::'])
|
||||
})
|
||||
Reference in New Issue
Block a user