Железный Егорин — форк Орбиты: тёплый стиль (amber), логин с odyssey WebGL-молнией справа, энергомониторинг+панель

Ребренд Мониторинг Орбита -> Железный Егорин; accent green->amber #F59E0B; LoginScreen правая половина = Lightning (WebGL, hue 35 тёплый) под стеклянной формой; левая панель — тёплый градиент.
Доступ агентам: репо публичный, push german-токеном.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
German
2026-07-07 23:57:16 +03:00
co-authored by Claude Opus 4.8
commit b1c52ab748
114 changed files with 14340 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Шлюз. Адаптер активен только если задан источник; иначе раздел на демо-данных.
PORT=4100
JWT_SECRET=change-me-in-prod
# Wiren Board / FLEX-сервер (MQTT). В ingest-стеке = mqtt://mosquitto:1883
MQTT_URL=mqtt://mosquitto:1883
MQTT_BASE_TOPIC=navtelecom
# MQTT_USER=
# MQTT_PASS=
# MQTT_PREFIX=/devices
# Traccar (когда стек развёрнут)
# TRACCAR_URL=http://traccar:8082
# TRACCAR_TOKEN=... (или TRACCAR_USER / TRACCAR_PASS)
# InfluxDB (энерго, когда стек развёрнут)
# INFLUX_URL=http://influxdb:8086
# INFLUX_TOKEN=...
# INFLUX_ORG=servaki
# INFLUX_BUCKET=energy
+5
View File
@@ -0,0 +1,5 @@
node_modules
.env
.env.*
!.env.example
*.log
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY src ./src
EXPOSE 4100
CMD ["node", "src/server.js"]
+18
View File
@@ -0,0 +1,18 @@
{
"name": "servaki-monitor-api",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Бэкенд-шлюз Servaki Monitor: адаптеры Traccar / Wiren Board (MQTT) / InfluxDB → единая модель, REST + WebSocket, RBAC, мультиарендность",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"@influxdata/influxdb-client": "^1.35.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"mqtt": "^5.7.0",
"ws": "^8.17.0"
}
}
+38
View File
@@ -0,0 +1,38 @@
// InfluxDB-адаптер: энерго-ряд (PV/SOC/нагрузка) из бакета. Активен при INFLUX_URL.
import { InfluxDB } from '@influxdata/influxdb-client';
import { config } from '../config.js';
import { setEnergySeries } from '../store.js';
export function startInflux() {
if (!config.influx) return false;
const q = new InfluxDB({ url: config.influx.url, token: config.influx.token }).getQueryApi(config.influx.org);
const flux = `from(bucket:"${config.influx.bucket}")
|> range(start:-24h)
|> filter(fn:(r)=> r._measurement=="pv" or r._measurement=="soc" or r._measurement=="load")
|> aggregateWindow(every:1h, fn:mean, createEmpty:false)`;
async function poll() {
const rows = {};
try {
await new Promise((resolve, reject) => {
q.queryRows(flux, {
next: (row, meta) => {
const o = meta.toObject(row);
const t = new Date(o._time).toISOString().slice(11, 16);
rows[t] = rows[t] || { t };
rows[t][o._measurement] = Math.round(o._value);
},
error: reject,
complete: resolve,
});
});
const series = Object.values(rows);
if (series.length) setEnergySeries(series);
} catch (e) {
console.error('[influx]', e.message);
}
}
poll();
setInterval(poll, 60000);
console.log('[influx] запросы', config.influx.url);
return true;
}
+50
View File
@@ -0,0 +1,50 @@
// MQTT-адаптер: приём позиций от FLEX-сервера (navtelecom/<imei>/state) +
// состояние каналов Wiren Board (/devices/<dev>/controls/<ch>).
import mqtt from 'mqtt';
import { config } from '../config.js';
import { addPosition, upsertEntity, updateChannel } from '../store.js';
import { imeiOwner } from '../tenants.js';
const TELE_KEYS = ['pwr_ext', 'pwr_int', 'temp1', 'temp2', 'gsm', 'speed', 'sat', 'event_code'];
export function startMqtt() {
if (!config.mqtt) return false;
const c = mqtt.connect(config.mqtt.url, {
username: config.mqtt.username,
password: config.mqtt.password,
reconnectPeriod: 5000,
});
c.on('connect', () => {
console.log('[mqtt] подключён', config.mqtt.url);
c.subscribe('navtelecom/+/state');
c.subscribe(`${config.mqtt.prefix}/+/controls/+`);
});
c.on('message', (topic, buf) => {
try {
let m = topic.match(/^navtelecom\/([^/]+)\/state$/);
if (m) {
const imei = m[1];
const p = JSON.parse(buf.toString());
const lat = p.lat ?? p.latitude;
const lon = p.lon ?? p.longitude;
const telemetry = {};
TELE_KEYS.forEach((k) => p[k] != null && (telemetry[k] = Number(p[k])));
const id = `nt-${imei}`;
const clientId = imeiOwner(imei) || 'unassigned';
upsertEntity({ id, clientId, name: `Трекер ${imei}`, type: 'transport' }); // тег арендатора по IMEI
if (lat != null && lon != null) addPosition(id, Number(lat), Number(lon), telemetry);
else upsertEntity({ id, online: true, telemetry });
return;
}
m = topic.match(new RegExp(`${config.mqtt.prefix}/([^/]+)/controls/([^/]+)$`));
if (m) {
const v = buf.toString();
updateChannel(`${m[1]}/${m[2]}`, { on: v === '1' || v === 'true', ...(isNaN(+v) ? {} : { level: +v }) });
}
} catch {
/* malformed payload — ignore */
}
});
c.on('error', (e) => console.error('[mqtt]', e.message));
return true;
}
+34
View File
@@ -0,0 +1,34 @@
// Traccar-адаптер: опрос REST /api/devices + /api/positions → объекты/треки.
// Активен при TRACCAR_URL. (Когда стек Traccar развёрнут — данные пойдут сюда.)
import { config } from '../config.js';
import { addPosition, upsertEntity } from '../store.js';
export function startTraccar() {
if (!config.traccar) return false;
const base = config.traccar.url.replace(/\/$/, '');
const headers = {};
if (config.traccar.token) headers.Authorization = `Bearer ${config.traccar.token}`;
else if (config.traccar.user)
headers.Authorization = 'Basic ' + Buffer.from(`${config.traccar.user}:${config.traccar.pass}`).toString('base64');
const devices = {};
async function poll() {
try {
const d = await (await fetch(`${base}/api/devices`, { headers })).json();
d.forEach((x) => {
devices[x.id] = x;
upsertEntity({ id: `tc-${x.id}`, clientId: 'traccar', name: x.name, type: 'transport', online: x.status === 'online' });
});
const pos = await (await fetch(`${base}/api/positions`, { headers })).json();
pos.forEach((p) =>
addPosition(`tc-${p.deviceId}`, p.latitude, p.longitude, { speed: Math.round((p.speed || 0) * 1.852), sat: p.attributes?.sat || 0 }, p.fixTime),
);
} catch (e) {
console.error('[traccar]', e.message);
}
}
poll();
setInterval(poll, 10000);
console.log('[traccar] опрос', base);
return true;
}
+89
View File
@@ -0,0 +1,89 @@
// RBAC-зеркало фронта + простой HMAC-токен (без внешних зависимостей).
import crypto from 'node:crypto';
import { config } from './config.js';
export const USERS = [
{
id: 'admin',
name: 'Администратор',
role: 'Полный доступ',
admin: true,
access: {
energy: { caps: ['view', 'edit', 'reports'], objects: 'all' },
smart: { caps: ['view', 'edit', 'reports'], objects: 'all' },
gps: { caps: ['view', 'edit', 'reports'], objects: 'all' },
netinfra: { caps: ['view', 'edit', 'reports'], objects: 'all' },
security: { caps: ['view', 'edit', 'reports'], objects: 'all' },
},
},
{ id: 'pcn-operator', name: 'Дежурный ПЦН', role: 'Оператор пульта', access: { security: { caps: ['view', 'edit', 'reports'], objects: 'all' } } },
{ id: 'energetik-ro', name: 'Сидоров А.', role: 'Энергетик · просмотр', access: { energy: { caps: ['view'], objects: ['cab-07'] } } },
{ id: 'energetik-rw', name: 'Петров В.', role: 'Энергетик · правка', access: { energy: { caps: ['view', 'edit', 'reports'], objects: 'all' } } },
{ id: 'logist', name: 'Кузнецова М.', role: 'Логист транспорта', access: { gps: { caps: ['view', 'edit', 'reports'], objects: 'all' } } },
{ id: 'netadmin', name: 'Орлов Д.', role: 'Сетевой инженер', access: { netinfra: { caps: ['view', 'edit', 'reports'], objects: 'all' } } },
{
id: 'demo',
name: 'Демо-доступ',
role: 'Только просмотр',
access: {
energy: { caps: ['view'], objects: 'all' },
smart: { caps: ['view'], objects: 'all' },
gps: { caps: ['view'], objects: 'all' },
netinfra: { caps: ['view'], objects: 'all' },
security: { caps: ['view'], objects: 'all' },
},
},
];
export const TYPE_SECTION = { transport: 'gps', energy: 'energy', realty: 'smart', network: 'netinfra' };
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // срок жизни сессии — 30 дней
const STATE_TTL_MS = 10 * 60 * 1000; // OAuth state (CSRF) — 10 минут
export function sign(user) {
const body = Buffer.from(JSON.stringify({ id: user.id, t: Date.now() })).toString('base64url');
const sig = crypto.createHmac('sha256', config.jwtSecret).update(body).digest('base64url');
return `${body}.${sig}`;
}
// Возвращает id пользователя из токена (сам пользователь резолвится в tenants). null если подпись/срок невалидны.
export function verifyId(token) {
if (!token) return null;
const [body, sig] = token.split('.');
if (!body || !sig) return null;
const exp = crypto.createHmac('sha256', config.jwtSecret).update(body).digest('base64url');
if (exp !== sig) return null;
try {
const p = JSON.parse(Buffer.from(body, 'base64url').toString());
if (!p.t || Date.now() - p.t > TOKEN_TTL_MS) return null; // сессия просрочена
return p.id || null;
} catch {
return null;
}
}
// CSRF-state для OAuth: подписанный короткоживущий токен (stateless, без хранилища сессий)
export function signState() {
const body = Buffer.from(JSON.stringify({ n: crypto.randomBytes(9).toString('hex'), t: Date.now() })).toString('base64url');
const sig = crypto.createHmac('sha256', config.jwtSecret).update('state:' + body).digest('base64url');
return `${body}.${sig}`;
}
export function verifyState(state) {
if (!state) return false;
const [body, sig] = String(state).split('.');
if (!body || !sig) return false;
const exp = crypto.createHmac('sha256', config.jwtSecret).update('state:' + body).digest('base64url');
if (exp !== sig) return false;
try {
const p = JSON.parse(Buffer.from(body, 'base64url').toString());
return !!p.t && Date.now() - p.t <= STATE_TTL_MS;
} catch {
return false;
}
}
export const can = (user, section, cap) => !!user?.access?.[section]?.caps?.includes(cap);
export function isObjectAllowed(user, section, id) {
const o = user?.access?.[section]?.objects;
return o === 'all' || (Array.isArray(o) && o.includes(id));
}
+38
View File
@@ -0,0 +1,38 @@
// Конфигурация шлюза из окружения. Адаптер активен только если задан его источник;
// иначе раздел работает на демо-данных (fallback). Так шлюз поднимается сразу,
// а реальные источники «зажигаются» по мере готовности их стеков.
const env = process.env;
export const config = {
port: Number(env.PORT || 4100),
jwtSecret: env.JWT_SECRET || 'dev-secret-change-me',
dataDir: env.DATA_DIR || '/data', // персист арендаторов/привязок устройств
traccar: env.TRACCAR_URL
? { url: env.TRACCAR_URL, token: env.TRACCAR_TOKEN || '', user: env.TRACCAR_USER || '', pass: env.TRACCAR_PASS || '' }
: null,
mqtt: env.MQTT_URL
? { url: env.MQTT_URL, username: env.MQTT_USER || undefined, password: env.MQTT_PASS || undefined, prefix: env.MQTT_PREFIX || '/devices' }
: null,
influx: env.INFLUX_URL
? { url: env.INFLUX_URL, token: env.INFLUX_TOKEN || '', org: env.INFLUX_ORG || '', bucket: env.INFLUX_BUCKET || 'energy' }
: null,
// Яндекс ID (OAuth). Активен при YANDEX_CLIENT_ID. Регистрация: oauth.yandex.ru.
yandex: env.YANDEX_CLIENT_ID
? {
clientId: env.YANDEX_CLIENT_ID,
secret: env.YANDEX_CLIENT_SECRET || '',
redirect: env.YANDEX_REDIRECT || 'https://monitor.servaki.online/api/auth/yandex/callback',
admins: (env.YANDEX_ADMINS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
}
: null,
};
export const sources = {
traccar: !!config.traccar,
wirenboard: !!config.mqtt,
influx: !!config.influx,
};
+77
View File
@@ -0,0 +1,77 @@
// Демо-данные (fallback, когда реальные источники не сконфигурированы).
// Транспорт «двигается» — формируются живые треки, как от реальных устройств.
import { upsertEntity, addPosition, setChannels, updateChannel, addEvent, setEnergySeries, setTrips } from './store.js';
export function seedDemo() {
// Энерго-щит (стационар)
upsertEntity({ id: 'cab-07', clientId: 'tempelhoff', name: 'Щит КТП-7', type: 'energy', online: false, lat: 44.08, lon: 43.0, telemetry: { pwr_ext: 0, pwr_int: 3.9, temp: 24 } });
// Сетевые узлы mesh2
const nodes = [
['mesh2-hub', 'finik-хаб', 44.072, 43.05, { latency: 1.2, uplink: 940, clients: 38, cpu: 17 }, { ip: '10.200.0.1', role: 'Центральный хаб mesh2' }],
['mesh2-kn1', 'Keenetic · Склад', 44.064, 43.058, { latency: 3.4, uplink: 310, clients: 12, cpu: 22 }, { ip: '10.200.1.2', role: 'Спица · склад' }],
['mesh2-kn2', 'Keenetic · Ферма', 44.081, 43.041, { latency: 5.1, uplink: 95, clients: 7, cpu: 19 }, { ip: '10.200.2.2', role: 'Спица · ферма' }],
];
nodes.forEach(([id, name, lat, lon, t, meta]) => upsertEntity({ id, clientId: 'tempelhoff', name, type: 'network', online: true, lat, lon, telemetry: t, meta }));
// Каналы Wiren Board
setChannels([
{ id: 'mr6c_214/K1', device: 'WB-MR6C #214', name: 'Освещение цех', kind: 'relay', on: true },
{ id: 'mr6c_214/K2', device: 'WB-MR6C #214', name: 'Ворота', kind: 'relay', on: false },
{ id: 'mdm3_30/CH1', device: 'WB-MDM3 #30', name: 'Свет офис', kind: 'dimmer', on: true, level: 65 },
{ id: 'mr6c_214/IN1', device: 'WB-MR6C #214', name: 'Датчик двери', kind: 'input', on: false },
]);
// Энерго-ряд за сутки
setEnergySeries(
Array.from({ length: 24 }, (_, h) => {
const s = Math.max(0, Math.sin(((h - 6) / 12) * Math.PI));
return { t: `${String(h).padStart(2, '0')}:00`, pv: Math.round(s * 4200), soc: Math.round(40 + s * 55), load: Math.round(600 + Math.sin((h / 24) * Math.PI * 2) * 250) };
}),
);
// Поездки (журнал)
setTrips([
{ id: 't1', entityId: 'veh-123', start: new Date(Date.now() - 6 * 3.6e6).toISOString(), end: new Date(Date.now() - 5.2 * 3.6e6).toISOString(), from: 'База', to: 'Пятигорск', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41 },
{ id: 't2', entityId: 'veh-123', start: new Date(Date.now() - 2 * 3.6e6).toISOString(), end: new Date(Date.now() - 1.4 * 3.6e6).toISOString(), from: 'Ессентуки', to: 'База', distanceKm: 31.6, durationMin: 37, maxSpeed: 91, avgSpeed: 51 },
]);
addEvent({ id: 'e1', entityId: 'cab-07', callsign: 'Щит КТП-7', severity: 'critical', type: 'power_loss', text: 'Пропадание сетевого питания, переход на ИБП.', ts: new Date().toISOString(), ackRequired: true });
// Подвижные объекты + стартовые позиции
const moving = {
'veh-123': { name: 'ГАЗель-123', lat: 44.05, lon: 43.06, hd: 0.4 },
'veh-204': { name: 'МТЗ-82 трактор', lat: 44.02, lon: 43.11, hd: 1.2 },
};
Object.entries(moving).forEach(([id, m]) =>
upsertEntity({ id, clientId: 'tempelhoff', name: m.name, type: 'transport', online: true, lat: m.lat, lon: m.lon, telemetry: { speed: 0, sat: 10, fuel: 55, voltage: 13.6 } }),
);
// Тикер «движения» → живые треки (имитация приёма с устройств)
setInterval(() => {
for (const [id, m] of Object.entries(moving)) {
m.hd += (Math.random() - 0.5) * 0.6;
const speed = id === 'veh-204' ? Math.round(Math.random() * 12) : Math.round(35 + Math.random() * 45);
const step = (speed / 3600) * 0.02;
m.lat += Math.cos(m.hd) * step;
m.lon += Math.sin(m.hd) * step;
addPosition(id, +m.lat.toFixed(5), +m.lon.toFixed(5), { speed, sat: 9 + Math.round(Math.random() * 4), fuel: 50, voltage: 13.6 });
}
}, 3000);
// Датчик двери: периодическая смена статуса + событие — демонстрация live-обновления по WS
let door = false;
setInterval(() => {
door = !door;
updateChannel('mr6c_214/IN1', { on: door });
addEvent({
id: 'door-' + new Date().toISOString(),
entityId: 'cab-07',
callsign: 'Датчик двери · WB-MR6C #214',
severity: door ? 'warning' : 'info',
type: door ? 'door_open' : 'door_closed',
text: door ? 'Дверь шкафа открыта' : 'Дверь шкафа закрыта',
ts: new Date().toISOString(),
});
}, 9000);
}
+78
View File
@@ -0,0 +1,78 @@
// Метрики: онлайн-пользователи по разделам, журнал активности, нагрузка. In-memory.
const seen = new Map(); // userId -> { ts, section, name }
const activity = []; // {ts, userId, name, section, action}
let reqTotal = 0;
const reqTimes = []; // метки запросов за последнюю минуту
const ONLINE_MS = 120000; // онлайн = активность за 2 мин
const wsByUser = new Map(); // userId -> { count, name }
export function wsConnect(user) {
const e = wsByUser.get(user.id) || { count: 0, name: user.name };
e.count++;
wsByUser.set(user.id, e);
}
export function wsDisconnect(userId) {
const e = wsByUser.get(userId);
if (e && --e.count <= 0) wsByUser.delete(userId);
}
export function touch(user, section = '—', action = 'запрос') {
const now = Date.now();
seen.set(user.id, { ts: now, section, name: user.name });
reqTotal++;
reqTimes.push(now);
activity.unshift({ ts: new Date(now).toISOString(), userId: user.id, name: user.name, section, action });
if (activity.length > 800) activity.length = 800;
}
function onlineSet() {
const now = Date.now();
const out = new Map(); // id -> { name, section, secAgo }
for (const [id, v] of seen) if (now - v.ts < ONLINE_MS) out.set(id, { name: v.name, section: v.section, secAgo: Math.round((now - v.ts) / 1000) });
for (const [id, v] of wsByUser) if (!out.has(id)) out.set(id, { name: v.name, section: 'live', secAgo: 0 });
return out;
}
export function stats() {
const now = Date.now();
while (reqTimes.length && reqTimes[0] < now - 60000) reqTimes.shift();
const on = onlineSet();
const bySection = {};
for (const [, v] of on) bySection[v.section] = (bySection[v.section] || 0) + 1;
return {
onlineTotal: on.size,
bySection,
reqPerMin: reqTimes.length,
reqTotal,
rssMb: Math.round(process.memoryUsage().rss / 1048576),
uptimeSec: Math.round(process.uptime()),
};
}
export function onlineUsers() {
return [...onlineSet().entries()].map(([id, v]) => ({ id, name: v.name, section: v.section, secAgo: v.secAgo }));
}
export const getActivity = (limit = 150) => activity.slice(0, limit);
export function dailyByUser() {
const today = new Date().toISOString().slice(0, 10);
const m = {};
for (const a of activity) {
if (a.ts.slice(0, 10) !== today) continue;
(m[a.userId] = m[a.userId] || { name: a.name, count: 0, last: a.ts }).count++;
}
return Object.entries(m).map(([id, v]) => ({ id, ...v }));
}
// Раздел по пути API (для статистики онлайн-по-разделам)
export function sectionOf(path) {
if (path.includes('/entities') || path.includes('/tracks') || path.includes('/trips')) return 'GPS';
if (path.includes('/channels')) return 'Смарт';
if (path.includes('/energy')) return 'Энерго';
if (path.includes('/devices')) return 'Устройства';
if (path.includes('/admin')) return 'Админ';
if (path.includes('/billing')) return 'Кабинет';
return '—';
}
+197
View File
@@ -0,0 +1,197 @@
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 { startMqtt } from './adapters/mqtt.js';
import { startTraccar } from './adapters/traccar.js';
import { startInflux } from './adapters/influx.js';
seedDemo();
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))));
// --- Яндекс 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));
+77
View File
@@ -0,0 +1,77 @@
// Состояние в памяти + шина событий для WebSocket. Треки = история позиций по объекту.
import { EventEmitter } from 'node:events';
export const bus = new EventEmitter();
bus.setMaxListeners(0);
const MAX_TRACK = 1000;
const state = {
entities: new Map(),
tracks: new Map(), // id -> [{lat,lon,ts}]
events: [],
channels: [],
energySeries: [],
trips: [],
};
export function upsertEntity(e) {
const prev = state.entities.get(e.id) || {};
const merged = {
clientId: 'default',
type: 'transport',
name: e.id,
online: true,
telemetry: {},
...prev,
...e,
telemetry: { ...(prev.telemetry || {}), ...(e.telemetry || {}) },
updatedAt: e.updatedAt || new Date().toISOString(),
};
state.entities.set(merged.id, merged);
bus.emit('entity', merged);
return merged;
}
export function addPosition(id, lat, lon, telemetry = {}, ts) {
const now = ts || new Date().toISOString();
const e = upsertEntity({ id, lat, lon, telemetry, online: true, updatedAt: now });
let t = state.tracks.get(id);
if (!t) {
t = [];
state.tracks.set(id, t);
}
t.push({ lat, lon, ts: now });
if (t.length > MAX_TRACK) t.shift();
bus.emit('position', { id, lat, lon, ts: now });
return e;
}
export const setChannels = (chs) => {
state.channels = chs;
};
export function updateChannel(id, patch) {
const c = state.channels.find((x) => x.id === id);
if (c) {
Object.assign(c, patch);
bus.emit('channel', c);
}
}
export function addEvent(ev) {
state.events.unshift(ev);
if (state.events.length > 200) state.events.pop();
bus.emit('event', ev);
}
export const setEnergySeries = (s) => {
state.energySeries = s;
};
export const setTrips = (t) => {
state.trips = t;
};
export const getEntities = () => [...state.entities.values()];
export const getEntity = (id) => state.entities.get(id);
export const getTrack = (id) => state.tracks.get(id) || [];
export const getEvents = () => state.events;
export const getChannels = () => state.channels;
export const getEnergySeries = () => state.energySeries;
export const getTrips = (id) => (id ? state.trips.filter((t) => t.entityId === id) : state.trips);
+180
View File
@@ -0,0 +1,180 @@
// Арендаторы (мультиарендность). Seed-роли + динамические Яндекс-пользователи.
// Привязка устройств по IMEI → clientId владельца. Персист в JSON-файл.
import fs from 'node:fs';
import path from 'node:path';
import { USERS as SEED } from './auth.js';
import { config } from './config.js';
const FILE = path.join(config.dataDir, 'tenants.json');
let users = [];
let bindings = {}; // imei -> { userId, clientId, name }
// Единый набор прав администратора (источник — seed admin). Включает все разделы,
// в т.ч. новые (security/ПЦН) — чтобы добавление раздела доезжало без ручных правок tenants.json.
const ADMIN_ACCESS = (SEED.find((s) => s.admin) || {}).access || {};
function persist() {
try {
fs.mkdirSync(config.dataDir, { recursive: true });
fs.writeFileSync(FILE, JSON.stringify({ users, bindings }, null, 2));
} catch (e) {
console.error('[tenants] save', e.message);
}
}
function init() {
try {
const j = JSON.parse(fs.readFileSync(FILE, 'utf8'));
users = j.users || [];
bindings = j.bindings || {};
} catch {
users = [];
bindings = {};
}
for (const s of SEED) {
const u = users.find((x) => x.id === s.id);
if (!u) users.push({ ...s, clientId: s.admin ? 'all' : 'tempelhoff' });
else u.access = s.access; // прокидываем изменения RBAC из seed (новые разделы, напр. security)
}
// Существующие Яндекс-админы — обновляем права до актуального админ-набора (идемпотентно).
for (const u of users) if (u.admin && u.id.startsWith('yx-')) u.access = { ...ADMIN_ACCESS };
persist();
console.log(`[tenants] пользователей: ${users.length}, привязок устройств: ${Object.keys(bindings).length}`);
}
init();
export const getUser = (id) => users.find((u) => u.id === id) || null;
export const allUsers = () => users;
export function upsertYandex({ email, name, sub }) {
const id = 'yx-' + sub;
const isAdmin = (config.yandex?.admins || []).includes(String(email || '').toLowerCase());
let u = users.find((x) => x.id === id);
if (!u) {
// если email привязан к чужому кабинету — присоединяемся к нему (общий clientId)
const linkCid = isAdmin ? null : linkedClientId(email);
u = {
id,
email,
name: name || email || 'Пользователь',
role: isAdmin ? 'Администратор' : linkCid ? 'Связанный аккаунт' : 'Кабинет арендатора',
admin: isAdmin,
clientId: isAdmin ? 'all' : linkCid || 't-' + String(sub).slice(-6),
// Новый арендатор — GPS-кабинет своих трекеров (+ «Мои устройства»).
// Энерго/Смарт/Сеть — инфра-разделы, выдаёт админ при необходимости.
access: isAdmin ? { ...ADMIN_ACCESS } : { gps: { caps: ['view', 'edit', 'reports'], objects: 'all' } },
};
users.push(u);
persist();
} else if (isAdmin && !u.admin) {
u.admin = true;
u.clientId = 'all';
u.access = { ...ADMIN_ACCESS };
persist();
}
return u;
}
// --- Привязка нескольких аккаунтов к одному кабинету (общий clientId) ---
// Владелец кабинета добавляет email; когда тот входит через Яндекс — получает тот же clientId.
export function linkedClientId(email) {
const e = String(email || '').toLowerCase();
if (!e) return null;
const owner = users.find((u) => Array.isArray(u.linked) && u.linked.includes(e));
return owner ? owner.clientId : null;
}
export function linksOf(userId) {
const u = getUser(userId);
return (u && u.linked) || [];
}
export function addLink(userId, email) {
const u = getUser(userId);
if (!u) return [];
const e = String(email || '').trim().toLowerCase();
u.linked = Array.isArray(u.linked) ? u.linked : [];
if (e && !u.linked.includes(e)) {
u.linked.push(e);
// если такой пользователь уже входил и не админ — сразу присоединить к кабинету
const existing = users.find((x) => String(x.email || '').toLowerCase() === e && x.id !== userId && !x.admin);
if (existing) existing.clientId = u.clientId;
persist();
}
return u.linked;
}
export function removeLink(userId, email) {
const u = getUser(userId);
if (!u || !Array.isArray(u.linked)) return [];
u.linked = u.linked.filter((x) => x !== String(email || '').toLowerCase());
persist();
return u.linked;
}
export function bindDevice(userId, imei, name) {
const u = getUser(userId);
if (!u) return null;
bindings[imei] = { userId, clientId: u.clientId, name: name || 'Трекер ' + imei };
persist();
return bindings[imei];
}
export function unbindDevice(userId, imei) {
if (bindings[imei] && bindings[imei].userId === userId) {
delete bindings[imei];
persist();
}
}
export const devicesOf = (userId) =>
Object.entries(bindings)
.filter(([, b]) => b.userId === userId)
.map(([imei, b]) => ({ imei, name: b.name }));
export const imeiOwner = (imei) => (bindings[imei] ? bindings[imei].clientId : null);
// --- Админ: блокировки и CRUD ---
// blocked = { from, until } (ISO|null). Блок активен, если now в окне.
export function setBlock(id, { from = null, until = null } = {}) {
const u = getUser(id);
if (!u || u.admin) return null;
u.blocked = { from: from || null, until: until || null };
persist();
return u;
}
export function clearBlock(id) {
const u = getUser(id);
if (!u) return null;
delete u.blocked;
persist();
return u;
}
export function isBlocked(user, nowIso) {
const b = user && user.blocked;
if (!b) return false;
const now = nowIso || new Date().toISOString();
if (b.from && now < b.from) return false; // блок ещё не начался
if (b.until && now > b.until) return false; // блок истёк / разморожен
return true;
}
export function addUser({ email, name, clientId } = {}) {
const base = (email || name || 'user').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 12) || 'user';
let id = 'u-' + base;
let n = 1;
while (getUser(id)) id = `u-${base}${n++}`;
const u = {
id,
email: email || '',
name: name || email || id,
role: 'Арендатор',
clientId: clientId || 't-' + id.slice(-6),
access: { gps: { caps: ['view', 'edit', 'reports'], objects: 'all' } },
};
users.push(u);
persist();
return u;
}
export function deleteUser(id) {
const i = users.findIndex((u) => u.id === id);
if (i >= 0 && !users[i].admin) {
users.splice(i, 1);
persist();
return true;
}
return false;
}