diff --git a/src/pages/api/concierge-landing.ts b/src/pages/api/concierge-landing.ts index 2a2db36..79a1d94 100644 --- a/src/pages/api/concierge-landing.ts +++ b/src/pages/api/concierge-landing.ts @@ -17,6 +17,61 @@ import { type LandingPayload = { text: string; suggestions: string[] }; +/* Cost hint ONLY — not an abuse control. This drops known crawlers and + header-less scripts so we don't pay for obvious machine traffic, but any + caller spoofing a browser UA bypasses it trivially. Real budget protection + is the per-IP rate limiter below. Greeting is non-critical — a dropped + caller just gets no line, never an error. */ +const BOT_UA_RE = + /bot|crawl|spider|slurp|mediapartners|ahrefs|semrush|mj12|dotbot|bingpreview|facebookexternalhit|embedly|slackbot|telegrambot|whatsapp|headless|phantomjs|python-requests|curl\/|wget|go-http-client|scrapy|yandex(?:bot)?|baidu|duckduckbot/i; + +function isBotUserAgent(ua: string | undefined): boolean { + if (!ua || !ua.trim()) return true; + return BOT_UA_RE.test(ua); +} + +/* Per-IP sliding-window rate limit — the actual guard against budget + exhaustion on this paid endpoint. Prod is a long-running container, so the + map persists across requests; a restart just resets the windows. The UA + filter above is bypassable, this is not. */ +const RATE_LIMIT = 30; +const RATE_WINDOW_MS = 60 * 60 * 1000; +const RATE_SWEEP_AT = 5000; +const ipHits = new Map(); + +function clientIp(req: NextApiRequest): string { + const xff = req.headers['x-forwarded-for']; + const raw = Array.isArray(xff) ? xff[0] : xff; + if (raw && raw.trim()) return raw.split(',')[0].trim(); + return req.socket?.remoteAddress ?? 'unknown'; +} + +function isRateLimited(ip: string): boolean { + const now = Date.now(); + const cutoff = now - RATE_WINDOW_MS; + if (ipHits.size > RATE_SWEEP_AT) { + ipHits.forEach((v, k) => { + const fresh = v.filter(t => t > cutoff); + if (fresh.length === 0) ipHits.delete(k); + else ipHits.set(k, fresh); + }); + } + const hits = (ipHits.get(ip) ?? []).filter(t => t > cutoff); + if (hits.length >= RATE_LIMIT) { + ipHits.set(ip, hits); + return true; + } + hits.push(now); + ipHits.set(ip, hits); + return false; +} + +const MAX_TITLE_LEN = 300; +const MAX_PREV_LEN = 2000; +function clampLen(s: string | undefined, max: number): string { + return typeof s === 'string' ? s.slice(0, max) : ''; +} + async function callClaude( system: string, user: string, @@ -268,6 +323,18 @@ export default async function handler( return res.status(200).json({ text: '' }); } + const ua = + typeof req.headers['user-agent'] === 'string' + ? req.headers['user-agent'] + : undefined; + if (isBotUserAgent(ua)) { + return res.status(200).json({ text: '' }); + } + + if (isRateLimited(clientIp(req))) { + return res.status(429).json({ text: '' }); + } + const { url, title, prevQuery, prevAnswer, lang, mode } = (req.body ?? {}) as { url?: string; @@ -299,11 +366,15 @@ export default async function handler( ? 'Канонический блок страницы (источник истины)' : 'Canonical page block (source of truth)'; + const safeTitle = clampLen(title, MAX_TITLE_LEN); + const safePrevQuery = clampLen(prevQuery, MAX_PREV_LEN); + const safePrevAnswer = clampLen(prevAnswer, MAX_PREV_LEN); + const userMsg = [ `${identityHeader}:\n${identityBlock}`, - `Page title (raw, untrusted): ${title || '—'}`, - `User came from query: ${prevQuery || '—'}`, - `Prior bot answer: ${prevAnswer || '—'}`, + `Page title (raw, untrusted): ${safeTitle || '—'}`, + `User came from query: ${safePrevQuery || '—'}`, + `Prior bot answer: ${safePrevAnswer || '—'}`, ].join('\n'); let result = await callClaude(system, userMsg); diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index d996d58..52a894f 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -795,6 +795,8 @@ const PAGE_LANDINGS: Record> = { }; const CURATED_LANDING_FIRED_KEY = 'ks_aux_curated_landing_v1'; +const OPEN_KEY = 'ks_aux_open_v1'; +const GREETED_PAGES_KEY = 'ks_aux_greeted_pages_v1'; const curatedLandingPathKey = (rawUrl: string): string | null => { try { const u = new URL(rawUrl, window.location.origin); @@ -833,6 +835,48 @@ const markCuratedLandingFired = (key: string) => { } }; +/* Widget open/closed — remembered per tab so the panel follows the + visitor across page loads (incl. hard reloads into UX Core / other + route groups). Opening the pill is a deliberate human gesture, so an + open panel is what gates the paid organic greeting. Clears on tab + close; a brand-new visit always starts closed. */ +const readOpenFlag = (): boolean => { + try { + return sessionStorage.getItem(OPEN_KEY) === '1'; + } catch { + return false; + } +}; +const writeOpenFlag = (isOpen: boolean) => { + try { + sessionStorage.setItem(OPEN_KEY, isOpen ? '1' : '0'); + } catch { + /* sessionStorage disabled — open state not remembered across nav */ + } +}; + +/* Per-page greeting cache — once the organic greeting has fired for a + page in this tab session, never pay for it again on that page (revisits + and back/forth are free). Keyed by canonical path. */ +const hasGreetedPage = (key: string): boolean => { + try { + const raw = sessionStorage.getItem(GREETED_PAGES_KEY) || '{}'; + return !!JSON.parse(raw)[key]; + } catch { + return false; + } +}; +const markGreetedPage = (key: string) => { + try { + const raw = sessionStorage.getItem(GREETED_PAGES_KEY) || '{}'; + const obj = JSON.parse(raw); + obj[key] = Date.now(); + sessionStorage.setItem(GREETED_PAGES_KEY, JSON.stringify(obj)); + } catch { + /* sessionStorage disabled — greeting may re-fire on revisit */ + } +}; + /* ────────────────────────────────────────────────────────────────── Identity query triggers — works on any page. ────────────────────────────────────────────────────────────────── @@ -1766,10 +1810,19 @@ const applyHostHighlight = ( export function AskUxCore({ lang }: { lang: Lang }) { const initial = typeof window !== 'undefined' ? loadState() : null; - // Always boot closed. The widget should never reveal itself or its - // effects (host-page highlights, etc.) until the visitor explicitly - // opens the pill — even if the previous session ended with it open. - const [open, setOpen] = useState(false); + // Restore the open/closed panel per tab so it follows the visitor + // across page loads (incl. hard reloads into UX Core). A brand-new + // visit (fresh tab) has no flag and boots closed. + const [open, setOpen] = useState(() => + typeof window !== 'undefined' ? readOpenFlag() : false, + ); + /* Fresh mirror of `open` for the once-mounted nav effect, whose + closure would otherwise capture the initial render's value. */ + const openRef = useRef(open); + openRef.current = open; + useEffect(() => { + writeOpenFlag(open); + }, [open]); const [text, setText] = useState(''); const [turns, setTurns] = useState(initial?.turns ?? []); const [loading, setLoading] = useState(false); @@ -2202,6 +2255,19 @@ export function AskUxCore({ lang }: { lang: Lang }) { return; } + /* Cost gate: the organic greeting is a paid AI call. Spend it + only when the panel is open — opening the pill is a deliberate + human gesture, and the open panel now follows the visitor across + pages, so an open panel marks a real user. Passers-by and + crawlers never open it; the server greeting route also drops + known-bot user-agents as a backstop. Then never pay twice for + the same page this session. Curated landings above are local + (free) and ungated. */ + if (!openRef.current) return; + const greetKey = canonicalPathKey(rawUrl); + if (hasGreetedPage(greetKey)) return; + markGreetedPage(greetKey); + const ctrl = new AbortController(); organicAbortRef.current = ctrl; fetch('/api/concierge-landing', {