215 lines
11 KiB
JavaScript
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));
|