@alizadefaig/pos-server 1.2.29 → 1.2.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  const PrinterRepository = require('../repositories/PrinterRepository');
2
2
  const KdsService = require('./KdsService');
3
3
  const OrderProduct = require('../models/OrderProduct');
4
- const {money, qty, round2, toBool, sum} = require('../support/num');
4
+ const {money, qty, round2, toBool, toFloat, sum} = require('../support/num');
5
5
  const {now, nowSql, parse} = require('../support/time');
6
6
  const {DEFAULT_TIMEZONE, zonedParts} = require('../support/tz');
7
7
  const {translated} = require('../support/lang');
@@ -463,7 +463,19 @@ class PrintService {
463
463
 
464
464
  // ------------------------------------------------------------ счёт гостю
465
465
 
466
- /** Товары счёта: гость → (товар + цена), модификаторы свёрнуты по имени и цене. */
466
+ /**
467
+ * Цена за 1 шт в счёте. У обычной позиции — с модификаторами, как в `OrderProduct.calculate`
468
+ * (иначе тех-карта с ценой в модификаторах печаталась «1 × 0 = 5»), у повременной — тариф.
469
+ */
470
+ billUnitPrice(item) {
471
+ if (item.type === TYPE_TIMER) {
472
+ return item.price;
473
+ }
474
+ const modifiersTotal = (item.modifiers || []).reduce((carry, modifier) => carry + toFloat(modifier.total), 0);
475
+ return money(toFloat(item.price) + Math.max(modifiersTotal, 0));
476
+ }
477
+
478
+ /** Товары счёта: гость → (товар + цена за 1 шт), модификаторы свёрнуты по имени и цене. */
467
479
  billProducts(order, showTableServiceCharge) {
468
480
  const byGuest = new Map();
469
481
  for (const item of order.products) {
@@ -477,7 +489,7 @@ class PrintService {
477
489
  for (const group of byGuest.values()) {
478
490
  const byProduct = new Map();
479
491
  for (const item of group) {
480
- const key = `${item.product_id}_${item.price}`;
492
+ const key = `${item.product_id}_${this.billUnitPrice(item)}`;
481
493
  if (!byProduct.has(key)) {
482
494
  byProduct.set(key, []);
483
495
  }
@@ -512,7 +524,7 @@ class PrintService {
512
524
  // весовой товар печатается как «1.5 kq», цена — за 1 kq
513
525
  is_weight_item: Boolean(first.product && toBool(first.product.is_weight_item)),
514
526
  unit_id: first.unit_id ?? null,
515
- price: first.price,
527
+ price: this.billUnitPrice(first),
516
528
  discount_amount: sum(productGroup, 'discount_amount'),
517
529
  service_charge_amount: sum(productGroup, 'service_charge_amount'),
518
530
  total: showTableServiceCharge ? sum(productGroup, 'total') : sum(productGroup, 'subtotal'),
@@ -69,6 +69,28 @@ const parseDate = (value, timeZone) => {
69
69
  // Collection::sum по полю — float в порядке коллекции, как в PHP.
70
70
  const sumOf = (rows, key) => rows.reduce((carry, row) => carry + toFloat(row[key]), 0);
71
71
 
72
+ /** Суммы по ключу за один проход: фильтр коллекции на каждую строку справочника давал O(n×m). */
73
+ const totalsBy = (rows, key, valueKey) => {
74
+ const totals = new Map();
75
+ for (const row of rows) {
76
+ if (row[key]) {
77
+ totals.set(row[key], (totals.get(row[key]) ?? 0) + toFloat(row[valueKey]));
78
+ }
79
+ }
80
+ return totals;
81
+ };
82
+
83
+ const groupBy = (rows, key) => {
84
+ const groups = new Map();
85
+ for (const row of rows) {
86
+ if (!groups.has(row[key])) {
87
+ groups.set(row[key], []);
88
+ }
89
+ groups.get(row[key]).push(row);
90
+ }
91
+ return groups;
92
+ };
93
+
72
94
  /**
73
95
  * Порт App\Http\Controllers\Api\Platform\ReportController (reports.getReport): X/Z-отчёт кассы
74
96
  * без облака. Форма ответа совпадает с Laravel поле в поле — её печатает ReportReceipt.vue.
@@ -321,16 +343,16 @@ class ReportService {
321
343
  }));
322
344
  const aliveProducts = orderProducts.filter((row) => !row.deleted_at);
323
345
 
324
- // Удалённые товары Laravel не читает (SoftDeletes без withTrashed). Цех категории — для
325
- // мутатора Product::productWorkshopId: ignore_workshop в select Laravel нет, поэтому
326
- // мутатор отдаёт колонку, а пустую — цех живой категории.
346
+ // Справочники — вместе с удалёнными, как в Laravel (withTrashed): удаление товара,
347
+ // категории, цеха, зала, способа оплаты или источника не отменяет продаж прошлого.
348
+ // Цех категории — для мутатора Product::productWorkshopId: ignore_workshop в select
349
+ // Laravel нет, поэтому мутатор отдаёт колонку, а пустую — цех категории.
327
350
  const productRows = await this.db.all(
328
351
  `SELECT p.id, p.full_name, p.product_category_id, p.product_workshop_id, p.is_weight_item, p.unit_id,
329
352
  c.product_workshop_id AS category_workshop_id
330
353
  FROM products p
331
- LEFT JOIN product_categories c ON c.id = p.product_category_id${this.notDeleted('c')}
332
- WHERE p.deleted_at IS NULL
333
- AND p.id IN (SELECT op.product_id FROM order_products op WHERE op.order_id IN (${orderIds}))`,
354
+ LEFT JOIN product_categories c ON c.id = p.product_category_id
355
+ WHERE p.id IN (SELECT op.product_id FROM order_products op WHERE op.order_id IN (${orderIds}))`,
334
356
  filter.params
335
357
  );
336
358
  const products = new Map(productRows.map((product) => [product.id, {
@@ -379,19 +401,20 @@ class ReportService {
379
401
 
380
402
  async byTable(orders, companyId) {
381
403
  const halls = await this.db.all(
382
- `SELECT h.id, h.name FROM halls h WHERE h.company_id = ?${this.notDeleted('h')}`,
404
+ `SELECT h.id, h.name FROM halls h WHERE h.company_id = ?`,
383
405
  [companyId]
384
406
  );
407
+ const totals = totalsBy(orders, 'table_id', 'total');
385
408
  const rows = [];
386
409
  for (const hall of halls) {
387
410
  const tables = await this.db.all(
388
- `SELECT t.id, t.label FROM tables t WHERE t.hall_id = ?${this.notDeleted('t')} ORDER BY t."order"`,
411
+ `SELECT t.id, t.label FROM tables t WHERE t.hall_id = ? ORDER BY t."order"`,
389
412
  [hall.id]
390
413
  );
391
414
  for (const table of tables) {
392
415
  rows.push({
393
416
  label: `${hall.name ?? ''} ${table.label ?? ''}`,
394
- value: round(sumOf(orders.filter((order) => order.table_id === table.id), 'total'), 2),
417
+ value: round(totals.get(table.id) ?? 0, 2),
395
418
  });
396
419
  }
397
420
  }
@@ -400,19 +423,20 @@ class ReportService {
400
423
 
401
424
  async byWorkshop(aliveProducts, products, team) {
402
425
  const workshops = await this.db.all(
403
- `SELECT w.id, w.name FROM product_workshops w WHERE w.team_id = ?${this.notDeleted('w')}`,
426
+ `SELECT w.id, w.name FROM product_workshops w WHERE w.team_id = ?`,
404
427
  [team ? team.id : this.context.teamId]
405
428
  );
406
- return workshops.map((workshop) => {
407
- let value = 0;
408
- for (const orderProduct of aliveProducts) {
409
- const product = products.get(orderProduct.product_id);
410
- if (product && (product.product_workshop_id || product.category_workshop_id) === workshop.id) {
411
- value += orderProduct.total;
412
- }
429
+ const totals = new Map();
430
+ for (const orderProduct of aliveProducts) {
431
+ const product = products.get(orderProduct.product_id);
432
+ const workshopId = product && (product.product_workshop_id || product.category_workshop_id);
433
+ if (workshopId) {
434
+ totals.set(workshopId, (totals.get(workshopId) ?? 0) + orderProduct.total);
413
435
  }
414
- return {label: workshop.name, value: round(value, 2)};
415
- }).filter((row) => row.value > 0);
436
+ }
437
+ return workshops
438
+ .map((workshop) => ({label: workshop.name, value: round(totals.get(workshop.id) ?? 0, 2)}))
439
+ .filter((row) => row.value > 0);
416
440
  }
417
441
 
418
442
  async byEmployee(orders) {
@@ -422,12 +446,13 @@ class ReportService {
422
446
  }
423
447
  const staff = await this.db.all(
424
448
  `SELECT tu.id, u.name FROM team_users tu
425
- LEFT JOIN users u ON u.id = tu.user_id${this.notDeleted('u')}
449
+ LEFT JOIN users u ON u.id = tu.user_id
426
450
  WHERE tu.id IN (${servingIds.map(() => '?').join(', ')})`,
427
451
  servingIds
428
452
  );
453
+ const byServing = groupBy(orders, 'serving_id');
429
454
  return staff.map((teamUser) => {
430
- const own = orders.filter((order) => order.serving_id === teamUser.id);
455
+ const own = byServing.get(teamUser.id) ?? [];
431
456
  return {
432
457
  label: teamUser.name ?? null,
433
458
  value: round(sumOf(own, 'total'), 2),
@@ -445,44 +470,49 @@ class ReportService {
445
470
  }
446
471
  const categories = await this.db.all(
447
472
  `SELECT c.id, c.name FROM product_categories c
448
- WHERE c.deleted_at IS NULL AND c.id IN (${categoryIds.map(() => '?').join(', ')})`,
473
+ WHERE c.id IN (${categoryIds.map(() => '?').join(', ')})`,
449
474
  categoryIds
450
475
  );
451
- return categories.map((category) => {
452
- const ids = new Set([...products.values()]
453
- .filter((product) => product.product_category_id === category.id)
454
- .map((product) => product.id));
455
- return {
456
- label: translated(category.name, locale),
457
- value: round(sumOf(aliveProducts.filter((row) => ids.has(row.product_id)), 'total'), 2),
458
- };
459
- });
476
+ const totals = new Map();
477
+ for (const row of aliveProducts) {
478
+ const product = products.get(row.product_id);
479
+ if (product && product.product_category_id) {
480
+ totals.set(product.product_category_id, (totals.get(product.product_category_id) ?? 0) + row.total);
481
+ }
482
+ }
483
+ return categories.map((category) => ({
484
+ label: translated(category.name, locale),
485
+ value: round(totals.get(category.id) ?? 0, 2),
486
+ }));
460
487
  }
461
488
 
462
489
  byProduct(orderProducts, products) {
463
- const rows = [];
464
- for (const product of products.values()) {
465
- // Снятые позиции тоже: их workshop_print_quantity — то, что успело уйти на кухню.
466
- const groups = new Map();
467
- for (const row of orderProducts) {
468
- if (row.product_id !== product.id) {
469
- continue;
470
- }
471
- const key = String(row.price);
472
- if (!groups.has(key)) {
473
- groups.set(key, []);
474
- }
475
- groups.get(key).push(row);
490
+ // Снятые позиции тоже: их workshop_print_quantity — то, что успело уйти на кухню.
491
+ // Товара нет в базе вовсе — строка «Silinən məhsul», продажа из отчёта не пропадает.
492
+ const byProduct = new Map();
493
+ for (const row of orderProducts) {
494
+ const productKey = row.product_id ?? '';
495
+ if (!byProduct.has(productKey)) {
496
+ byProduct.set(productKey, new Map());
476
497
  }
498
+ const groups = byProduct.get(productKey);
499
+ const key = String(row.price);
500
+ if (!groups.has(key)) {
501
+ groups.set(key, {quantity: 0, price: row.price});
502
+ }
503
+ groups.get(key).quantity += row.workshop_print_quantity;
504
+ }
505
+ const rows = [];
506
+ for (const [productId, groups] of byProduct) {
507
+ const product = (productId && products.get(productId)) || null;
477
508
  for (const group of groups.values()) {
478
- const quantity = sumOf(group, 'workshop_print_quantity');
479
509
  rows.push({
480
- label: product.full_name,
481
- quantity,
482
- value: round(quantity * group[0].price, 2),
483
- price: group[0].price,
484
- is_weight_item: toBool(product.is_weight_item),
485
- unit_id: product.unit_id ?? null,
510
+ label: (product && product.full_name) ?? DELETED_PRODUCT,
511
+ quantity: group.quantity,
512
+ value: round(group.quantity * group.price, 2),
513
+ price: group.price,
514
+ is_weight_item: Boolean(product && toBool(product.is_weight_item)),
515
+ unit_id: product ? product.unit_id ?? null : null,
486
516
  });
487
517
  }
488
518
  }
@@ -529,12 +559,13 @@ class ReportService {
529
559
  }
530
560
  const methods = await this.db.all(
531
561
  `SELECT m.id, m.name FROM payment_methods m
532
- WHERE m.id IN (${methodIds.map(() => '?').join(', ')})${this.notDeleted('m')}`,
562
+ WHERE m.id IN (${methodIds.map(() => '?').join(', ')})`,
533
563
  methodIds
534
564
  );
565
+ const totals = totalsBy(payments, 'payment_method_id', 'amount');
535
566
  return methods.map((method) => ({
536
567
  label: method.name,
537
- value: round(sumOf(payments.filter((payment) => payment.payment_method_id === method.id), 'amount'), 2),
568
+ value: round(totals.get(method.id) ?? 0, 2),
538
569
  }));
539
570
  }
540
571
 
@@ -545,12 +576,13 @@ class ReportService {
545
576
  }
546
577
  const sources = await this.db.all(
547
578
  `SELECT s.id, s.name FROM order_sources s
548
- WHERE s.id IN (${sourceIds.map(() => '?').join(', ')})${this.notDeleted('s')}`,
579
+ WHERE s.id IN (${sourceIds.map(() => '?').join(', ')})`,
549
580
  sourceIds
550
581
  );
582
+ const totals = totalsBy(orders, 'order_source_id', 'total');
551
583
  return sources.map((source) => ({
552
584
  label: source.name,
553
- value: round(sumOf(orders.filter((order) => order.order_source_id === source.id), 'total'), 2),
585
+ value: round(totals.get(source.id) ?? 0, 2),
554
586
  }));
555
587
  }
556
588
 
@@ -6,7 +6,7 @@ const {phpArray, zeroOneToBool, filterBool} = require('./php');
6
6
  * app.init). Фронт терминала читает отсюда всё: настройки чека, терминала, KDS, управления.
7
7
  *
8
8
  * Порядок ключей как в PHP: существующие остаются на месте, недостающие дописываются в конец в
9
- * порядке manage → terminal_settings → receipts → kds.
9
+ * порядке manage → terminal_settings → receipts → kds → loyalty.
10
10
  */
11
11
 
12
12
  const isContainer = (value) => value !== null && typeof value === 'object';
@@ -48,6 +48,14 @@ const kdsSettings = (value) => {
48
48
  return kds;
49
49
  };
50
50
 
51
+ /** Лояльность — как KDS: секции может не быть, флаги приводятся к boolean (TeamSettingsCast). */
52
+ const loyaltySettings = (value) => {
53
+ const loyalty = isContainer(value) ? asObject(value) : {};
54
+ loyalty.enabled = filterBool(loyalty.enabled ?? false);
55
+ loyalty.balance_enabled = filterBool(loyalty.balance_enabled ?? false);
56
+ return loyalty;
57
+ };
58
+
51
59
  /**
52
60
  * @param {string|object|null} raw teams.settings (строка JSON или уже разобранный объект)
53
61
  * @param {string|null} currencySymbol символ валюты команды — для конфига счёта
@@ -62,6 +70,7 @@ const teamSettings = (raw, currencySymbol = null) => {
62
70
  // receipts — из исходного JSON: normalizeReceipts тот же, что уходит на терминал в печати.
63
71
  settings.receipts = normalizeReceipts(isContainer(decoded) ? decoded.receipts : null, currencySymbol);
64
72
  settings.kds = kdsSettings(settings.kds);
73
+ settings.loyalty = loyaltySettings(settings.loyalty);
65
74
  return settings;
66
75
  };
67
76
 
@@ -0,0 +1,93 @@
1
+ const {HttpError} = require('../errors');
2
+ const {alive} = require('./rows');
3
+ const {toBool} = require('./num');
4
+ const {value, isUuid} = require('../auth/scope');
5
+
6
+ /**
7
+ * Привязки входа на кассу — порт App\Services\Platform\TerminalAccess (миграция Laravel
8
+ * 2026_10_28_000001):
9
+ * - team_users.company_id — сотрудник работает только в этом заведении (null — в любом заведении команды);
10
+ * - terminals.team_user_id — на терминал входит только этот сотрудник (и по PIN, и логином);
11
+ * - terminals.company_switchable — терминал не закреплён за заведением: при входе выбирается любое
12
+ * заведение команды, а terminals.company_id остаётся заведением по умолчанию.
13
+ *
14
+ * Правила действуют всегда, без PLATFORM_SCOPE_CHECK: колонки по умолчанию пустые, поэтому для
15
+ * команд без привязок поведение прежнее. Правишь здесь — правь и Laravel.
16
+ */
17
+ const DENY_MESSAGE = 'Bu terminala giriş icazəniz yoxdur';
18
+ const ERROR_CODE = 'terminal_access_denied';
19
+
20
+ const same = (a, b) => String(a ?? '').toLowerCase() === String(b ?? '').toLowerCase();
21
+
22
+ /** Заведение доступно терминалу: своё или любое заведение команды у переключаемого. */
23
+ const companyAllowed = async (db, terminal, companyId) => {
24
+ companyId = value(companyId);
25
+ if (companyId === null || same(terminal.company_id, companyId)) {
26
+ return true;
27
+ }
28
+ if (!toBool(terminal.company_switchable) || !isUuid(companyId)) {
29
+ return false;
30
+ }
31
+ return Boolean(await db.get(
32
+ `SELECT 1 AS ok FROM companies WHERE id = ? AND team_id = ?${alive(db)} LIMIT 1`,
33
+ [companyId, terminal.team_id],
34
+ ));
35
+ };
36
+
37
+ /**
38
+ * Сотрудник может работать на терминале в этом заведении (PIN, employee-id). Закреплённый
39
+ * терминал — только для его сотрудника.
40
+ */
41
+ const employeeAllowed = (member, terminal, companyId = null) => {
42
+ if (terminal.team_user_id && !same(terminal.team_user_id, member.id)) {
43
+ return false;
44
+ }
45
+ return memberInCompany(member, terminal, companyId);
46
+ };
47
+
48
+ /**
49
+ * Вход терминала логином (app.auth, loginTerminal): закрепление терминала за сотрудником
50
+ * его не касается, оно только для PIN. Проверяется лишь заведение сотрудника.
51
+ */
52
+ const memberInCompany = (member, terminal, companyId = null) => {
53
+ companyId = value(companyId) ?? terminal.company_id;
54
+ return !member.company_id || same(member.company_id, companyId);
55
+ };
56
+
57
+ /**
58
+ * Заведения, которые можно выбрать на терминале: у обычного — только его, у переключаемого —
59
+ * все заведения команды; сотруднику с привязкой — только его заведение. `[{id, name}]`.
60
+ */
61
+ const companiesFor = async (db, terminal, member = null) => {
62
+ // В снапшоте кассы created_at у companies нет — там порядок по имени.
63
+ const order = db.dialect === 'pgsql' ? 'created_at' : 'name';
64
+ let companies = toBool(terminal.company_switchable)
65
+ ? await db.all(`SELECT id, name FROM companies WHERE team_id = ?${alive(db)} ORDER BY ${order}`, [terminal.team_id])
66
+ : await db.all(`SELECT id, name FROM companies WHERE id = ?${alive(db)}`, [terminal.company_id]);
67
+ if (member?.company_id) {
68
+ companies = companies.filter((company) => same(company.id, member.company_id));
69
+ }
70
+ return companies.map((company) => ({id: company.id, name: company.name}));
71
+ };
72
+
73
+ /** Терминал виден сотруднику в списке входа логином (app.auth); закрепление не учитывается. */
74
+ const visibleTo = async (db, terminal, member) => (await companiesFor(db, terminal, member)).length > 0;
75
+
76
+ /**
77
+ * Переключаемый терминал работает в заведении из заголовка company-id: подменяем company_id
78
+ * только в памяти, чтобы ресурсы, синк и печать видели выбранное заведение.
79
+ */
80
+ const applyCompany = async (db, terminal, companyId) => {
81
+ companyId = value(companyId);
82
+ if (!terminal || !toBool(terminal.company_switchable) || companyId === null
83
+ || same(terminal.company_id, companyId)
84
+ || !await companyAllowed(db, terminal, companyId)) {
85
+ return terminal;
86
+ }
87
+ terminal.company_id = companyId;
88
+ return terminal;
89
+ };
90
+
91
+ const deny = () => new HttpError(403, {message: DENY_MESSAGE, error_code: ERROR_CODE});
92
+
93
+ module.exports = {DENY_MESSAGE, ERROR_CODE, companyAllowed, employeeAllowed, memberInCompany, companiesFor, visibleTo, applyCompany, deny};
@@ -1,6 +1,7 @@
1
1
  const {createHmac, timingSafeEqual} = require('node:crypto');
2
2
  const {HttpError} = require('../errors');
3
3
  const {toBool} = require('./num');
4
+ const {employeeAllowed} = require('./terminalAccess');
4
5
 
5
6
  /**
6
7
  * Проверка PIN сотрудника на кассе без облака: офлайн-`auth.unlock` (routes/desktop/api/offline.js)
@@ -87,7 +88,11 @@ const hashMatches = (expected, actual) => {
87
88
  return timingSafeEqual(Buffer.from(expected), Buffer.from(actual));
88
89
  };
89
90
 
90
- const findUnlockUser = async (db, users, code, salt = null) => {
91
+ /**
92
+ * terminal — терминал из init-блоба: привязки сотрудника к заведению и терминала к сотруднику
93
+ * (support/terminalAccess.js) действуют и без облака.
94
+ */
95
+ const findUnlockUser = async (db, users, code, salt = null, terminal = null) => {
91
96
  const value = String(code);
92
97
  const hashed = filled(salt) ? codeHash(salt, value) : null;
93
98
  for (const user of users) {
@@ -100,6 +105,9 @@ const findUnlockUser = async (db, users, code, salt = null) => {
100
105
  if (!matches) {
101
106
  continue;
102
107
  }
108
+ if (terminal && !employeeAllowed(user, terminal)) {
109
+ continue;
110
+ }
103
111
  if (toBool(user.can_work_with_terminal) || await roleIsOwner(db, user)) {
104
112
  return user;
105
113
  }
@@ -0,0 +1,5 @@
1
+ -- Привязки входа на кассу (Laravel 2026_10_28_000001, support/terminalAccess.js): сотрудник к
2
+ -- заведению, терминал к сотруднику, терминал без закреплённого заведения. Снапшот заполнит колонки.
3
+ ALTER TABLE team_users ADD COLUMN company_id TEXT;
4
+ ALTER TABLE terminals ADD COLUMN team_user_id TEXT;
5
+ ALTER TABLE terminals ADD COLUMN company_switchable INTEGER DEFAULT 0;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Сетевая диагностика Node кассы: на каком адресе он слушает и доходят ли до него соединения
3
+ * из зала. Уезжает в /local/status (`net`) и оттуда в облачную диагностику кассы.
4
+ *
5
+ * Главный вопрос, на который отвечает: ведомый не находит мастера — пакеты до мастера не доходят
6
+ * вовсе (брандмауэр, изоляция Wi-Fi) или доходят, но отвергаются (ключ, origin, токен)?
7
+ * `remote.connections` считает TCP-соединения с не-loopback адресов ещё до любой обработки,
8
+ * `remote.requests` — HTTP-запросы с итоговым статусом.
9
+ */
10
+
11
+ const MAX_PEERS = 10;
12
+
13
+ const state = {
14
+ listen: {host: null, port: null, address: null, error: null, at: null},
15
+ remote: {
16
+ connections: 0,
17
+ last_connection_from: null,
18
+ last_connection_at: null,
19
+ requests: 0,
20
+ statuses: {},
21
+ last_request: null,
22
+ peers: {},
23
+ },
24
+ };
25
+
26
+ const isLoopback = (address) => {
27
+ const value = String(address || '');
28
+ return value === '::1' || value.startsWith('127.') || value.startsWith('::ffff:127.') || value === '';
29
+ };
30
+
31
+ const plain = (address) => String(address || '').replace(/^::ffff:/, '');
32
+
33
+ const notePeer = (address) => {
34
+ const peers = state.remote.peers;
35
+ if (peers[address] === undefined && Object.keys(peers).length >= MAX_PEERS) {
36
+ return;
37
+ }
38
+ peers[address] = (peers[address] || 0) + 1;
39
+ };
40
+
41
+ /** Подключить к http.Server до listen(). host — с каким адресом слушаем (0.0.0.0 / localhost). */
42
+ const attach = (server, {host, port}) => {
43
+ state.listen.host = host;
44
+ state.listen.port = port;
45
+ server.on('listening', () => {
46
+ const address = server.address();
47
+ state.listen.address = address && typeof address === 'object'
48
+ ? `${address.address}:${address.port}`
49
+ : String(address);
50
+ state.listen.error = null;
51
+ state.listen.at = new Date().toISOString();
52
+ });
53
+ server.on('error', (e) => {
54
+ state.listen.error = `${e.code || ''} ${e.message}`.trim();
55
+ state.listen.at = new Date().toISOString();
56
+ // Раньше слушателя 'error' не было и сбой listen (занятый порт) ронял процесс — в режиме
57
+ // utilityProcess Electron его перезапускает. Сохраняем это поведение.
58
+ if (server.listenerCount('error') === 1) {
59
+ throw e;
60
+ }
61
+ });
62
+ server.on('connection', (socket) => {
63
+ const address = socket.remoteAddress;
64
+ if (isLoopback(address)) {
65
+ return;
66
+ }
67
+ state.remote.connections += 1;
68
+ state.remote.last_connection_from = plain(address);
69
+ state.remote.last_connection_at = new Date().toISOString();
70
+ notePeer(plain(address));
71
+ });
72
+ };
73
+
74
+ /** express middleware: HTTP-запросы с не-loopback адресов и чем они закончились. */
75
+ const middleware = (req, res, next) => {
76
+ const address = req.socket && req.socket.remoteAddress;
77
+ if (isLoopback(address)) {
78
+ return next();
79
+ }
80
+ state.remote.requests += 1;
81
+ const path = String(req.path || req.url || '').slice(0, 80);
82
+ res.on('finish', () => {
83
+ const bucket = `${Math.floor(res.statusCode / 100)}xx`;
84
+ state.remote.statuses[bucket] = (state.remote.statuses[bucket] || 0) + 1;
85
+ state.remote.last_request = {
86
+ from: plain(address),
87
+ method: req.method,
88
+ path,
89
+ status: res.statusCode,
90
+ at: new Date().toISOString(),
91
+ };
92
+ });
93
+ return next();
94
+ };
95
+
96
+ const snapshot = () => JSON.parse(JSON.stringify(state));
97
+
98
+ module.exports = {attach, middleware, snapshot, isLoopback};