import http from 'node:http'; import express from 'express'; import cors from 'cors'; import { WebSocketServer } from 'ws'; import { config } from './config.js'; import { sign, verifyId, signState, verifyState, can, isObjectAllowed, TYPE_SECTION } from './auth.js'; import * as tenants from './tenants.js'; import * as metrics from './metrics.js'; import * as store from './store.js'; import { seedDemo } from './demo.js'; import * as lora from './lora.js'; import { startMqtt } from './adapters/mqtt.js'; import { startTraccar } from './adapters/traccar.js'; import { startInflux } from './adapters/influx.js'; seedDemo(); lora.seedLora(); const live = { wirenboard: startMqtt(), traccar: startTraccar(), influx: startInflux() }; const app = express(); app.use(cors()); app.use(express.json()); function auth(req, res, next) { const u = tenants.getUser(verifyId((req.headers.authorization || '').replace(/^Bearer /, ''))); if (!u) return res.status(401).json({ error: 'unauthorized' }); if (tenants.isBlocked(u)) return res.status(403).json({ error: 'Доступ заблокирован', blocked: u.blocked }); req.user = u; metrics.touch(u, metrics.sectionOf(req.path)); next(); } function adminOnly(req, res, next) { if (!req.user?.admin) return res.status(403).json({ error: 'forbidden' }); next(); } // Видимость: мультиарендность по clientId (admin/'all' видят всё) + RBAC раздела/объекта. const visible = (user, e) => { if (!user.admin && user.clientId !== 'all' && e.clientId !== user.clientId) return false; const sec = TYPE_SECTION[e.type]; return can(user, sec, 'view') && isObjectAllowed(user, sec, e.id); }; const filterEnt = (user, list) => list.filter((e) => visible(user, e)); app.get('/api/health', (_req, res) => res.json({ ok: true, sources: live })); // Вход по id — только для seed-ролей/demo (переключатель). Яндекс-арендаторы (yx-*) — только через OAuth. app.post('/api/login', (req, res) => { const id = String(req.body?.userId || 'demo'); if (id.startsWith('yx-')) return res.status(403).json({ error: 'Для этого пользователя вход только через Яндекс ID' }); const u = tenants.getUser(id) || tenants.getUser('demo'); res.json({ token: sign(u), user: u }); }); app.get('/api/me', auth, (req, res) => res.json(req.user)); // Устройства арендатора (привязка трекеров по IMEI → clientId владельца) app.get('/api/devices', auth, (req, res) => res.json(tenants.devicesOf(req.user.id))); app.post('/api/devices/bind', auth, (req, res) => { const imei = String(req.body?.imei || '').trim(); if (!/^\d{6,20}$/.test(imei)) return res.status(400).json({ error: 'Некорректный IMEI (только цифры)' }); const device = tenants.bindDevice(req.user.id, imei, req.body?.name); const e = store.getEntity(`nt-${imei}`); if (e) store.upsertEntity({ id: e.id, clientId: req.user.clientId }); // пере-тег уже принятой сущности res.json({ ok: true, device }); }); app.delete('/api/devices/:imei', auth, (req, res) => { tenants.unbindDevice(req.user.id, req.params.imei); res.json({ ok: true }); }); app.get('/api/entities', auth, (req, res) => { let l = filterEnt(req.user, store.getEntities()); if (req.query.type) l = l.filter((e) => e.type === req.query.type); res.json(l); }); app.get('/api/tracks/:id', auth, (req, res) => res.json(store.getTrack(req.params.id))); app.get('/api/trips', auth, (req, res) => res.json(store.getTrips(req.query.entityId))); app.get('/api/events', auth, (req, res) => res.json(store.getEvents())); app.get('/api/channels', auth, (req, res) => res.json(can(req.user, 'smart', 'view') ? store.getChannels() : [])); app.get('/api/energy/series', auth, (req, res) => res.json(can(req.user, 'energy', 'view') ? store.getEnergySeries() : [])); // --- Админ-консоль (только admin) --- app.get('/api/admin/stats', auth, adminOnly, (_req, res) => res.json(metrics.stats())); app.get('/api/admin/online', auth, adminOnly, (_req, res) => res.json(metrics.onlineUsers())); app.get('/api/admin/activity', auth, adminOnly, (_req, res) => res.json(metrics.getActivity())); app.get('/api/admin/daily', auth, adminOnly, (_req, res) => res.json(metrics.dailyByUser())); app.get('/api/admin/users', auth, adminOnly, (_req, res) => res.json( tenants.allUsers().map((u) => ({ id: u.id, name: u.name, email: u.email || '', role: u.role || '', admin: !!u.admin, clientId: u.clientId, blocked: u.blocked || null, sections: Object.keys(u.access || {}), })), ), ); app.post('/api/admin/users', auth, adminOnly, (req, res) => res.json(tenants.addUser(req.body || {}))); app.delete('/api/admin/users/:id', auth, adminOnly, (req, res) => res.json({ ok: tenants.deleteUser(req.params.id) })); app.post('/api/admin/users/:id/block', auth, adminOnly, (req, res) => res.json(tenants.setBlock(req.params.id, req.body || {}) || { error: 'not found' })); app.post('/api/admin/users/:id/unblock', auth, adminOnly, (req, res) => res.json(tenants.clearBlock(req.params.id) || { error: 'not found' })); // --- Личный кабинет / биллинг (заглушка оплаты ЮMoney/Сбербанк) --- const balances = {}; app.get('/api/billing', auth, (req, res) => { const cid = req.user.clientId; res.json({ clientId: cid, balance: balances[cid] ?? 0, currency: 'RUB', plan: req.user.admin ? 'Безлимит' : 'Базовый', devices: tenants.devicesOf(req.user.id).length }); }); app.post('/api/billing/topup', auth, (req, res) => { const amount = Math.max(0, Number(req.body?.amount) || 0); const method = String(req.body?.method || 'yoomoney'); const order = `sm-${req.user.clientId}-${Date.now()}`; // ЗАГЛУШКА: реальная интеграция ЮMoney/Сбербанк — позже (см. план billing). const payUrl = method === 'sberbank' ? `https://3dsec.sberbank.ru/payment/merchants/servaki/payment_ru.html?mdOrder=${order}` : `https://yoomoney.ru/quickpay/confirm.xml?receiver=4100100000000&quickpay-form=shop&targets=Servaki+Monitor&sum=${amount}&label=${order}`; res.json({ ok: true, demo: true, amount, method, order, payUrl, note: 'Демо-заглушка: оплата не списывается' }); }); // Связанные аккаунты кабинета (общий clientId) app.get('/api/cabinet/links', auth, (req, res) => res.json(tenants.linksOf(req.user.id))); app.post('/api/cabinet/links', auth, (req, res) => res.json(tenants.addLink(req.user.id, req.body?.email))); app.delete('/api/cabinet/links/:email', auth, (req, res) => res.json(tenants.removeLink(req.user.id, decodeURIComponent(req.params.email)))); // --- LoRa/LAN энергомониторинг: приём от шлюза-концентратора + реестр устройств --- // Концентратор (LAN-шлюз) шлёт паспорт/телеметрию узлов сюда (открытый ingress железа). app.post('/api/lora/ingest', (req, res) => { const b = req.body || {}; const items = Array.isArray(b) ? b : [b]; for (const it of items) (it.t === 'info' ? lora.ingestInfo(it) : lora.ingestData(it)); res.json({ ok: true, count: items.length }); }); app.get('/api/lora/devices', auth, (req, res) => res.json(can(req.user, 'energy', 'view') ? lora.listDevices({ status: req.query.status, project: req.query.project }) : [])); app.get('/api/lora/gateways', auth, (req, res) => res.json(lora.listGateways())); app.get('/api/lora/relay-events', auth, (req, res) => res.json(lora.relayEvents())); app.post('/api/lora/devices/:id/approve', auth, (req, res) => res.json(lora.approve(req.params.id, req.body || {}) || { error: 'not found' })); app.post('/api/lora/devices/:id/disable', auth, (req, res) => res.json(lora.disable(req.params.id) || { error: 'not found' })); app.post('/api/lora/devices/:id/cmd', auth, (req, res) => res.json(lora.cmd(req.params.id, req.body || {}, 'ui') || { error: 'not found' })); // --- Яндекс ID (OAuth) --- app.get('/api/auth/yandex/start', (_req, res) => { if (!config.yandex) return res.redirect('/?autherror=noyandex'); const u = new URL('https://oauth.yandex.ru/authorize'); u.searchParams.set('response_type', 'code'); u.searchParams.set('client_id', config.yandex.clientId); u.searchParams.set('redirect_uri', config.yandex.redirect); u.searchParams.set('state', signState()); // CSRF-защита res.redirect(u.toString()); }); app.get('/api/auth/yandex/callback', async (req, res) => { if (!config.yandex) return res.redirect('/?autherror=noyandex'); // Пользователь отклонил доступ / ошибка Яндекса if (req.query.error) return res.redirect(`/?autherror=${encodeURIComponent(String(req.query.error))}`); // Проверка state (CSRF) if (!verifyState(req.query.state)) return res.redirect('/?autherror=state'); if (!req.query.code) return res.redirect('/?autherror=nocode'); try { const body = new URLSearchParams({ grant_type: 'authorization_code', code: String(req.query.code), client_id: config.yandex.clientId, client_secret: config.yandex.secret, }); const tok = await (await fetch('https://oauth.yandex.ru/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body })).json(); if (!tok.access_token) return res.redirect('/?autherror=token'); const info = await (await fetch('https://login.yandex.ru/info?format=json', { headers: { Authorization: `OAuth ${tok.access_token}` } })).json(); if (!info.id) return res.redirect('/?autherror=userinfo'); const u = tenants.upsertYandex({ email: String(info.default_email || ''), name: info.real_name || info.display_name || info.login, sub: String(info.id), }); res.redirect(`/?token=${sign(u)}`); } catch { res.redirect('/?autherror=oauth'); } }); const server = http.createServer(app); const wss = new WebSocketServer({ server, path: '/socket' }); wss.on('connection', (ws, req) => { const user = tenants.getUser(verifyId(new URL(req.url, 'http://x').searchParams.get('token'))); if (!user || tenants.isBlocked(user)) return ws.close(); metrics.wsConnect(user); const send = (type, data) => { try { ws.send(JSON.stringify({ type, data })); } catch { /* socket closed */ } }; send('snapshot', { entities: filterEnt(user, store.getEntities()) }); const onPos = (p) => { const e = store.getEntity(p.id); if (e && visible(user, e)) send('position', p); }; const onEnt = (e) => visible(user, e) && send('entity', e); const onEv = (ev) => send('event', ev); const onCh = (c) => can(user, 'smart', 'view') && send('channel', c); store.bus.on('position', onPos); store.bus.on('entity', onEnt); store.bus.on('event', onEv); store.bus.on('channel', onCh); ws.on('close', () => { metrics.wsDisconnect(user.id); store.bus.off('position', onPos); store.bus.off('entity', onEnt); store.bus.off('event', onEv); store.bus.off('channel', onCh); }); }); server.listen(config.port, () => console.log(`[api] :${config.port} · источники`, live));