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:
lekss361 2026-05-24 14:52:31 +03:00
parent 3665a61e48
commit c5506aab37
2 changed files with 84 additions and 7 deletions

View file

@ -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="Закрыть"

View file

@ -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 && (