import path from 'node:path' import { fileURLToPath } from 'node:url' import express from 'express' import { leadSchema, type LeadResponse } from '../../shared/lead.ts' import { AmoClient, AmoError } from './amocrm.ts' import { readAmoConfig, serverConfig } from './config.ts' import { LeadService } from './lead-service.ts' import { createRateLimiter } from './rate-limit.ts' const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') const clientDir = path.join(rootDir, 'dist/client') const amoConfig = readAmoConfig() const leadService = amoConfig ? new LeadService(new AmoClient(amoConfig)) : null if (!leadService) { console.warn( '[amo] AMO_SUBDOMAIN / AMO_LONG_LIVED_TOKEN are not set — the site runs, but /api/leads/* will return 503.', ) } const app = express() // 'loopback', not true: only the local nginx hop is trusted, so req.ip is the // address nginx actually observed and the rate limiter below cannot be // side-stepped with a forged X-Forwarded-For header. app.set('trust proxy', 'loopback') app.use(express.json({ limit: '64kb' })) const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 }) app.get('/api/health', (_req, res) => { res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null }) }) app.post('/api/leads/hotels', async (req, res) => { const send = (status: number, body: LeadResponse) => res.status(status).json(body) const parsed = leadSchema.safeParse(req.body) if (!parsed.success) { const fields: Record = {} for (const issue of parsed.error.issues) { const key = issue.path.join('.') if (key && !fields[key]) fields[key] = issue.message } return send(400, { ok: false, error: 'Проверьте заполненные поля.', fields }) } const payload = parsed.data // Honeypot: answer exactly like a success so bots learn nothing. if (payload.website) { console.info('[lead] honeypot triggered from %s', req.ip) return send(200, { ok: true, leadId: 0, contactId: 0 }) } const { allowed, retryAfterSeconds } = limiter(req.ip ?? 'unknown') if (!allowed) { res.setHeader('Retry-After', String(retryAfterSeconds)) return send(429, { ok: false, error: 'Слишком много заявок с этого адреса. Попробуйте позже.' }) } if (!leadService) { // Never drop a real lead silently — it must be findable in the logs. console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload)) return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' }) } try { const result = await leadService.submit(payload) console.info( '[lead] amoCRM lead %d (contact %d, %s)', result.leadId, result.contactId, result.contactCreated ? 'new contact' : 'existing contact', ) return send(200, { ok: true, leadId: result.leadId, contactId: result.contactId }) } catch (error) { console.error('[lead] amoCRM submission failed:', error) console.error('[lead] payload was:', JSON.stringify(payload)) const message = error instanceof AmoError && error.status === 401 ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.' : 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.' return send(502, { ok: false, error: message }) } }) if (serverConfig.isProduction) { app.use( express.static(clientDir, { index: false, setHeaders(res, filePath) { // Vite fingerprints everything under /assets, so it can be cached hard. if (filePath.includes(`${path.sep}assets${path.sep}`)) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') } }, }), ) // SPA fallback. Written as middleware because Express 5 no longer accepts a // bare '*' route pattern. app.use((req, res, next) => { if (req.method !== 'GET' || req.path.startsWith('/api/')) return next() res.sendFile(path.join(clientDir, 'index.html')) }) } app.listen(serverConfig.port, serverConfig.host, () => { console.info( '[server] listening on http://localhost:%d%s', serverConfig.port, serverConfig.isProduction ? ` (serving ${path.relative(rootDir, clientDir)})` : ' (API only — run vite for the UI)', ) })