관리자 업로드 제한 설정 추가
This commit is contained in:
@@ -29,20 +29,30 @@ import { isPostCardThumbnailUrl } from '../../lib/post-card-thumbnail-url.js'
|
|||||||
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
|
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
|
||||||
import {
|
import {
|
||||||
buildDefaultUploadSizeLimits,
|
buildDefaultUploadSizeLimits,
|
||||||
|
DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
||||||
|
DEFAULT_UPLOAD_MAX_TOTAL_SIZE,
|
||||||
formatUploadSizeLimit,
|
formatUploadSizeLimit,
|
||||||
|
getUploadFilesTotalSize,
|
||||||
getMaxUploadBytesForKind,
|
getMaxUploadBytesForKind,
|
||||||
getUploadKind
|
getUploadKind,
|
||||||
|
normalizeUploadMaxFileCount,
|
||||||
|
normalizeUploadMaxTotalSize
|
||||||
} from '../../lib/upload-size-limit.js'
|
} from '../../lib/upload-size-limit.js'
|
||||||
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
|
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
|
||||||
|
|
||||||
const { toast, showToast } = useAdminToast()
|
const { toast, showToast } = useAdminToast()
|
||||||
const runtimeConfig = useRuntimeConfig()
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const { data: uploadSettings } = useFetch('/admin/api/settings', {
|
||||||
|
key: 'admin-markdown-editor-upload-settings'
|
||||||
|
})
|
||||||
const uploadSizeLimits = computed(() => buildDefaultUploadSizeLimits({
|
const uploadSizeLimits = computed(() => buildDefaultUploadSizeLimits({
|
||||||
image: Number(runtimeConfig.public.maxFileSize || 10485760),
|
image: Number(runtimeConfig.public.maxFileSize || 10485760),
|
||||||
video: Number(runtimeConfig.public.maxVideoFileSize || 209715200),
|
video: Number(runtimeConfig.public.maxVideoFileSize || 209715200),
|
||||||
audio: Number(runtimeConfig.public.maxAudioFileSize || 52428800),
|
audio: Number(runtimeConfig.public.maxAudioFileSize || 52428800),
|
||||||
document: Number(runtimeConfig.public.maxDocumentFileSize || 52428800)
|
document: Number(runtimeConfig.public.maxDocumentFileSize || 52428800)
|
||||||
}))
|
}))
|
||||||
|
const uploadMaxFileCount = computed(() => normalizeUploadMaxFileCount(uploadSettings.value?.uploadMaxFileCount || DEFAULT_UPLOAD_MAX_FILE_COUNT))
|
||||||
|
const uploadMaxTotalSize = computed(() => normalizeUploadMaxTotalSize(uploadSettings.value?.uploadMaxTotalSize || DEFAULT_UPLOAD_MAX_TOTAL_SIZE))
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -3241,7 +3251,19 @@ const resolveUploadFetchErrorMessage = (error) => {
|
|||||||
* @returns {string|null} 초과 시 오류 메시지, 아니면 null
|
* @returns {string|null} 초과 시 오류 메시지, 아니면 null
|
||||||
*/
|
*/
|
||||||
const findUploadSizeLimitError = (files) => {
|
const findUploadSizeLimitError = (files) => {
|
||||||
for (const file of Array.from(files)) {
|
const fileList = Array.from(files || [])
|
||||||
|
|
||||||
|
if (fileList.length > uploadMaxFileCount.value) {
|
||||||
|
return `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount.value}개입니다.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadTotalSize = getUploadFilesTotalSize(fileList)
|
||||||
|
|
||||||
|
if (uploadTotalSize > uploadMaxTotalSize.value) {
|
||||||
|
return `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize.value)})`
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of fileList) {
|
||||||
const uploadKind = getUploadKind(file.type, file.name)
|
const uploadKind = getUploadKind(file.type, file.name)
|
||||||
const maxBytes = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits.value)
|
const maxBytes = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits.value)
|
||||||
|
|
||||||
|
|||||||
19
db/migrations/063_site_settings_upload_limits.sql
Normal file
19
db/migrations/063_site_settings_upload_limits.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS upload_max_file_count INTEGER NOT NULL DEFAULT 20,
|
||||||
|
ADD COLUMN IF NOT EXISTS upload_max_total_size BIGINT NOT NULL DEFAULT 314572800;
|
||||||
|
|
||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD CONSTRAINT site_settings_upload_max_file_count_range_check
|
||||||
|
CHECK (upload_max_file_count BETWEEN 1 AND 50)
|
||||||
|
NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD CONSTRAINT site_settings_upload_max_total_size_range_check
|
||||||
|
CHECK (upload_max_total_size BETWEEN 1048576 AND 1073741824)
|
||||||
|
NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE site_settings
|
||||||
|
VALIDATE CONSTRAINT site_settings_upload_max_file_count_range_check;
|
||||||
|
|
||||||
|
ALTER TABLE site_settings
|
||||||
|
VALIDATE CONSTRAINT site_settings_upload_max_total_size_range_check;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 배포 가이드
|
# 배포 가이드
|
||||||
|
|
||||||
> 로컬 기준 v1.10.8에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
> 로컬 기준 v1.10.9에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||||
|
|
||||||
## 빌드 유형
|
## 빌드 유형
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
|
|||||||
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
|
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
|
||||||
```
|
```
|
||||||
|
|
||||||
> v1.9.16부터 공개 목록 표시개수 설정 컬럼을 추가하는 `062_site_settings_display_counts.sql` 마이그레이션이 필요하다.
|
> 지난 버전 마이그레이션을 아직 적용하지 않았다면 `062_site_settings_display_counts.sql`과 `063_site_settings_upload_limits.sql`이 순차 적용되어야 한다. 일반 배포에서는 `npm run db:migrate:prod` 또는 NAS 컨테이너 내부 마이그레이션 명령이 이 순서를 자동 처리한다.
|
||||||
|
|
||||||
### 확인 주소
|
### 확인 주소
|
||||||
|
|
||||||
@@ -423,7 +423,7 @@ Homepage 위젯 연동은 `docs/homepage-widget.md`를 참고한다. Docker에
|
|||||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||||
- NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
|
- NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
|
||||||
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
|
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
|
||||||
- `MAX_FILE_SIZE`, `MAX_VIDEO_FILE_SIZE`, `MAX_AUDIO_FILE_SIZE`, `MAX_DOCUMENT_FILE_SIZE` 환경 변수로 관리자 미디어 업로드 최대 크기를 제한한다. 리버스 프록시(Nginx 등)를 쓰면 `client_max_body_size`가 앱 한도보다 작지 않은지 함께 확인한다.
|
- `MAX_FILE_SIZE`, `MAX_VIDEO_FILE_SIZE`, `MAX_AUDIO_FILE_SIZE`, `MAX_DOCUMENT_FILE_SIZE` 환경 변수로 관리자 미디어 업로드 파일별 최대 크기를 제한한다. 1회 업로드 최대 파일 수와 전체 용량은 관리자 Settings > POST 설정 > 업로드 제한에서 조절한다. 리버스 프록시(Nginx 등)를 쓰면 `client_max_body_size`가 앱 한도보다 작지 않은지 함께 확인한다.
|
||||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||||
|
|
||||||
## 사용자 액션 필요 항목
|
## 사용자 액션 필요 항목
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-07-01 v1.10.9 — 업로드 묶음 제한은 운영자가 설정에서 조절한다
|
||||||
|
|
||||||
|
파일별 최대 크기는 서버 운영 정책에 가까워 환경 변수로 유지하지만, 한 번에 몇 개·총 몇 MB까지 올릴지는 운영자가 콘텐츠 작업량에 따라 자주 조절할 수 있다. NAS 설정 파일을 매번 수정하는 방식은 관리 부담이 커서, 사이트 설정 POST 설정 안에 1회 업로드 파일 수와 전체 용량을 추가했다. 서버는 최종 검증을 담당하고, 관리자 미디어·글쓰기 화면은 같은 설정값으로 사전 오류를 보여 준다. 다만 현재 multipart 파서는 요청 본문을 메모리에 받은 뒤 검사하므로, 더 큰 규모의 업로드 안정화는 스트리밍 파서 도입을 별도 과제로 둔다.
|
||||||
|
|
||||||
## 2026-07-01 v1.10.8 — 라이트박스도 원본 대신 큰 파생 이미지를 우선한다
|
## 2026-07-01 v1.10.8 — 라이트박스도 원본 대신 큰 파생 이미지를 우선한다
|
||||||
|
|
||||||
본문의 크게 보기 기능은 원본 파일 자체를 내려받게 하는 것보다 독자가 이미지를 선명하게 확인할 수 있는 크기를 안정적으로 제공하는 쪽이 목적에 맞다. 따라서 라이트박스에는 2560px WebP 파생 이미지를 우선 사용하고, 별도 원본 보기 UI는 두지 않는다. 파생 이미지가 아직 생성되지 않았거나 로드에 실패한 경우에만 화면 깨짐을 막기 위해 원본 fallback을 유지한다.
|
본문의 크게 보기 기능은 원본 파일 자체를 내려받게 하는 것보다 독자가 이미지를 선명하게 확인할 수 있는 크기를 안정적으로 제공하는 쪽이 목적에 맞다. 따라서 라이트박스에는 2560px WebP 파생 이미지를 우선 사용하고, 별도 원본 보기 UI는 두지 않는다. 파생 이미지가 아직 생성되지 않았거나 로드에 실패한 경우에만 화면 깨짐을 막기 위해 원본 fallback을 유지한다.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
| 파일 | 용도 |
|
| 파일 | 용도 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·용량 문구(서버·클라이언트 공용) |
|
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·1회 파일 수/총량 제한 정규화·용량 문구(서버·클라이언트 공용) |
|
||||||
| lib/post-card-thumbnail-url.js | 게시물 카드 썸네일 `thumbs/*-card.webp`와 본문 표시용 이미지 `thumbs/*-body.webp` URL 판별(대표 이미지·본문 이미지·갤러리 선택 및 서버 검증 공용) |
|
| lib/post-card-thumbnail-url.js | 게시물 카드 썸네일 `thumbs/*-card.webp`와 본문 표시용 이미지 `thumbs/*-body.webp` URL 판별(대표 이미지·본문 이미지·갤러리 선택 및 서버 검증 공용) |
|
||||||
| lib/external-favicon-url.js | 외부 URL origin 기준 Google `gstatic.com/faviconV2` 파비콘 URL 생성(내부 경로는 빈 문자열) |
|
| lib/external-favicon-url.js | 외부 URL origin 기준 Google `gstatic.com/faviconV2` 파비콘 URL 생성(내부 경로는 빈 문자열) |
|
||||||
| lib/navigation-url.js | 네비게이션 URL 접두사 분리·합성·정규화(`https://` 자동 보정, `/` 내부 경로) |
|
| lib/navigation-url.js | 네비게이션 URL 접두사 분리·합성·정규화(`https://` 자동 보정, `/` 내부 경로) |
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
| db/migrations/060_page_listing_metadata.sql | 페이지 홈 진열장 노출 메타 컬럼 추가 |
|
| db/migrations/060_page_listing_metadata.sql | 페이지 홈 진열장 노출 메타 컬럼 추가 |
|
||||||
| db/migrations/061_page_listing_sections.sql | 페이지 홈 진열장 섹션 관리 테이블 추가 |
|
| db/migrations/061_page_listing_sections.sql | 페이지 홈 진열장 섹션 관리 테이블 추가 |
|
||||||
| db/migrations/062_site_settings_display_counts.sql | 사이트 설정 공개 목록 표시개수 컬럼 추가 |
|
| db/migrations/062_site_settings_display_counts.sql | 사이트 설정 공개 목록 표시개수 컬럼 추가 |
|
||||||
|
| db/migrations/063_site_settings_upload_limits.sql | 사이트 설정 관리자 업로드 제한 컬럼 추가 |
|
||||||
|
|
||||||
## 서버 미들웨어
|
## 서버 미들웨어
|
||||||
|
|
||||||
@@ -287,7 +288,7 @@
|
|||||||
| server/routes/admin/api/media/thumbnail.post.js | 게시물 대표 이미지 원본의 목록 카드 썸네일 생성·재생성 API |
|
| server/routes/admin/api/media/thumbnail.post.js | 게시물 대표 이미지 원본의 목록 카드 썸네일 생성·재생성 API |
|
||||||
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
||||||
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
|
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
|
||||||
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 미디어 원본 업로드 API(이미지·비디오·오디오·문서, Unicode 원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata`를 `미분류`로 기록) |
|
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 미디어 원본 업로드 API(이미지·비디오·오디오·문서, 사이트 설정 1회 파일 수·총량 제한, Unicode 원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata`를 `미분류`로 기록) |
|
||||||
| server/routes/admin/api/member-avatar.post.js | 관리자 새 회원 생성 전 썸네일 사전 업로드 API(`/uploads/members/avatars` 저장, WebP 변환·1:1 크롭) |
|
| server/routes/admin/api/member-avatar.post.js | 관리자 새 회원 생성 전 썸네일 사전 업로드 API(`/uploads/members/avatars` 저장, WebP 변환·1:1 크롭) |
|
||||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
|
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
|
||||||
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||||
|
|||||||
15
docs/spec.md
15
docs/spec.md
@@ -464,6 +464,8 @@ components/content/
|
|||||||
| favicon_url | String | 파비콘 이미지 URL |
|
| favicon_url | String | 파비콘 이미지 URL |
|
||||||
| show_post_updated_at | Boolean | 관리자 글 목록 수정일 보조 표시 여부 |
|
| show_post_updated_at | Boolean | 관리자 글 목록 수정일 보조 표시 여부 |
|
||||||
| post_tag_limit | Integer | 게시물별 태그 최대 개수, 기본 5, 허용 범위 1~10 |
|
| post_tag_limit | Integer | 게시물별 태그 최대 개수, 기본 5, 허용 범위 1~10 |
|
||||||
|
| upload_max_file_count | Integer | 관리자 미디어 1회 업로드 최대 파일 수, 기본 20, 허용 범위 1~50 |
|
||||||
|
| upload_max_total_size | BigInt | 관리자 미디어 1회 업로드 최대 전체 용량 바이트, 기본 300MB, 허용 범위 1MB~1GB |
|
||||||
| home_cover_image_url | String | 라이트모드 홈 커버 이미지 URL |
|
| home_cover_image_url | String | 라이트모드 홈 커버 이미지 URL |
|
||||||
| home_cover_dark_image_url | String | 다크모드 홈 커버 이미지 URL |
|
| home_cover_dark_image_url | String | 다크모드 홈 커버 이미지 URL |
|
||||||
| home_cover_title | String | 홈 커버 오버레이 제목 |
|
| home_cover_title | String | 홈 커버 오버레이 제목 |
|
||||||
@@ -650,7 +652,7 @@ components/content/
|
|||||||
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
||||||
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||||
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
||||||
- `POST /admin/api/uploads` - 관리자 게시물·페이지용 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링, `/uploads/posts/YYYY/MM` 저장)
|
- `POST /admin/api/uploads` - 관리자 게시물·페이지용 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링, `/uploads/posts/YYYY/MM` 저장). 종류별 파일 크기는 환경 변수 한도를 따르고, 1회 파일 수·전체 용량은 사이트 설정 `uploadMaxFileCount`, `uploadMaxTotalSize`를 따른다.
|
||||||
- `POST /admin/api/member-avatar` - 관리자 멤버 썸네일 사전 업로드(신규·편집 공통, `/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭). 편집 화면에서는 미리보기만 반영하고 **저장**(`PUT /admin/api/members/:id`) 시 회원에 연결한다.
|
- `POST /admin/api/member-avatar` - 관리자 멤버 썸네일 사전 업로드(신규·편집 공통, `/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭). 편집 화면에서는 미리보기만 반영하고 **저장**(`PUT /admin/api/members/:id`) 시 회원에 연결한다.
|
||||||
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||||
- `POST /admin/api/tags` - 태그 생성
|
- `POST /admin/api/tags` - 태그 생성
|
||||||
@@ -658,8 +660,8 @@ components/content/
|
|||||||
- `PUT /admin/api/tags/:id` - 태그 수정
|
- `PUT /admin/api/tags/:id` - 태그 수정
|
||||||
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
||||||
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
|
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
|
||||||
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, `postTagLimit`, 라이트·다크 홈 커버, 어나운스 바, 사이트 코드 필드 포함)
|
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, `postTagLimit`, 업로드 제한, 라이트·다크 홈 커버, 어나운스 바, 사이트 코드 필드 포함)
|
||||||
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, `postTagLimit`, 라이트·다크 홈 커버, 어나운스 바, `signupBlockedUsernames`, `adsTxt`, `customHeadCode`, `customFooterCode` 포함). `postTagLimit`은 1~10 정수만 허용한다. `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
|
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, `postTagLimit`, 업로드 제한, 라이트·다크 홈 커버, 어나운스 바, `signupBlockedUsernames`, `adsTxt`, `customHeadCode`, `customFooterCode` 포함). `postTagLimit`은 1~10 정수만 허용한다. 업로드 최대 파일 수는 1~50, 업로드 전체 용량은 1MB~1GB 정수만 허용한다. `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
|
||||||
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). 라이트·다크 어느 슬롯에 반영할지는 클라이언트 폼에서 결정하며, `site_settings` 반영은 `PUT` 저장 시 함께 처리한다.
|
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). 라이트·다크 어느 슬롯에 반영할지는 클라이언트 폼에서 결정하며, `site_settings` 반영은 `PUT` 저장 시 함께 처리한다.
|
||||||
- `POST /admin/api/settings/logo` - 로고·파비콘 파일만 업로드(`{ logoUrl, faviconUrl }` 반환). `site_settings` 반영은 사이트 정보 저장 시 `PUT`으로 처리한다.
|
- `POST /admin/api/settings/logo` - 로고·파비콘 파일만 업로드(`{ logoUrl, faviconUrl }` 반환). `site_settings` 반영은 사이트 정보 저장 시 `PUT`으로 처리한다.
|
||||||
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버·어나운스 바·사이트 코드 필드 포함)
|
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버·어나운스 바·사이트 코드 필드 포함)
|
||||||
@@ -829,7 +831,7 @@ components/content/
|
|||||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||||
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 라이트모드와 다크모드 프리뷰를 상하로 모두 표시하고, 각 모드 제목 오른쪽의 이미지 변경·삭제 버튼으로 개별 이미지를 관리한다. 이미지가 비어 있는 모드는 점선 드롭존으로 표시하며 파일 선택과 드래그 앤 드롭 업로드를 지원한다. 커버 파일 업로드·제목·본문은 편집 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
|
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 라이트모드와 다크모드 프리뷰를 상하로 모두 표시하고, 각 모드 제목 오른쪽의 이미지 변경·삭제 버튼으로 개별 이미지를 관리한다. 이미지가 비어 있는 모드는 점선 드롭존으로 표시하며 파일 선택과 드래그 앤 드롭 업로드를 지원한다. 커버 파일 업로드·제목·본문은 편집 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
|
||||||
- **POST 설정**(`show_post_updated_at`, `post_tag_limit`, `home_latest_post_limit`, `post_list_page_size`, `tag_post_list_page_size`, `featured_post_limit`, `page_section_item_limit`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부와 게시물별 태그 최대 개수를 관리한다. `post_tag_limit` 기본값은 5이며 1~10 범위로 제한한다. 표시개수 설정은 Latest, Posts, 태그 게시물, Featured, Pages 섹션의 공개 목록 로드·표시 개수를 관리하며 1~50 범위의 셀렉트 값만 사용한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
|
- **POST 설정**(`show_post_updated_at`, `post_tag_limit`, `home_latest_post_limit`, `post_list_page_size`, `tag_post_list_page_size`, `featured_post_limit`, `page_section_item_limit`, `upload_max_file_count`, `upload_max_total_size`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부와 게시물별 태그 최대 개수, 공개 목록 표시 개수, 관리자 미디어 1회 업로드 제한을 관리한다. `post_tag_limit` 기본값은 5이며 1~10 범위로 제한한다. 표시개수 설정은 Latest, Posts, 태그 게시물, Featured, Pages 섹션의 공개 목록 로드·표시 개수를 관리하며 1~50 범위의 셀렉트 값만 사용한다. 업로드 제한은 기본 파일 20개·전체 300MB이며 파일 수는 1~50, 전체 용량은 1MB~1GB 범위에서 관리한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
|
||||||
- **가입 금지 닉네임**(`signup_blocked_usernames`, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는 `{단어}은 사용할 수 없는 단어입니다.` 형식이다. 기본값: `admin`, `master`, `zenn`, `sori`, `sori.studio`.
|
- **가입 금지 닉네임**(`signup_blocked_usernames`, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는 `{단어}은 사용할 수 없는 단어입니다.` 형식이다. 기본값: `admin`, `master`, `zenn`, `sori`, `sori.studio`.
|
||||||
- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자는 **이번 방문 동안 닫기**(X, `sessionStorage`) 또는 **7일간 보지 않기**(`localStorage`, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어 `updatedAt`이 달라지면 다시 노출된다.
|
- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자는 **이번 방문 동안 닫기**(X, `sessionStorage`) 또는 **7일간 보지 않기**(`localStorage`, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어 `updatedAt`이 달라지면 다시 노출된다.
|
||||||
- **사이트 코드**(`ads_txt`, `custom_head_code`, `custom_footer_code`): `ads_txt`는 루트 `/ads.txt`에서 `text/plain`으로 응답한다. `custom_head_code`는 공개 Nuxt HTML 응답의 `head` 끝에, `custom_footer_code`는 `body` 끝에 원문 HTML로 삽입한다. 관리자 페이지, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 응답에는 삽입하지 않는다.
|
- **사이트 코드**(`ads_txt`, `custom_head_code`, `custom_footer_code`): `ads_txt`는 루트 `/ads.txt`에서 `text/plain`으로 응답한다. `custom_head_code`는 공개 Nuxt HTML 응답의 `head` 끝에, `custom_footer_code`는 `body` 끝에 원문 HTML로 삽입한다. 관리자 페이지, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 응답에는 삽입하지 않는다.
|
||||||
@@ -914,7 +916,7 @@ components/content/
|
|||||||
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
|
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
|
||||||
|
|
||||||
- 관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록).
|
- 관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록).
|
||||||
- 업로드 파일 크기 제한은 종류별 환경 변수를 따른다. 이미지·아바타·로고 등은 `MAX_FILE_SIZE`(기본 10MB), 비디오는 `MAX_VIDEO_FILE_SIZE`(기본 200MB), 오디오는 `MAX_AUDIO_FILE_SIZE`(기본 50MB), 문서·ZIP 등은 `MAX_DOCUMENT_FILE_SIZE`(기본 50MB).
|
- 업로드 파일 크기 제한은 종류별 환경 변수를 따른다. 이미지·아바타·로고 등은 `MAX_FILE_SIZE`(기본 10MB), 비디오는 `MAX_VIDEO_FILE_SIZE`(기본 200MB), 오디오는 `MAX_AUDIO_FILE_SIZE`(기본 50MB), 문서·ZIP 등은 `MAX_DOCUMENT_FILE_SIZE`(기본 50MB). 관리자 게시물·페이지용 미디어 업로드의 1회 파일 수와 전체 용량은 사이트 설정 POST 설정의 업로드 제한을 따른다.
|
||||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||||
- 관리자 미디어 화면 상단에 **미디어 라이브러리**·**카드 썸네일**·**본문 이미지**·**프로필 이미지** 탭을 두어, 라이브러리 탭에서는 원본 업로드 파일만, 카드 썸네일 탭에서는 `/posts/YYYY/MM/thumbs/*-card.webp` 파일만, 본문 이미지 탭에서는 `/posts/YYYY/MM/thumbs/*-body.webp` 파일만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. 라이브러리 탭의 파일 종류 필터는 `전체/이미지/영상/음악/파일`을 표시하고 사용 상태 필터는 중복 `전체` 배지를 피하기 위해 `미사용` 토글만 표시한다. 시스템 파생 탭에서는 전체/미사용 배지형 필터를 표시하고, 목록 카드 오른쪽 위에는 실제 사용 개수 숫자 배지를 표시한다. 카드 썸네일 탭과 본문 이미지 탭에서는 모든 항목이 해당 파생 파일이므로 각각 `카드`, `본문` 배지는 표시하지 않는다.
|
- 관리자 미디어 화면 상단에 **미디어 라이브러리**·**카드 썸네일**·**본문 이미지**·**프로필 이미지** 탭을 두어, 라이브러리 탭에서는 원본 업로드 파일만, 카드 썸네일 탭에서는 `/posts/YYYY/MM/thumbs/*-card.webp` 파일만, 본문 이미지 탭에서는 `/posts/YYYY/MM/thumbs/*-body.webp` 파일만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. 라이브러리 탭의 파일 종류 필터는 `전체/이미지/영상/음악/파일`을 표시하고 사용 상태 필터는 중복 `전체` 배지를 피하기 위해 `미사용` 토글만 표시한다. 시스템 파생 탭에서는 전체/미사용 배지형 필터를 표시하고, 목록 카드 오른쪽 위에는 실제 사용 개수 숫자 배지를 표시한다. 카드 썸네일 탭과 본문 이미지 탭에서는 모든 항목이 해당 파생 파일이므로 각각 `카드`, `본문` 배지는 표시하지 않는다.
|
||||||
- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||||
@@ -959,6 +961,9 @@ MAX_FILE_SIZE=10485760
|
|||||||
MAX_VIDEO_FILE_SIZE=209715200
|
MAX_VIDEO_FILE_SIZE=209715200
|
||||||
MAX_AUDIO_FILE_SIZE=52428800
|
MAX_AUDIO_FILE_SIZE=52428800
|
||||||
MAX_DOCUMENT_FILE_SIZE=52428800
|
MAX_DOCUMENT_FILE_SIZE=52428800
|
||||||
|
MAX_VIDEO_FILE_SIZE=209715200
|
||||||
|
MAX_AUDIO_FILE_SIZE=52428800
|
||||||
|
MAX_DOCUMENT_FILE_SIZE=52428800
|
||||||
AVATAR_MIN_WIDTH=96
|
AVATAR_MIN_WIDTH=96
|
||||||
AVATAR_MIN_HEIGHT=96
|
AVATAR_MIN_HEIGHT=96
|
||||||
AVATAR_MAX_WIDTH=512
|
AVATAR_MAX_WIDTH=512
|
||||||
|
|||||||
@@ -9,9 +9,8 @@
|
|||||||
## 프론트엔드 개발
|
## 프론트엔드 개발
|
||||||
|
|
||||||
- 업로드 총량 제한 및 메모리 사용량 개선.
|
- 업로드 총량 제한 및 메모리 사용량 개선.
|
||||||
- 현재 관리자 업로드는 파일별 제한만 있고 한 번에 올리는 파일 개수·총 용량 제한이 없음.
|
- 관리자 업로드는 사이트 설정에서 1회 최대 파일 수·전체 용량을 조절하고 클라이언트·서버 양쪽에서 검사함.
|
||||||
- 서버는 multipart 전체를 메모리에 받은 뒤 검사하므로, 고용량 다중 업로드 시 메모리 피크가 생길 수 있음.
|
- 서버는 아직 multipart 전체를 메모리에 받은 뒤 검사하므로, 고용량 다중 업로드의 메모리 피크를 더 줄이려면 스트리밍 업로드 파서 도입 검토.
|
||||||
- 클라이언트·서버 양쪽에 총 파일 수/총 바이트 제한을 추가하고, 장기적으로 스트리밍 업로드 파서 도입 검토.
|
|
||||||
- 업로드 파일 캐시 정책 개선.
|
- 업로드 파일 캐시 정책 개선.
|
||||||
- 현재 `/uploads/**` 응답은 `cache-control: no-cache`라 이미지가 많은 페이지에서 재검증 요청이 늘어날 수 있음.
|
- 현재 `/uploads/**` 응답은 `cache-control: no-cache`라 이미지가 많은 페이지에서 재검증 요청이 늘어날 수 있음.
|
||||||
- 업로드 파일명은 충돌 방지로 고유하게 저장되므로 `public, max-age=31536000, immutable` 적용 가능 여부 검토.
|
- 업로드 파일명은 충돌 방지로 고유하게 저장되므로 `public, max-age=31536000, immutable` 적용 가능 여부 검토.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.10.9
|
||||||
|
|
||||||
|
- 사이트 설정 POST 설정에 업로드 제한(1회 최대 파일 수·전체 용량) 관리 추가.
|
||||||
|
- `site_settings`에 `upload_max_file_count`, `upload_max_total_size` 컬럼 추가.
|
||||||
|
- 관리자 미디어·글쓰기 업로드에서 설정된 파일 수·총량 제한을 클라이언트와 서버 양쪽에서 검사하도록 수정.
|
||||||
|
- 업로드 메모리 개선 후속 과제를 스트리밍 업로드 파서 검토로 재정리.
|
||||||
|
|
||||||
## v1.10.8
|
## v1.10.8
|
||||||
|
|
||||||
- 게시물 본문 이미지 저장 시 라이트박스용 WebP(`thumbs/*-lightbox.webp`, 2560px) 생성 추가.
|
- 게시물 본문 이미지 저장 시 라이트박스용 WebP(`thumbs/*-lightbox.webp`, 2560px) 생성 추가.
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov'])
|
|||||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a'])
|
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a'])
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||||
|
|
||||||
|
export const DEFAULT_UPLOAD_MAX_FILE_COUNT = 20
|
||||||
|
export const DEFAULT_UPLOAD_MAX_TOTAL_SIZE = 314572800
|
||||||
|
export const MIN_UPLOAD_MAX_FILE_COUNT = 1
|
||||||
|
export const MAX_UPLOAD_MAX_FILE_COUNT = 50
|
||||||
|
export const MIN_UPLOAD_MAX_TOTAL_SIZE = 1048576
|
||||||
|
export const MAX_UPLOAD_MAX_TOTAL_SIZE = 1073741824
|
||||||
|
export const UPLOAD_FILE_COUNT_OPTIONS = [5, 10, 20, 30, 50]
|
||||||
|
export const UPLOAD_TOTAL_SIZE_OPTIONS = [52428800, 104857600, 209715200, 314572800, 524288000, 1073741824]
|
||||||
|
|
||||||
/** @type {Record<string, string>} 업로드 종류 한글 라벨 */
|
/** @type {Record<string, string>} 업로드 종류 한글 라벨 */
|
||||||
const UPLOAD_KIND_LABELS = {
|
const UPLOAD_KIND_LABELS = {
|
||||||
[UPLOAD_KIND.image]: '이미지',
|
[UPLOAD_KIND.image]: '이미지',
|
||||||
@@ -47,6 +56,69 @@ export const buildDefaultUploadSizeLimits = (overrides = {}) => ({
|
|||||||
...overrides
|
...overrides
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 최대 파일 수를 정규화한다.
|
||||||
|
* @param {unknown} value - 원본 값
|
||||||
|
* @returns {number} 최대 파일 수
|
||||||
|
*/
|
||||||
|
export const normalizeUploadMaxFileCount = (value) => {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return DEFAULT_UPLOAD_MAX_FILE_COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
const rounded = Math.round(parsed)
|
||||||
|
if (rounded < MIN_UPLOAD_MAX_FILE_COUNT) {
|
||||||
|
return MIN_UPLOAD_MAX_FILE_COUNT
|
||||||
|
}
|
||||||
|
if (rounded > MAX_UPLOAD_MAX_FILE_COUNT) {
|
||||||
|
return MAX_UPLOAD_MAX_FILE_COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 최대 총량을 정규화한다.
|
||||||
|
* @param {unknown} value - 원본 값
|
||||||
|
* @returns {number} 최대 총량 바이트
|
||||||
|
*/
|
||||||
|
export const normalizeUploadMaxTotalSize = (value) => {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return DEFAULT_UPLOAD_MAX_TOTAL_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
const rounded = Math.round(parsed)
|
||||||
|
if (rounded < MIN_UPLOAD_MAX_TOTAL_SIZE) {
|
||||||
|
return MIN_UPLOAD_MAX_TOTAL_SIZE
|
||||||
|
}
|
||||||
|
if (rounded > MAX_UPLOAD_MAX_TOTAL_SIZE) {
|
||||||
|
return MAX_UPLOAD_MAX_TOTAL_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 파일 크기를 반환한다.
|
||||||
|
* @param {Object} file - 브라우저 File 또는 서버 multipart 파일 파트
|
||||||
|
* @returns {number} 파일 크기
|
||||||
|
*/
|
||||||
|
export const getUploadFileSize = (file) => {
|
||||||
|
const size = file?.size ?? file?.data?.length ?? 0
|
||||||
|
const parsed = Number(size)
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 파일 전체 크기를 반환한다.
|
||||||
|
* @param {Iterable<Object>|ArrayLike<Object>} files - 업로드 파일 목록
|
||||||
|
* @returns {number} 전체 크기
|
||||||
|
*/
|
||||||
|
export const getUploadFilesTotalSize = (files) => Array.from(files || [])
|
||||||
|
.reduce((total, file) => total + getUploadFileSize(file), 0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MIME·파일명으로 업로드 종류를 판별한다.
|
* MIME·파일명으로 업로드 종류를 판별한다.
|
||||||
* @param {string} [mimeType] - MIME 타입
|
* @param {string} [mimeType] - MIME 타입
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.8",
|
"version": "1.10.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.8",
|
"version": "1.10.9",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.8",
|
"version": "1.10.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import {
|
||||||
|
DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
||||||
|
DEFAULT_UPLOAD_MAX_TOTAL_SIZE,
|
||||||
|
formatUploadSizeLimit,
|
||||||
|
getUploadFilesTotalSize,
|
||||||
|
normalizeUploadMaxFileCount,
|
||||||
|
normalizeUploadMaxTotalSize
|
||||||
|
} from '~/lib/upload-size-limit.js'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'admin'
|
layout: 'admin'
|
||||||
})
|
})
|
||||||
@@ -42,6 +51,11 @@ const draggingUrls = ref([])
|
|||||||
const uploadInputRef = ref(null)
|
const uploadInputRef = ref(null)
|
||||||
const uploadingFiles = ref(false)
|
const uploadingFiles = ref(false)
|
||||||
const deletingSelected = ref(false)
|
const deletingSelected = ref(false)
|
||||||
|
const { data: uploadSettings } = await useFetch('/admin/api/settings', {
|
||||||
|
key: 'admin-media-upload-settings'
|
||||||
|
})
|
||||||
|
const uploadMaxFileCount = computed(() => normalizeUploadMaxFileCount(uploadSettings.value?.uploadMaxFileCount || DEFAULT_UPLOAD_MAX_FILE_COUNT))
|
||||||
|
const uploadMaxTotalSize = computed(() => normalizeUploadMaxTotalSize(uploadSettings.value?.uploadMaxTotalSize || DEFAULT_UPLOAD_MAX_TOTAL_SIZE))
|
||||||
|
|
||||||
const { toast, showToast } = useAdminToast()
|
const { toast, showToast } = useAdminToast()
|
||||||
|
|
||||||
@@ -617,6 +631,19 @@ const uploadSelectedFiles = async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const files = Array.from(input.files)
|
const files = Array.from(input.files)
|
||||||
|
|
||||||
|
if (files.length > uploadMaxFileCount.value) {
|
||||||
|
showToast('error', `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount.value}개입니다.`)
|
||||||
|
input.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getUploadFilesTotalSize(files) > uploadMaxTotalSize.value) {
|
||||||
|
showToast('error', `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize.value)})`)
|
||||||
|
input.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ import {
|
|||||||
POST_LIST_COUNT_OPTIONS,
|
POST_LIST_COUNT_OPTIONS,
|
||||||
normalizeDisplayCount
|
normalizeDisplayCount
|
||||||
} from '~/lib/display-count-settings.js'
|
} from '~/lib/display-count-settings.js'
|
||||||
|
import {
|
||||||
|
DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
||||||
|
DEFAULT_UPLOAD_MAX_TOTAL_SIZE,
|
||||||
|
UPLOAD_FILE_COUNT_OPTIONS,
|
||||||
|
UPLOAD_TOTAL_SIZE_OPTIONS,
|
||||||
|
formatUploadSizeLimit,
|
||||||
|
normalizeUploadMaxFileCount,
|
||||||
|
normalizeUploadMaxTotalSize
|
||||||
|
} from '~/lib/upload-size-limit.js'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'admin'
|
layout: 'admin'
|
||||||
@@ -125,7 +134,9 @@ const postSnapshot = reactive({
|
|||||||
postListPageSize: DEFAULT_POST_LIST_PAGE_SIZE,
|
postListPageSize: DEFAULT_POST_LIST_PAGE_SIZE,
|
||||||
tagPostListPageSize: DEFAULT_TAG_POST_LIST_PAGE_SIZE,
|
tagPostListPageSize: DEFAULT_TAG_POST_LIST_PAGE_SIZE,
|
||||||
featuredPostLimit: DEFAULT_FEATURED_POST_LIMIT,
|
featuredPostLimit: DEFAULT_FEATURED_POST_LIMIT,
|
||||||
pageSectionItemLimit: DEFAULT_PAGE_SECTION_ITEM_LIMIT
|
pageSectionItemLimit: DEFAULT_PAGE_SECTION_ITEM_LIMIT,
|
||||||
|
uploadMaxFileCount: DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
||||||
|
uploadMaxTotalSize: DEFAULT_UPLOAD_MAX_TOTAL_SIZE
|
||||||
})
|
})
|
||||||
/** 편집 시작 시점의 메인 화면 커버(취소 시 복원용) */
|
/** 편집 시작 시점의 메인 화면 커버(취소 시 복원용) */
|
||||||
const homeCoverSnapshot = reactive({
|
const homeCoverSnapshot = reactive({
|
||||||
@@ -194,6 +205,8 @@ const form = reactive({
|
|||||||
tagPostListPageSize: normalizeDisplayCount(settings.value?.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
|
tagPostListPageSize: normalizeDisplayCount(settings.value?.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
|
||||||
featuredPostLimit: normalizeDisplayCount(settings.value?.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
|
featuredPostLimit: normalizeDisplayCount(settings.value?.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
|
||||||
pageSectionItemLimit: normalizeDisplayCount(settings.value?.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
|
pageSectionItemLimit: normalizeDisplayCount(settings.value?.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
|
||||||
|
uploadMaxFileCount: normalizeUploadMaxFileCount(settings.value?.uploadMaxFileCount),
|
||||||
|
uploadMaxTotalSize: normalizeUploadMaxTotalSize(settings.value?.uploadMaxTotalSize),
|
||||||
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
|
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
|
||||||
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
|
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
|
||||||
homeCoverTitle: settings.value?.homeCoverTitle || '',
|
homeCoverTitle: settings.value?.homeCoverTitle || '',
|
||||||
@@ -258,6 +271,8 @@ const hasPostChanges = computed(() => editPost.value
|
|||||||
|| normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE) !== normalizeDisplayCount(postSnapshot.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
|
|| normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE) !== normalizeDisplayCount(postSnapshot.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
|
||||||
|| normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT) !== normalizeDisplayCount(postSnapshot.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
|
|| normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT) !== normalizeDisplayCount(postSnapshot.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
|
||||||
|| normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT) !== normalizeDisplayCount(postSnapshot.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
|
|| normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT) !== normalizeDisplayCount(postSnapshot.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
|
||||||
|
|| normalizeUploadMaxFileCount(form.uploadMaxFileCount) !== normalizeUploadMaxFileCount(postSnapshot.uploadMaxFileCount)
|
||||||
|
|| normalizeUploadMaxTotalSize(form.uploadMaxTotalSize) !== normalizeUploadMaxTotalSize(postSnapshot.uploadMaxTotalSize)
|
||||||
))
|
))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1164,6 +1179,8 @@ const buildSiteSettingsPayload = () => ({
|
|||||||
tagPostListPageSize: normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
|
tagPostListPageSize: normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
|
||||||
featuredPostLimit: normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
|
featuredPostLimit: normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
|
||||||
pageSectionItemLimit: normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
|
pageSectionItemLimit: normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
|
||||||
|
uploadMaxFileCount: normalizeUploadMaxFileCount(form.uploadMaxFileCount),
|
||||||
|
uploadMaxTotalSize: normalizeUploadMaxTotalSize(form.uploadMaxTotalSize),
|
||||||
homeCoverImageUrl: form.homeCoverImageUrl || '',
|
homeCoverImageUrl: form.homeCoverImageUrl || '',
|
||||||
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
|
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
|
||||||
homeCoverTitle: form.homeCoverTitle || '',
|
homeCoverTitle: form.homeCoverTitle || '',
|
||||||
@@ -1399,12 +1416,16 @@ const beginEditPost = () => {
|
|||||||
postSnapshot.tagPostListPageSize = normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
|
postSnapshot.tagPostListPageSize = normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
|
||||||
postSnapshot.featuredPostLimit = normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
|
postSnapshot.featuredPostLimit = normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
|
||||||
postSnapshot.pageSectionItemLimit = normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
|
postSnapshot.pageSectionItemLimit = normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
|
||||||
|
postSnapshot.uploadMaxFileCount = normalizeUploadMaxFileCount(form.uploadMaxFileCount)
|
||||||
|
postSnapshot.uploadMaxTotalSize = normalizeUploadMaxTotalSize(form.uploadMaxTotalSize)
|
||||||
form.postTagLimit = normalizePostTagLimit(form.postTagLimit)
|
form.postTagLimit = normalizePostTagLimit(form.postTagLimit)
|
||||||
form.homeLatestPostLimit = postSnapshot.homeLatestPostLimit
|
form.homeLatestPostLimit = postSnapshot.homeLatestPostLimit
|
||||||
form.postListPageSize = postSnapshot.postListPageSize
|
form.postListPageSize = postSnapshot.postListPageSize
|
||||||
form.tagPostListPageSize = postSnapshot.tagPostListPageSize
|
form.tagPostListPageSize = postSnapshot.tagPostListPageSize
|
||||||
form.featuredPostLimit = postSnapshot.featuredPostLimit
|
form.featuredPostLimit = postSnapshot.featuredPostLimit
|
||||||
form.pageSectionItemLimit = postSnapshot.pageSectionItemLimit
|
form.pageSectionItemLimit = postSnapshot.pageSectionItemLimit
|
||||||
|
form.uploadMaxFileCount = postSnapshot.uploadMaxFileCount
|
||||||
|
form.uploadMaxTotalSize = postSnapshot.uploadMaxTotalSize
|
||||||
editPost.value = true
|
editPost.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1420,6 +1441,8 @@ const cancelEditPost = () => {
|
|||||||
form.tagPostListPageSize = normalizeDisplayCount(postSnapshot.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
|
form.tagPostListPageSize = normalizeDisplayCount(postSnapshot.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
|
||||||
form.featuredPostLimit = normalizeDisplayCount(postSnapshot.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
|
form.featuredPostLimit = normalizeDisplayCount(postSnapshot.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
|
||||||
form.pageSectionItemLimit = normalizeDisplayCount(postSnapshot.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
|
form.pageSectionItemLimit = normalizeDisplayCount(postSnapshot.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
|
||||||
|
form.uploadMaxFileCount = normalizeUploadMaxFileCount(postSnapshot.uploadMaxFileCount)
|
||||||
|
form.uploadMaxTotalSize = normalizeUploadMaxTotalSize(postSnapshot.uploadMaxTotalSize)
|
||||||
editPost.value = false
|
editPost.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1445,12 +1468,16 @@ const savePostSection = async () => {
|
|||||||
postSnapshot.tagPostListPageSize = normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
|
postSnapshot.tagPostListPageSize = normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
|
||||||
postSnapshot.featuredPostLimit = normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
|
postSnapshot.featuredPostLimit = normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
|
||||||
postSnapshot.pageSectionItemLimit = normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
|
postSnapshot.pageSectionItemLimit = normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
|
||||||
|
postSnapshot.uploadMaxFileCount = normalizeUploadMaxFileCount(form.uploadMaxFileCount)
|
||||||
|
postSnapshot.uploadMaxTotalSize = normalizeUploadMaxTotalSize(form.uploadMaxTotalSize)
|
||||||
form.postTagLimit = postSnapshot.postTagLimit
|
form.postTagLimit = postSnapshot.postTagLimit
|
||||||
form.homeLatestPostLimit = postSnapshot.homeLatestPostLimit
|
form.homeLatestPostLimit = postSnapshot.homeLatestPostLimit
|
||||||
form.postListPageSize = postSnapshot.postListPageSize
|
form.postListPageSize = postSnapshot.postListPageSize
|
||||||
form.tagPostListPageSize = postSnapshot.tagPostListPageSize
|
form.tagPostListPageSize = postSnapshot.tagPostListPageSize
|
||||||
form.featuredPostLimit = postSnapshot.featuredPostLimit
|
form.featuredPostLimit = postSnapshot.featuredPostLimit
|
||||||
form.pageSectionItemLimit = postSnapshot.pageSectionItemLimit
|
form.pageSectionItemLimit = postSnapshot.pageSectionItemLimit
|
||||||
|
form.uploadMaxFileCount = postSnapshot.uploadMaxFileCount
|
||||||
|
form.uploadMaxTotalSize = postSnapshot.uploadMaxTotalSize
|
||||||
editPost.value = false
|
editPost.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2104,6 +2131,40 @@ onBeforeUnmount(() => {
|
|||||||
{{ form.description?.trim() ? form.description : '—' }}
|
{{ form.description?.trim() ? form.description : '—' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="admin-settings-screen__field grid gap-3 border-t border-[#eceff2] pt-5">
|
||||||
|
<div>
|
||||||
|
<span class="font-bold text-[#15171a]">업로드 제한</span>
|
||||||
|
<p class="mt-1 text-xs leading-relaxed text-[#657080]">
|
||||||
|
파일 추가나 글쓰기 미디어 업로드에서 한 번에 처리할 수 있는 최대치를 선택합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label class="grid gap-1.5">
|
||||||
|
<span class="text-xs font-semibold text-[#657080]">파일 수</span>
|
||||||
|
<span class="relative">
|
||||||
|
<select
|
||||||
|
v-model.number="form.uploadMaxFileCount"
|
||||||
|
class="h-10 w-full appearance-none rounded-md border border-[#dce0e5] bg-white px-3 pr-10 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
|
>
|
||||||
|
<option v-for="option in UPLOAD_FILE_COUNT_OPTIONS" :key="`upload-count-${option}`" :value="option">{{ option }}개</option>
|
||||||
|
</select>
|
||||||
|
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-1.5">
|
||||||
|
<span class="text-xs font-semibold text-[#657080]">전체 용량</span>
|
||||||
|
<span class="relative">
|
||||||
|
<select
|
||||||
|
v-model.number="form.uploadMaxTotalSize"
|
||||||
|
class="h-10 w-full appearance-none rounded-md border border-[#dce0e5] bg-white px-3 pr-10 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
|
>
|
||||||
|
<option v-for="option in UPLOAD_TOTAL_SIZE_OPTIONS" :key="`upload-total-${option}`" :value="option">{{ formatUploadSizeLimit(option) }}</option>
|
||||||
|
</select>
|
||||||
|
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="admin-settings-screen__card-body grid gap-5">
|
<div v-else class="admin-settings-screen__card-body grid gap-5">
|
||||||
@@ -2540,6 +2601,22 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-5 grid gap-3 border-t border-[#eceff2] pt-5">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span class="font-bold text-[#15171a]">업로드 제한</span>
|
||||||
|
<span class="text-xs text-[#657080]">1회 업로드 기준</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 text-xs text-[#657080] sm:grid-cols-2">
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
|
||||||
|
<span>파일 수</span>
|
||||||
|
<strong class="font-mono text-[#15171a]">{{ normalizeUploadMaxFileCount(form.uploadMaxFileCount) }}개</strong>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
|
||||||
|
<span>전체 용량</span>
|
||||||
|
<strong class="font-mono text-[#15171a]">{{ formatUploadSizeLimit(normalizeUploadMaxTotalSize(form.uploadMaxTotalSize)) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import {
|
|||||||
DEFAULT_TAG_POST_LIST_PAGE_SIZE,
|
DEFAULT_TAG_POST_LIST_PAGE_SIZE,
|
||||||
normalizeDisplayCount
|
normalizeDisplayCount
|
||||||
} from '../../lib/display-count-settings.js'
|
} from '../../lib/display-count-settings.js'
|
||||||
|
import {
|
||||||
|
normalizeUploadMaxFileCount,
|
||||||
|
normalizeUploadMaxTotalSize
|
||||||
|
} from '../../lib/upload-size-limit.js'
|
||||||
import { normalizeSocialLinks } from '../../lib/social-links.js'
|
import { normalizeSocialLinks } from '../../lib/social-links.js'
|
||||||
import {
|
import {
|
||||||
createPostBodyThumbnailsForContent,
|
createPostBodyThumbnailsForContent,
|
||||||
@@ -157,6 +161,8 @@ const mapSiteSettingsRow = (row) => ({
|
|||||||
tagPostListPageSize: normalizeDisplayCount(row.tag_post_list_page_size, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
|
tagPostListPageSize: normalizeDisplayCount(row.tag_post_list_page_size, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
|
||||||
featuredPostLimit: normalizeDisplayCount(row.featured_post_limit, DEFAULT_FEATURED_POST_LIMIT),
|
featuredPostLimit: normalizeDisplayCount(row.featured_post_limit, DEFAULT_FEATURED_POST_LIMIT),
|
||||||
pageSectionItemLimit: normalizeDisplayCount(row.page_section_item_limit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
|
pageSectionItemLimit: normalizeDisplayCount(row.page_section_item_limit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
|
||||||
|
uploadMaxFileCount: normalizeUploadMaxFileCount(row.upload_max_file_count),
|
||||||
|
uploadMaxTotalSize: normalizeUploadMaxTotalSize(row.upload_max_total_size),
|
||||||
homeCoverImageUrl: row.home_cover_image_url || '',
|
homeCoverImageUrl: row.home_cover_image_url || '',
|
||||||
homeCoverDarkImageUrl: row.home_cover_dark_image_url || '',
|
homeCoverDarkImageUrl: row.home_cover_dark_image_url || '',
|
||||||
homeCoverTitle: row.home_cover_title || '',
|
homeCoverTitle: row.home_cover_title || '',
|
||||||
@@ -1463,6 +1469,8 @@ export const updateSiteSettings = async (input) => {
|
|||||||
tag_post_list_page_size,
|
tag_post_list_page_size,
|
||||||
featured_post_limit,
|
featured_post_limit,
|
||||||
page_section_item_limit,
|
page_section_item_limit,
|
||||||
|
upload_max_file_count,
|
||||||
|
upload_max_total_size,
|
||||||
home_cover_image_url,
|
home_cover_image_url,
|
||||||
home_cover_dark_image_url,
|
home_cover_dark_image_url,
|
||||||
home_cover_title,
|
home_cover_title,
|
||||||
@@ -1503,6 +1511,8 @@ export const updateSiteSettings = async (input) => {
|
|||||||
${normalizeDisplayCount(input.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)},
|
${normalizeDisplayCount(input.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)},
|
||||||
${normalizeDisplayCount(input.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)},
|
${normalizeDisplayCount(input.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)},
|
||||||
${normalizeDisplayCount(input.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)},
|
${normalizeDisplayCount(input.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)},
|
||||||
|
${normalizeUploadMaxFileCount(input.uploadMaxFileCount)},
|
||||||
|
${normalizeUploadMaxTotalSize(input.uploadMaxTotalSize)},
|
||||||
${input.homeCoverImageUrl || ''},
|
${input.homeCoverImageUrl || ''},
|
||||||
${input.homeCoverDarkImageUrl || ''},
|
${input.homeCoverDarkImageUrl || ''},
|
||||||
${input.homeCoverTitle || ''},
|
${input.homeCoverTitle || ''},
|
||||||
@@ -1543,6 +1553,8 @@ export const updateSiteSettings = async (input) => {
|
|||||||
tag_post_list_page_size = EXCLUDED.tag_post_list_page_size,
|
tag_post_list_page_size = EXCLUDED.tag_post_list_page_size,
|
||||||
featured_post_limit = EXCLUDED.featured_post_limit,
|
featured_post_limit = EXCLUDED.featured_post_limit,
|
||||||
page_section_item_limit = EXCLUDED.page_section_item_limit,
|
page_section_item_limit = EXCLUDED.page_section_item_limit,
|
||||||
|
upload_max_file_count = EXCLUDED.upload_max_file_count,
|
||||||
|
upload_max_total_size = EXCLUDED.upload_max_total_size,
|
||||||
home_cover_image_url = EXCLUDED.home_cover_image_url,
|
home_cover_image_url = EXCLUDED.home_cover_image_url,
|
||||||
home_cover_dark_image_url = EXCLUDED.home_cover_dark_image_url,
|
home_cover_dark_image_url = EXCLUDED.home_cover_dark_image_url,
|
||||||
home_cover_title = EXCLUDED.home_cover_title,
|
home_cover_title = EXCLUDED.home_cover_title,
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import { createError, readMultipartFormData } from 'h3'
|
|||||||
import {
|
import {
|
||||||
buildDefaultUploadSizeLimits,
|
buildDefaultUploadSizeLimits,
|
||||||
formatUploadSizeLimit,
|
formatUploadSizeLimit,
|
||||||
|
getUploadFilesTotalSize,
|
||||||
getMaxUploadBytesForKind,
|
getMaxUploadBytesForKind,
|
||||||
getUploadKind,
|
getUploadKind,
|
||||||
getUploadKindLabel
|
getUploadKindLabel,
|
||||||
|
normalizeUploadMaxFileCount,
|
||||||
|
normalizeUploadMaxTotalSize
|
||||||
} from '../../../../lib/upload-size-limit.js'
|
} from '../../../../lib/upload-size-limit.js'
|
||||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||||
import { getRuntimeEnvNumber } from '../../../utils/runtime-env.js'
|
import { getRuntimeEnvNumber } from '../../../utils/runtime-env.js'
|
||||||
import { upsertMediaMetadataCategory } from '../../../utils/media-library'
|
import { upsertMediaMetadataCategory } from '../../../utils/media-library'
|
||||||
|
import { getSiteSettings } from '../../../repositories/content-repository'
|
||||||
|
|
||||||
const allowedUploadTypes = new Map([
|
const allowedUploadTypes = new Map([
|
||||||
['image/jpeg', '.jpg'],
|
['image/jpeg', '.jpg'],
|
||||||
@@ -135,6 +139,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
audio: getRuntimeEnvNumber('MAX_AUDIO_FILE_SIZE', 'maxAudioFileSize', 52428800),
|
audio: getRuntimeEnvNumber('MAX_AUDIO_FILE_SIZE', 'maxAudioFileSize', 52428800),
|
||||||
document: getRuntimeEnvNumber('MAX_DOCUMENT_FILE_SIZE', 'maxDocumentFileSize', 52428800)
|
document: getRuntimeEnvNumber('MAX_DOCUMENT_FILE_SIZE', 'maxDocumentFileSize', 52428800)
|
||||||
})
|
})
|
||||||
|
const settings = await getSiteSettings()
|
||||||
|
const uploadMaxFileCount = normalizeUploadMaxFileCount(settings.uploadMaxFileCount)
|
||||||
|
const uploadMaxTotalSize = normalizeUploadMaxTotalSize(settings.uploadMaxTotalSize)
|
||||||
const formData = await readMultipartFormData(event)
|
const formData = await readMultipartFormData(event)
|
||||||
const files = (formData || []).filter((part) => part.name === 'files' && part.filename)
|
const files = (formData || []).filter((part) => part.name === 'files' && part.filename)
|
||||||
|
|
||||||
@@ -145,6 +152,22 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (files.length > uploadMaxFileCount) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 413,
|
||||||
|
message: `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount}개입니다.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadTotalSize = getUploadFilesTotalSize(files)
|
||||||
|
|
||||||
|
if (uploadTotalSize > uploadMaxTotalSize) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 413,
|
||||||
|
message: `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize)})`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const year = String(now.getFullYear())
|
const year = String(now.getFullYear())
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ import {
|
|||||||
MIN_DISPLAY_COUNT,
|
MIN_DISPLAY_COUNT,
|
||||||
normalizeDisplayCount
|
normalizeDisplayCount
|
||||||
} from '../../lib/display-count-settings.js'
|
} from '../../lib/display-count-settings.js'
|
||||||
|
import {
|
||||||
|
DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
||||||
|
DEFAULT_UPLOAD_MAX_TOTAL_SIZE,
|
||||||
|
MAX_UPLOAD_MAX_FILE_COUNT,
|
||||||
|
MAX_UPLOAD_MAX_TOTAL_SIZE,
|
||||||
|
MIN_UPLOAD_MAX_FILE_COUNT,
|
||||||
|
MIN_UPLOAD_MAX_TOTAL_SIZE,
|
||||||
|
normalizeUploadMaxFileCount,
|
||||||
|
normalizeUploadMaxTotalSize
|
||||||
|
} from '../../lib/upload-size-limit.js'
|
||||||
|
|
||||||
export const adminSiteSettingsInputSchema = z.object({
|
export const adminSiteSettingsInputSchema = z.object({
|
||||||
title: z.string().trim().min(1),
|
title: z.string().trim().min(1),
|
||||||
@@ -52,6 +62,8 @@ export const adminSiteSettingsInputSchema = z.object({
|
|||||||
tagPostListPageSize: z.coerce.number().int().min(MIN_DISPLAY_COUNT).max(MAX_DISPLAY_COUNT).optional().default(DEFAULT_TAG_POST_LIST_PAGE_SIZE),
|
tagPostListPageSize: z.coerce.number().int().min(MIN_DISPLAY_COUNT).max(MAX_DISPLAY_COUNT).optional().default(DEFAULT_TAG_POST_LIST_PAGE_SIZE),
|
||||||
featuredPostLimit: z.coerce.number().int().min(MIN_DISPLAY_COUNT).max(MAX_DISPLAY_COUNT).optional().default(DEFAULT_FEATURED_POST_LIMIT),
|
featuredPostLimit: z.coerce.number().int().min(MIN_DISPLAY_COUNT).max(MAX_DISPLAY_COUNT).optional().default(DEFAULT_FEATURED_POST_LIMIT),
|
||||||
pageSectionItemLimit: z.coerce.number().int().min(MIN_DISPLAY_COUNT).max(MAX_DISPLAY_COUNT).optional().default(DEFAULT_PAGE_SECTION_ITEM_LIMIT),
|
pageSectionItemLimit: z.coerce.number().int().min(MIN_DISPLAY_COUNT).max(MAX_DISPLAY_COUNT).optional().default(DEFAULT_PAGE_SECTION_ITEM_LIMIT),
|
||||||
|
uploadMaxFileCount: z.coerce.number().int().min(MIN_UPLOAD_MAX_FILE_COUNT).max(MAX_UPLOAD_MAX_FILE_COUNT).optional().default(DEFAULT_UPLOAD_MAX_FILE_COUNT),
|
||||||
|
uploadMaxTotalSize: z.coerce.number().int().min(MIN_UPLOAD_MAX_TOTAL_SIZE).max(MAX_UPLOAD_MAX_TOTAL_SIZE).optional().default(DEFAULT_UPLOAD_MAX_TOTAL_SIZE),
|
||||||
homeCoverImageUrl: z.string().trim().max(500).optional().default(''),
|
homeCoverImageUrl: z.string().trim().max(500).optional().default(''),
|
||||||
homeCoverDarkImageUrl: z.string().trim().max(500).optional().default(''),
|
homeCoverDarkImageUrl: z.string().trim().max(500).optional().default(''),
|
||||||
homeCoverTitle: z.string().trim().max(120).optional().default(''),
|
homeCoverTitle: z.string().trim().max(120).optional().default(''),
|
||||||
@@ -109,6 +121,8 @@ export const adminSiteSettingsInputSchema = z.object({
|
|||||||
tagPostListPageSize: normalizeDisplayCount(data.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
|
tagPostListPageSize: normalizeDisplayCount(data.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
|
||||||
featuredPostLimit: normalizeDisplayCount(data.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
|
featuredPostLimit: normalizeDisplayCount(data.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
|
||||||
pageSectionItemLimit: normalizeDisplayCount(data.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
|
pageSectionItemLimit: normalizeDisplayCount(data.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
|
||||||
|
uploadMaxFileCount: normalizeUploadMaxFileCount(data.uploadMaxFileCount),
|
||||||
|
uploadMaxTotalSize: normalizeUploadMaxTotalSize(data.uploadMaxTotalSize),
|
||||||
announcementUrl: normalizeAnnouncementUrl(data.announcementUrl),
|
announcementUrl: normalizeAnnouncementUrl(data.announcementUrl),
|
||||||
announcementBackgroundColor: normalizeAnnouncementBackgroundColor(data.announcementBackgroundColor),
|
announcementBackgroundColor: normalizeAnnouncementBackgroundColor(data.announcementBackgroundColor),
|
||||||
announcementAlignment: normalizeAnnouncementAlignment(data.announcementAlignment),
|
announcementAlignment: normalizeAnnouncementAlignment(data.announcementAlignment),
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
DEFAULT_POST_LIST_PAGE_SIZE,
|
DEFAULT_POST_LIST_PAGE_SIZE,
|
||||||
DEFAULT_TAG_POST_LIST_PAGE_SIZE
|
DEFAULT_TAG_POST_LIST_PAGE_SIZE
|
||||||
} from '../../lib/display-count-settings.js'
|
} from '../../lib/display-count-settings.js'
|
||||||
|
import {
|
||||||
|
DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
||||||
|
DEFAULT_UPLOAD_MAX_TOTAL_SIZE
|
||||||
|
} from '../../lib/upload-size-limit.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기본 사이트 설정 반환
|
* 기본 사이트 설정 반환
|
||||||
@@ -37,6 +41,8 @@ export const getDefaultSiteSettings = () => {
|
|||||||
tagPostListPageSize: DEFAULT_TAG_POST_LIST_PAGE_SIZE,
|
tagPostListPageSize: DEFAULT_TAG_POST_LIST_PAGE_SIZE,
|
||||||
featuredPostLimit: DEFAULT_FEATURED_POST_LIMIT,
|
featuredPostLimit: DEFAULT_FEATURED_POST_LIMIT,
|
||||||
pageSectionItemLimit: DEFAULT_PAGE_SECTION_ITEM_LIMIT,
|
pageSectionItemLimit: DEFAULT_PAGE_SECTION_ITEM_LIMIT,
|
||||||
|
uploadMaxFileCount: DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
||||||
|
uploadMaxTotalSize: DEFAULT_UPLOAD_MAX_TOTAL_SIZE,
|
||||||
homeCoverImageUrl: '',
|
homeCoverImageUrl: '',
|
||||||
homeCoverDarkImageUrl: '',
|
homeCoverDarkImageUrl: '',
|
||||||
homeCoverTitle: '',
|
homeCoverTitle: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user