fix(tradein-frontend): MapPicker focus trap + Leaflet SRI, PhotoUpload size check
Two isolated UX/security fixes from 2026-05-24 audit: * MapPicker.tsx (#12): add role=dialog, aria-modal, aria-labelledby; trap Tab/Shift+Tab focus inside modal; Escape closes; restore focus on close. Pin Leaflet 1.9.4 CSS+JS with sha256 SRI hashes + crossOrigin=anonymous (defends against unpkg CDN hijack). * PhotoUpload.tsx (#13): client-side file.size check (10 MB mirror of backend _MAX_PHOTO_BYTES) prevents wasted bandwidth on oversized uploads. Replace single error state with per-file errors[] list. Clear stale errors on every new file pick.
This commit is contained in:
parent
3665a61e48
commit
c5506aab37
2 changed files with 84 additions and 7 deletions
|
|
@ -9,6 +9,8 @@ import { API_BASE_URL } from "@/lib/api";
|
||||||
const LEAFLET_VER = "1.9.4";
|
const LEAFLET_VER = "1.9.4";
|
||||||
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
|
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
|
||||||
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
|
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
|
||||||
|
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
|
||||||
|
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
|
||||||
const EKB_CENTER: [number, number] = [56.8389, 60.6057];
|
const EKB_CENTER: [number, number] = [56.8389, 60.6057];
|
||||||
|
|
||||||
/** Подгружает Leaflet с CDN один раз, резолвит window.L. */
|
/** Подгружает Leaflet с CDN один раз, резолвит window.L. */
|
||||||
|
|
@ -23,6 +25,8 @@ function loadLeaflet(): Promise<any> {
|
||||||
const link = document.createElement("link");
|
const link = document.createElement("link");
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
link.href = LEAFLET_CSS;
|
link.href = LEAFLET_CSS;
|
||||||
|
link.integrity = LEAFLET_CSS_SRI;
|
||||||
|
link.crossOrigin = "anonymous";
|
||||||
link.setAttribute("data-leaflet", "1");
|
link.setAttribute("data-leaflet", "1");
|
||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
@ -34,6 +38,8 @@ function loadLeaflet(): Promise<any> {
|
||||||
}
|
}
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.src = LEAFLET_JS;
|
script.src = LEAFLET_JS;
|
||||||
|
script.integrity = LEAFLET_JS_SRI;
|
||||||
|
script.crossOrigin = "anonymous";
|
||||||
script.setAttribute("data-leaflet", "1");
|
script.setAttribute("data-leaflet", "1");
|
||||||
script.onload = () => resolve(w.L);
|
script.onload = () => resolve(w.L);
|
||||||
script.onerror = () => reject(new Error("leaflet load failed"));
|
script.onerror = () => reject(new Error("leaflet load failed"));
|
||||||
|
|
@ -96,6 +102,44 @@ export function MapPicker({ onPick, onClose }: Props) {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Focus management: initial focus to close button, restore on unmount, Esc closes
|
||||||
|
const closeBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||||
|
closeBtnRef.current?.focus();
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
previouslyFocused.current?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Focus trap: keep Tab/Shift+Tab inside the modal
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const onDialogKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key !== "Tab" || !dialogRef.current) return;
|
||||||
|
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
if (focusables.length === 0) return;
|
||||||
|
const first = focusables[0];
|
||||||
|
const last = focusables[focusables.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -111,7 +155,12 @@ export function MapPicker({ onPick, onClose }: Props) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="map-picker-title"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={onDialogKeyDown}
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
|
|
@ -131,8 +180,9 @@ export function MapPicker({ onPick, onClose }: Props) {
|
||||||
borderBottom: "1px solid #e5e5e5",
|
borderBottom: "1px solid #e5e5e5",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<b>Выбор адреса на карте ЕКБ</b>
|
<b id="map-picker-title">Выбор адреса на карте ЕКБ</b>
|
||||||
<button
|
<button
|
||||||
|
ref={closeBtnRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { getOrCreateSessionId } from "@/lib/sessionId";
|
||||||
|
|
||||||
const BASE = "/api/v1/trade-in";
|
const BASE = "/api/v1/trade-in";
|
||||||
const MAX_PHOTOS = 12;
|
const MAX_PHOTOS = 12;
|
||||||
|
const MAX_FILE_BYTES = 10 * 1024 * 1024; // match backend `_MAX_PHOTO_BYTES`
|
||||||
|
|
||||||
interface PhotoMeta {
|
interface PhotoMeta {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -21,7 +22,7 @@ interface PhotoMeta {
|
||||||
export function PhotoUpload({ estimateId }: { estimateId: string }) {
|
export function PhotoUpload({ estimateId }: { estimateId: string }) {
|
||||||
const [photos, setPhotos] = useState<PhotoMeta[]>([]);
|
const [photos, setPhotos] = useState<PhotoMeta[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
|
@ -40,9 +41,21 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
|
||||||
async function handleFiles(files: FileList | null) {
|
async function handleFiles(files: FileList | null) {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError(null);
|
setErrors([]); // clear stale errors on every new pick
|
||||||
const sid = getOrCreateSessionId();
|
const sid = getOrCreateSessionId();
|
||||||
|
const collected: string[] = [];
|
||||||
|
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
|
// Client-side guards mirror backend filters; reject before bandwidth waste.
|
||||||
|
if (file.size > MAX_FILE_BYTES) {
|
||||||
|
collected.push(`${file.name}: файл больше 10 МБ (${(file.size / 1024 / 1024).toFixed(1)} МБ)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (file.size === 0) {
|
||||||
|
collected.push(`${file.name}: пустой файл`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
try {
|
try {
|
||||||
|
|
@ -54,11 +67,13 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
|
||||||
headers: sid ? { "X-Session-Id": sid } : {},
|
headers: sid ? { "X-Session-Id": sid } : {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!r.ok) setError(`${file.name}: ошибка загрузки (${r.status})`);
|
if (!r.ok) collected.push(`${file.name}: ошибка загрузки (${r.status})`);
|
||||||
} catch {
|
} catch {
|
||||||
setError(`${file.name}: сеть недоступна`);
|
collected.push(`${file.name}: сеть недоступна`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setErrors(collected);
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (inputRef.current) inputRef.current.value = "";
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
await refresh();
|
await refresh();
|
||||||
|
|
@ -102,8 +117,20 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
|
||||||
Достигнут лимит {MAX_PHOTOS} фото.
|
Достигнут лимит {MAX_PHOTOS} фото.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{errors.length > 0 && (
|
||||||
<p style={{ color: "#c0392b", fontSize: 13, marginTop: 8 }}>{error}</p>
|
<ul
|
||||||
|
style={{
|
||||||
|
color: "#c0392b",
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 8,
|
||||||
|
paddingLeft: 18,
|
||||||
|
listStyleType: "disc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{errors.map((msg, i) => (
|
||||||
|
<li key={i}>{msg}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{photos.length > 0 && (
|
{photos.length > 0 && (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue