fix: 프론트엔드 1단계 — XSS 수정 + Svelte 5 변환 + 필터/아이콘/a11y
- [critical] DOMPurify 적용 (FORBID_TAGS/ATTR, ALLOW_UNKNOWN_PROTOCOLS) - [high] $: → $derived 변환 (documents/[id]) - [high] 태그/소스 필터 구현 (filterTag, filterSource) - FormatIcon: docx/xlsx/pptx/odt/ods/odp/dwg/dxf 추가 - editTab 선언 순서 수정 - debounceTimer 미사용 변수 제거 - Toast role="status" aria-live 추가 - marked 옵션: mangle/headerIds false Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2245
frontend/package-lock.json
generated
Normal file
2245
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"lucide-svelte": "^0.400.0",
|
"lucide-svelte": "^0.400.0",
|
||||||
"marked": "^15.0.0"
|
"marked": "^15.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,20 @@
|
|||||||
import { api, getAccessToken } from '$lib/api';
|
import { api, getAccessToken } from '$lib/api';
|
||||||
import { addToast } from '$lib/stores/ui';
|
import { addToast } from '$lib/stores/ui';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
import { ExternalLink, Save, RefreshCw } from 'lucide-svelte';
|
import { ExternalLink, Save, RefreshCw } from 'lucide-svelte';
|
||||||
|
|
||||||
|
// marked + sanitize
|
||||||
|
marked.use({ mangle: false, headerIds: false });
|
||||||
|
function renderMd(text) {
|
||||||
|
return DOMPurify.sanitize(marked(text), {
|
||||||
|
USE_PROFILES: { html: true },
|
||||||
|
FORBID_TAGS: ['style', 'script'],
|
||||||
|
FORBID_ATTR: ['onerror', 'onclick'],
|
||||||
|
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let { doc } = $props();
|
let { doc } = $props();
|
||||||
let fullDoc = $state(null);
|
let fullDoc = $state(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -12,6 +24,7 @@
|
|||||||
// Markdown 편집
|
// Markdown 편집
|
||||||
let editMode = $state(false);
|
let editMode = $state(false);
|
||||||
let editContent = $state('');
|
let editContent = $state('');
|
||||||
|
let editTab = $state('edit');
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let rawMarkdown = $state('');
|
let rawMarkdown = $state('');
|
||||||
|
|
||||||
@@ -74,8 +87,6 @@
|
|||||||
editTab = 'edit';
|
editTab = 'edit';
|
||||||
}
|
}
|
||||||
|
|
||||||
let editTab = $state('edit'); // 'edit' | 'preview'
|
|
||||||
|
|
||||||
async function saveContent() {
|
async function saveContent() {
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
@@ -178,13 +189,13 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex-1 overflow-auto p-4 markdown-body">
|
<div class="flex-1 overflow-auto p-4 markdown-body">
|
||||||
{@html marked(editContent)}
|
{@html renderMd(editContent)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="p-4 markdown-body">
|
<div class="p-4 markdown-body">
|
||||||
{@html marked(fullDoc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
|
{@html renderMd(fullDoc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if viewerType === 'pdf'}
|
{:else if viewerType === 'pdf'}
|
||||||
|
|||||||
@@ -22,6 +22,17 @@
|
|||||||
eml: Mail,
|
eml: Mail,
|
||||||
odoc: FileText,
|
odoc: FileText,
|
||||||
osheet: FileSpreadsheet,
|
osheet: FileSpreadsheet,
|
||||||
|
docx: FileText,
|
||||||
|
doc: FileText,
|
||||||
|
xlsx: FileSpreadsheet,
|
||||||
|
xls: FileSpreadsheet,
|
||||||
|
pptx: Presentation,
|
||||||
|
ppt: Presentation,
|
||||||
|
odt: FileText,
|
||||||
|
ods: FileSpreadsheet,
|
||||||
|
odp: Presentation,
|
||||||
|
dwg: FileCode,
|
||||||
|
dxf: FileCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
let Icon = $derived(ICON_MAP[format?.toLowerCase()] || FileQuestion);
|
let Icon = $derived(ICON_MAP[format?.toLowerCase()] || FileQuestion);
|
||||||
|
|||||||
@@ -113,7 +113,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Toast -->
|
<!-- Toast -->
|
||||||
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm" role="status" aria-live="polite">
|
||||||
{#each $toasts as toast (toast.id)}
|
{#each $toasts as toast (toast.id)}
|
||||||
<button
|
<button
|
||||||
class="px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 cursor-pointer text-left"
|
class="px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 cursor-pointer text-left"
|
||||||
|
|||||||
@@ -17,17 +17,20 @@
|
|||||||
let searchResults = $state(null);
|
let searchResults = $state(null);
|
||||||
let selectedDoc = $state(null);
|
let selectedDoc = $state(null);
|
||||||
let infoPanelOpen = $state(false);
|
let infoPanelOpen = $state(false);
|
||||||
let debounceTimer;
|
|
||||||
|
|
||||||
// URL params → filter
|
// URL params → filter
|
||||||
let currentPage = $derived(parseInt($page.url.searchParams.get('page') || '1'));
|
let currentPage = $derived(parseInt($page.url.searchParams.get('page') || '1'));
|
||||||
let filterDomain = $derived($page.url.searchParams.get('domain') || '');
|
let filterDomain = $derived($page.url.searchParams.get('domain') || '');
|
||||||
let filterSubGroup = $derived($page.url.searchParams.get('sub_group') || '');
|
let filterSubGroup = $derived($page.url.searchParams.get('sub_group') || '');
|
||||||
|
let filterTag = $derived($page.url.searchParams.get('tag') || '');
|
||||||
|
let filterSource = $derived($page.url.searchParams.get('source') || '');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const _p = currentPage;
|
const _p = currentPage;
|
||||||
const _d = filterDomain;
|
const _d = filterDomain;
|
||||||
const _s = filterSubGroup;
|
const _s = filterSubGroup;
|
||||||
|
const _t = filterTag;
|
||||||
|
const _src = filterSource;
|
||||||
const urlQ = $page.url.searchParams.get('q') || '';
|
const urlQ = $page.url.searchParams.get('q') || '';
|
||||||
const urlMode = $page.url.searchParams.get('mode') || 'hybrid';
|
const urlMode = $page.url.searchParams.get('mode') || 'hybrid';
|
||||||
|
|
||||||
@@ -52,6 +55,8 @@
|
|||||||
params.set('page_size', '20');
|
params.set('page_size', '20');
|
||||||
if (filterDomain) params.set('domain', filterDomain);
|
if (filterDomain) params.set('domain', filterDomain);
|
||||||
if (filterSubGroup) params.set('sub_group', filterSubGroup);
|
if (filterSubGroup) params.set('sub_group', filterSubGroup);
|
||||||
|
if (filterTag) params.set('tag', filterTag);
|
||||||
|
if (filterSource) params.set('source', filterSource);
|
||||||
|
|
||||||
const data = await api(`/documents/?${params}`);
|
const data = await api(`/documents/?${params}`);
|
||||||
documents = data.items;
|
documents = data.items;
|
||||||
@@ -132,7 +137,7 @@
|
|||||||
|
|
||||||
let totalPages = $derived(Math.ceil(total / 20));
|
let totalPages = $derived(Math.ceil(total / 20));
|
||||||
let items = $derived(searchResults || documents);
|
let items = $derived(searchResults || documents);
|
||||||
let hasActiveFilters = $derived(!!filterDomain || !!filterSubGroup || !!searchQuery);
|
let hasActiveFilters = $derived(!!filterDomain || !!filterSubGroup || !!filterTag || !!filterSource || !!searchQuery);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKeydown} />
|
<svelte:window on:keydown={handleKeydown} />
|
||||||
|
|||||||
@@ -4,12 +4,23 @@
|
|||||||
import { api, getAccessToken } from '$lib/api';
|
import { api, getAccessToken } from '$lib/api';
|
||||||
import { addToast } from '$lib/stores/ui';
|
import { addToast } from '$lib/stores/ui';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
import TagPill from '$lib/components/TagPill.svelte';
|
import TagPill from '$lib/components/TagPill.svelte';
|
||||||
|
|
||||||
let doc = null;
|
marked.use({ mangle: false, headerIds: false });
|
||||||
let loading = true;
|
function renderMd(text) {
|
||||||
|
return DOMPurify.sanitize(marked(text), {
|
||||||
|
USE_PROFILES: { html: true },
|
||||||
|
FORBID_TAGS: ['style', 'script'],
|
||||||
|
FORBID_ATTR: ['onerror', 'onclick'],
|
||||||
|
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$: docId = $page.params.id;
|
let doc = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
let docId = $derived($page.params.id);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -21,8 +32,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 포맷별 뷰어 타입
|
let viewerType = $derived(doc ? getViewerType(doc.file_format) : 'none');
|
||||||
$: viewerType = doc ? getViewerType(doc.file_format) : 'none';
|
|
||||||
|
|
||||||
function getViewerType(format) {
|
function getViewerType(format) {
|
||||||
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
|
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
|
||||||
@@ -52,7 +62,7 @@
|
|||||||
<div class="lg:col-span-2 bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 min-h-[500px]">
|
<div class="lg:col-span-2 bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 min-h-[500px]">
|
||||||
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
|
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
|
||||||
<div class="prose prose-invert prose-sm max-w-none">
|
<div class="prose prose-invert prose-sm max-w-none">
|
||||||
{@html marked(doc.extracted_text || '*텍스트 추출 대기 중*')}
|
{@html renderMd(doc.extracted_text || '*텍스트 추출 대기 중*')}
|
||||||
</div>
|
</div>
|
||||||
{:else if viewerType === 'pdf'}
|
{:else if viewerType === 'pdf'}
|
||||||
<iframe
|
<iframe
|
||||||
|
|||||||
Reference in New Issue
Block a user