refactor(upload): 프론트 pre-check 가 서버 공개 설정을 구독하도록 전환

프론트의 `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) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-17 08:05:49 +09:00
parent 7d2e678ea1
commit d2aa6c7c41
3 changed files with 67 additions and 7 deletions
@@ -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
);
}
+55
View File
@@ -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<void> | null = null;
const internal = writable<PublicConfig>(FALLBACK_CONFIG, (_set) => {
// 첫 구독 시 1회만 fetch
if (!fetched) {
void refresh();
}
});
export const publicConfig = { subscribe: internal.subscribe };
export async function refresh(): Promise<void> {
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const data = await api<PublicConfig>('/config/public');
internal.set(data);
fetched = true;
} catch (err) {
// fallback 유지. 서버 enforcement 가 본선이므로 UI fallback 은 안전.
console.error('공개 설정 fetch 실패, fallback 사용:', err);
} finally {
inFlight = null;
}
})();
return inFlight;
}
+3
View File
@@ -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(() => {