From d2aa6c7c418ec99e7281e4ef318f4daeaf42a25c Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 17 Apr 2026 08:05:49 +0900 Subject: [PATCH] =?UTF-8?q?refactor(upload):=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=20pre-check=20=EA=B0=80=20=EC=84=9C=EB=B2=84=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EA=B5=AC=EB=8F=85?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트의 `MAX_UPLOAD_BYTES = 100 * 1000 * 1000` 하드코딩 상수를 제거하고 서버 `GET /api/config/public` 응답을 단일 진실 공급원으로 사용. pre-check 자체는 그대로 유지 (UX 개선 — 대용량 파일을 edge proxy 까지 올리기 전 클라이언트에서 즉시 차단). 값의 출처만 서버로 이동. 변경: - frontend/src/lib/stores/config.ts 신규 — publicConfig readable store * 첫 구독 시 `/config/public` 1회 fetch * fetch 실패 시 fallback 100MB 유지 (서버 enforcement 가 본선이라 안전) - +layout.svelte onMount 에서 prewarm refresh() 호출 - UploadDropzone.svelte 에서 `$derived` 로 store 값을 반응형 구독 * `maxBytes` / `maxBytesLabel` 을 파생 * 에러 토스트 문구도 동적 라벨 사용 (`100MB` 하드코딩 제거) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/components/UploadDropzone.svelte | 16 +++--- frontend/src/lib/stores/config.ts | 55 +++++++++++++++++++ frontend/src/routes/+layout.svelte | 3 + 3 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/stores/config.ts diff --git a/frontend/src/lib/components/UploadDropzone.svelte b/frontend/src/lib/components/UploadDropzone.svelte index 44764c4..a420438 100644 --- a/frontend/src/lib/components/UploadDropzone.svelte +++ b/frontend/src/lib/components/UploadDropzone.svelte @@ -2,13 +2,15 @@ import { onMount } from 'svelte'; import { api } from '$lib/api'; import { addToast } from '$lib/stores/toast'; + import { publicConfig } from '$lib/stores/config'; import { Upload } from 'lucide-svelte'; let { onupload = () => {} } = $props(); - // 업로드 크기 한도 (SI 기준 100MB). 백엔드 설정과 동기화 필요 — 추후 서버에서 내려받는 구조로 전환 예정. - // 100MB 초과 파일은 NAS 의 PKM/Inbox 폴더에 직접 두면 file_watcher 가 감시 경로로 수집. - const MAX_UPLOAD_BYTES = 100 * 1000 * 1000; + // 업로드 크기 한도는 서버 `upload.max_bytes` 가 authoritative. 여기서는 pre-check UX 용으로만 사용. + // 실제 enforcement 는 /documents/ POST 413 응답 (서버 스트리밍 검증) 이 담당. + const maxBytes = $derived($publicConfig.upload.max_bytes); + const maxBytesLabel = $derived(`${Math.round($publicConfig.upload.max_bytes / 1_000_000)}MB`); const NAS_FALLBACK_HINT = '대용량 파일은 NAS의 PKM/Inbox 폴더에 두면 자동 수집 대상이 됩니다. 감시 주기와 처리 대기열 상황에 따라 반영 시점은 달라질 수 있습니다.'; let dragging = $state(false); @@ -64,9 +66,9 @@ const allFiles = Array.from(fileList || []); if (allFiles.length === 0) return; - // 사전 크기 검사 — 100MB 초과는 즉시 차단 + NAS file_watcher 안내 - const tooLarge = allFiles.filter(f => f.size > MAX_UPLOAD_BYTES); - const files = allFiles.filter(f => f.size <= MAX_UPLOAD_BYTES); + // 사전 크기 검사 — 서버 한도(maxBytes) 초과는 즉시 차단 + NAS file_watcher 안내 + const tooLarge = allFiles.filter(f => f.size > maxBytes); + const files = allFiles.filter(f => f.size <= maxBytes); if (tooLarge.length > 0) { const names = tooLarge @@ -74,7 +76,7 @@ .join(', '); addToast( 'error', - `100MB 초과 파일은 업로드 불가 (${tooLarge.length}건: ${names}). ${NAS_FALLBACK_HINT}`, + `${maxBytesLabel} 초과 파일은 업로드 불가 (${tooLarge.length}건: ${names}). ${NAS_FALLBACK_HINT}`, 10000 ); } diff --git a/frontend/src/lib/stores/config.ts b/frontend/src/lib/stores/config.ts new file mode 100644 index 0000000..0852608 --- /dev/null +++ b/frontend/src/lib/stores/config.ts @@ -0,0 +1,55 @@ +// 공개 서버 설정 store. +// +// /api/config/public 을 초기 1회 fetch 해서 upload.max_bytes 등 프론트 UX 에 +// 필요한 값을 공급. 서버가 authoritative 소스이며, 여기서는 캐시만 제공. +// +// fetch 실패 시 하드코딩 fallback 값을 유지해 초기 렌더 깜빡임과 offline UX +// 퇴행을 방지. 실제 size enforcement 는 서버 (/documents/ POST 413) 가 담당 +// 하므로 fallback 값이 일시적으로 서버와 불일치해도 보안적 영향 없음. +// +// API 응답 shape: app/api/config.py PublicConfigResponse 참조 + +import { writable } from 'svelte/store'; +import { api } from '$lib/api'; + +export interface PublicConfig { + upload: { + max_bytes: number; + }; +} + +// 서버 fetch 전 fallback. 실제 정책값은 서버 config.yaml `upload.max_bytes`. +const FALLBACK_CONFIG: PublicConfig = { + upload: { + max_bytes: 100_000_000, + }, +}; + +let fetched = false; +let inFlight: Promise | null = null; + +const internal = writable(FALLBACK_CONFIG, (_set) => { + // 첫 구독 시 1회만 fetch + if (!fetched) { + void refresh(); + } +}); + +export const publicConfig = { subscribe: internal.subscribe }; + +export async function refresh(): Promise { + if (inFlight) return inFlight; + inFlight = (async () => { + try { + const data = await api('/config/public'); + internal.set(data); + fetched = true; + } catch (err) { + // fallback 유지. 서버 enforcement 가 본선이므로 UI fallback 은 안전. + console.error('공개 설정 fetch 실패, fallback 사용:', err); + } finally { + inFlight = null; + } + })(); + return inFlight; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c9c2473..3e10a87 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -6,6 +6,7 @@ import { Menu, EllipsisVertical } from 'lucide-svelte'; import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth'; import { toasts, removeToast } from '$lib/stores/toast'; + import { refresh as refreshPublicConfig } from '$lib/stores/config'; import { ui } from '$lib/stores/uiState.svelte'; import Sidebar from '$lib/components/Sidebar.svelte'; import SystemStatusDot from '$lib/components/SystemStatusDot.svelte'; @@ -38,6 +39,8 @@ await tryRefresh(); } authChecked = true; + // 공개 설정 prewarm (인증 상태와 독립적). 실패 시 fallback 유지. + void refreshPublicConfig(); }); $effect(() => {