fix(tradein-frontend): safeUrl validator, apiFetch dedup, ListingsCard a11y, error boundary #512
4 changed files with 96 additions and 26 deletions
55
tradein-mvp/frontend/src/app/error.tsx
Normal file
55
tradein-mvp/frontend/src/app/error.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* App Router error boundary for the trade-in page (Next 15 idiom).
|
||||
*
|
||||
* Triggers when a component below `app/page.tsx` throws during render or
|
||||
* inside an Effect. Without this, the page would white-screen. Closes
|
||||
* finding #16 from 2026-05-24 trade-in audit.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface Props {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export default function Error({ error, reset }: Props) {
|
||||
useEffect(() => {
|
||||
// Surfaces to browser devtools + (if wired) Sentry.
|
||||
console.error("trade-in page error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main className="page" role="alert" style={{ padding: "32px 16px", maxWidth: 600, margin: "0 auto" }}>
|
||||
<h1 style={{ marginBottom: 12, fontSize: 20, fontWeight: 600 }}>
|
||||
Что-то пошло не так
|
||||
</h1>
|
||||
<p style={{ color: "var(--muted)", marginBottom: 16, lineHeight: 1.5 }}>
|
||||
Произошла ошибка при отображении страницы. Можно попробовать ещё раз —
|
||||
если повторяется, перезагрузите страницу.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p style={{ color: "var(--muted-2)", fontSize: 12, marginBottom: 16, fontFamily: "monospace" }}>
|
||||
ID ошибки: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
background: "var(--accent)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Попробовать ещё раз
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
* Counts strip + filters + price bar + table.
|
||||
*/
|
||||
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
||||
import { safeUrl } from "@/lib/safeUrl";
|
||||
|
||||
interface Props {
|
||||
estimate: AggregatedEstimate;
|
||||
|
|
@ -243,13 +244,15 @@ function AnalogRow({ lot }: { lot: AnalogLot }) {
|
|||
const dot = SOURCE_DOTS[src] ?? "dom";
|
||||
const label = SOURCE_LABELS[src] ?? src;
|
||||
const distance =
|
||||
lot.distance_m === null ? "—" : lot.distance_m < 1000 ? `${lot.distance_m} м` : `${(lot.distance_m / 1000).toFixed(1)} км`;
|
||||
const onClick = lot.source_url
|
||||
? () => window.open(lot.source_url!, "_blank", "noopener,noreferrer")
|
||||
: undefined;
|
||||
lot.distance_m === null
|
||||
? "—"
|
||||
: lot.distance_m < 1000
|
||||
? `${lot.distance_m} м`
|
||||
: `${(lot.distance_m / 1000).toFixed(1)} км`;
|
||||
const externalUrl = safeUrl(lot.source_url);
|
||||
|
||||
return (
|
||||
<tr onClick={onClick} style={{ cursor: lot.source_url ? "pointer" : "default" }}>
|
||||
<tr>
|
||||
<td>
|
||||
<div className="addr">
|
||||
<span className="a-main">{lot.address}</span>
|
||||
|
|
@ -270,13 +273,12 @@ function AnalogRow({ lot }: { lot: AnalogLot }) {
|
|||
<td className="num">{fmtRub(lot.price_rub)}</td>
|
||||
<td className="num">{distance}</td>
|
||||
<td>
|
||||
{lot.source_url && (
|
||||
{externalUrl && (
|
||||
<a
|
||||
href={lot.source_url}
|
||||
href={externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="row-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Открыть оригинал"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
|
|
|||
|
|
@ -26,24 +26,6 @@ function withSessionHeader(init?: RequestInit): RequestInit {
|
|||
};
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const merged = withSessionHeader(init);
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...merged,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(merged.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status-aware variant of apiFetch — возвращает status + body для случаев
|
||||
* когда логика зависит от HTTP кода (например 202 Accepted для async jobs).
|
||||
|
|
@ -93,3 +75,11 @@ export async function apiFetchWithStatus<T>(
|
|||
}
|
||||
return { status: response.status, body: body as T };
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const { body } = await apiFetchWithStatus<T>(path, init);
|
||||
return body;
|
||||
}
|
||||
|
|
|
|||
23
tradein-mvp/frontend/src/lib/safeUrl.ts
Normal file
23
tradein-mvp/frontend/src/lib/safeUrl.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Validate an external URL string. Returns the URL only if it parses and uses
|
||||
* an http/https scheme — otherwise null.
|
||||
*
|
||||
* Defends against:
|
||||
* - javascript: URLs (XSS via href clicks)
|
||||
* - data: URLs (data exfiltration, weird content)
|
||||
* - file:, ftp:, custom-scheme abuse
|
||||
* - malformed strings (null reference protection)
|
||||
*
|
||||
* Closes finding #3 from 2026-05-24 trade-in audit. Backend supplies
|
||||
* lot.source_url verbatim from scraped sites — must not trust the string.
|
||||
*/
|
||||
export function safeUrl(raw: string | null | undefined): string | null {
|
||||
if (!raw || typeof raw !== "string") return null;
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
return u.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue