Security+UX: Add DOMPurify XSS protection, security headers, admin auth gate, import file size limit, due date color coding, swipe/scroll conflict prevention

This commit is contained in:
2026-08-20 22:58:47 +09:00
parent 36c8d690d9
commit 8212f16d28
8 changed files with 136 additions and 22 deletions
+25 -1
View File
@@ -1,6 +1,8 @@
"use client";
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useI18n } from "@/lib/i18n";
import { getDemoStore } from "@/lib/mockData";
@@ -46,17 +48,39 @@ const INITIAL_ADMIN_USERS: AdminUser[] = [
export default function AdminPage() {
const { t } = useI18n();
const router = useRouter();
const { data: session, status } = useSession();
const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS);
const [search, setSearch] = useState("");
const [totalTasks, setTotalTasks] = useState(67);
const [totalLists, setTotalLists] = useState(9);
// 인증 게이트: 비로그인 시 /login으로 리디렉션
useEffect(() => {
if (status === "unauthenticated") {
router.replace("/login");
}
}, [status, router]);
useEffect(() => {
const store = getDemoStore();
if (store.tasks) setTotalTasks(store.tasks.length);
if (store.lists) setTotalLists(store.lists.length);
}, []);
// 로딩 중 스피너
if (status === "loading" || status === "unauthenticated") {
return (
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)" }}>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>🔒</div>
<p style={{ color: "var(--text-secondary)" }}>Checking access...</p>
</div>
</div>
);
}
const toggleRole = (id: string) => {
setUsers((prev) =>
prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u))
+12 -2
View File
@@ -16,12 +16,22 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "file and listId required" }, { status: 400 });
}
// 파일 크기 제한: 5MB
const MAX_SIZE = 5 * 1024 * 1024;
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: "File too large. Maximum size is 5MB." }, { status: 413 });
}
// 허용 확장자 검증
const ext = file.name.split(".").pop()?.toLowerCase();
if (!["csv", "ics"].includes(ext ?? "")) {
return NextResponse.json({ error: "Unsupported file format. Use CSV or ICS." }, { status: 400 });
}
const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } });
if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 });
const text = await file.text();
const ext = file.name.split(".").pop()?.toLowerCase();
let imported = 0;
if (ext === "csv") {
+9 -2
View File
@@ -1,6 +1,7 @@
"use client";
"use client";
import React, { useState, useRef, useCallback, useEffect } from "react";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { useI18n } from "@/lib/i18n";
interface MarkdownNoteEditorProps {
@@ -114,7 +115,13 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
return `<span class="markdown-checkbox-box" data-idx="${id}"></span>`;
});
return rawHtml;
// XSS 방어: DOMPurify sanitize (링크 target=_blank, class, data-idx 허용)
const sanitized = DOMPurify.sanitize(rawHtml, {
ADD_ATTR: ["target", "rel", "data-idx", "class"],
ALLOW_DATA_ATTR: true,
});
return sanitized;
} catch {
return value;
}
+22 -3
View File
@@ -46,6 +46,8 @@ export function TaskDetail({
// Mobile Bottom-Sheet: swipe down to close
const touchStartY = useRef<number | null>(null);
const touchStartX = useRef<number | null>(null);
const gestureDirection = useRef<"vertical" | "horizontal" | null>(null);
const [panelTranslateY, setPanelTranslateY] = useState(0);
const [isMobile, setIsMobile] = useState(false);
@@ -306,15 +308,30 @@ export function TaskDetail({
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
);
// Touch handlers: 모바일에서 아래로 스와이프 → 패널 닫기
// Touch handlers: 모바일에서 아래로 스와이프 → 패널 닫기 (스크롤과 충돌 방지)
const handleTouchStart = (e: React.TouchEvent) => {
// 스와이프 핸들 또는 detail-header 영역에서만 드래그 닫기 허용
const target = e.target as HTMLElement;
const isHandle = target.closest(".mobile-swipe-handle") || target.closest(".detail-header");
if (!isHandle || !isMobile) return;
touchStartY.current = e.touches[0].clientY;
touchStartX.current = e.touches[0].clientX;
gestureDirection.current = null;
};
const handleTouchMove = (e: React.TouchEvent) => {
if (touchStartY.current === null) return;
if (touchStartY.current === null || touchStartX.current === null) return;
const deltaY = e.touches[0].clientY - touchStartY.current;
if (deltaY > 0) {
const deltaX = e.touches[0].clientX - touchStartX.current;
// 초기 제스처 방향 결정 (첫 10px 이동 기준)
if (gestureDirection.current === null && (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10)) {
gestureDirection.current = Math.abs(deltaY) > Math.abs(deltaX) ? "vertical" : "horizontal";
}
// 수직 드래그일 때만 패널 이동
if (gestureDirection.current === "vertical" && deltaY > 0) {
e.preventDefault();
setPanelTranslateY(deltaY);
}
};
@@ -325,6 +342,8 @@ export function TaskDetail({
}
setPanelTranslateY(0);
touchStartY.current = null;
touchStartX.current = null;
gestureDirection.current = null;
};
const completedCount = subtasks.filter((s) => s.completed).length;
+23 -13
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useI18n } from "@/lib/i18n";
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
@@ -97,15 +97,18 @@ function RecursiveTaskItem({
}
}, [addingSubtask]);
const formatDate = (d: string | null) => {
// 마감일 상태 반환: label + 색상
const getDueDateInfo = (d: string | null): { label: string; color: string } | null => {
if (!d) return null;
const date = new Date(d);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
if (isToday) return t("today");
if (isTomorrow) return t("tomorrow");
return date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" });
const isPast = date < now && !isToday;
if (isToday) return { label: t("today"), color: "#EF4444" };
if (isTomorrow) return { label: t("tomorrow"), color: "#F97316" };
if (isPast) return { label: date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" }), color: "#DC2626" };
return { label: date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" }), color: "var(--text-tertiary)" };
};
const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")];
@@ -261,14 +264,21 @@ function RecursiveTaskItem({
title={priorityLabels[task.priority]}
/>
)}
{task.dueDate && !isTrashMode && (
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
</svg>
{formatDate(task.dueDate)}
</span>
)}
{task.dueDate && !isTrashMode && (() => {
const dueDateInfo = getDueDateInfo(task.dueDate);
if (!dueDateInfo) return null;
return (
<span
className="task-due"
style={{ color: task.completed ? undefined : dueDateInfo.color, fontWeight: (dueDateInfo.color !== "var(--text-tertiary)" && !task.completed) ? 600 : undefined }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
</svg>
{dueDateInfo.label}
</span>
);
})()}
{totalChildren > 0 && !isTrashMode && (
<span
className="task-sub-count"