Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions src/pages/api/concierge-landing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number[]>();

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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
74 changes: 70 additions & 4 deletions widget/src/AskUxCore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,8 @@ const PAGE_LANDINGS: Record<Lang, Record<string, PageLanding>> = {
};

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);
Expand Down Expand Up @@ -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.
──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<boolean>(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<boolean>(() =>
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<Turn[]>(initial?.turns ?? []);
const [loading, setLoading] = useState(false);
Expand Down Expand Up @@ -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', {
Expand Down
Loading