Files
egorin/server/src/server.js
T

215 lines
11 KiB
JavaScript

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));