@7365admin1/core 3.32.2-staging.76 → 3.32.2-staging.78

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.
@@ -0,0 +1,20 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Fix three Dahua HTTP API defects found by checking our integration against the
6
+ vendor specification (V3.37):
7
+
8
+ - The ANPR reconnect loop retried authentication ten times at one-second
9
+ intervals. The device locks the account for 1800 seconds after three failed
10
+ logins in thirty, so our own recovery could take ANPR down at a site for half
11
+ an hour. Authentication now backs off well past the device's counting window,
12
+ and a network fault is distinguished from an authentication failure.
13
+ - 401 and 403 were handled backwards. Per spec §3.4, 401 is the digest
14
+ challenge (retry) and 403 is a credentials rejection (stop). We treated 403 as
15
+ an account lock and killed the listener, and counted 401s toward a "wrong
16
+ password" warning.
17
+ - `updatePlateNumber`, `bulkInsertPlateNumber` and `removePlateNumber`
18
+ interpolated values into the CGI query string unencoded, against the spec's
19
+ stated RFC 3986 requirement. A plate or owner name containing a space or "&"
20
+ broke the request or appended extra parameters to it.
@@ -0,0 +1,26 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Stop rejecting site names that only differ by their building number
6
+
7
+ Adding a site refused any name within a Levenshtein distance of 2 of an
8
+ existing site in the same organisation, and told the user to contact support.
9
+ That is exactly one character, so "Winsland House II" could not be added next
10
+ to "Winsland House I". The same rule blocked "Tower A" beside "Tower B",
11
+ "Phase 2" beside "Phase 1" and "Block 15" beside "Block 5" — the standard way
12
+ buildings are named here.
13
+
14
+ The check now treats trailing numbers, Roman numerals and single letters as the
15
+ part that tells two buildings apart: if they differ, the names are different
16
+ sites and the distance is never measured. Real duplicates are still refused —
17
+ an exact repeat, a different capitalisation, stray or doubled whitespace,
18
+ punctuation-only differences, and a one or two character typo within the same
19
+ building. "House 1" and "House I" are still read as the same building.
20
+
21
+ The refusal now names the site it matched and says what to do about it instead
22
+ of pointing the user at support.
23
+
24
+ `site.repo.getByExactName` also built its case-insensitive regex from the raw
25
+ name; a name containing regex characters either threw or matched a site it is
26
+ not. It is escaped.
package/dist/index.js CHANGED
@@ -11811,8 +11811,9 @@ function useSiteRepo() {
11811
11811
  } catch (error2) {
11812
11812
  throw new import_node_server_utils19.BadRequestError("Invalid org ID format.");
11813
11813
  }
11814
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11814
11815
  const query2 = {
11815
- name: { $regex: new RegExp(`^${name}$`, "i") },
11816
+ name: { $regex: new RegExp(`^${escapedName}$`, "i") },
11816
11817
  // Case-insensitive exact match
11817
11818
  orgId,
11818
11819
  status: { $ne: "deleted" }
@@ -24777,6 +24778,45 @@ function shouldAlertOnFailure(consecutiveFailures) {
24777
24778
  return (consecutiveFailures - CAMERA_ALERT_EVERY_AFTER) % CAMERA_ALERT_EVERY_AFTER === 0;
24778
24779
  }
24779
24780
 
24781
+ // src/utils/dahua-protocol.util.ts
24782
+ var DAHUA_AUTH_ATTEMPTS_BEFORE_ALERT = 2;
24783
+ var AUTH_BACKOFF_MS = [6e4, 3e5, 9e5];
24784
+ function dahuaAuthBackoffMs(consecutiveAuthFailures) {
24785
+ const index = Math.min(
24786
+ Math.max(consecutiveAuthFailures, 1),
24787
+ AUTH_BACKOFF_MS.length
24788
+ );
24789
+ return AUTH_BACKOFF_MS[index - 1];
24790
+ }
24791
+ function classifyDahuaStatus(statusCode) {
24792
+ if (statusCode === 401)
24793
+ return "challenge";
24794
+ if (statusCode === 403)
24795
+ return "rejected";
24796
+ if (statusCode === 400 || statusCode === 500)
24797
+ return "fatal";
24798
+ return "proceed";
24799
+ }
24800
+ function classifyDahuaFailure(message) {
24801
+ const text = String(message ?? "");
24802
+ if (/\b403\b|forbidden/i.test(text))
24803
+ return "rejected";
24804
+ if (/\b401\b|unauthorized|invalid authority|digest/i.test(text)) {
24805
+ return "challenge";
24806
+ }
24807
+ return "transient";
24808
+ }
24809
+ var enc = (value) => encodeURIComponent(String(value));
24810
+ function buildPlateUpdateEndpoint(value) {
24811
+ return `/cgi-bin/recordUpdater.cgi?action=update&name=${enc(value.mode)}&recno=${enc(value.recno)}&PlateNumber=${enc(value.plateNumber)}&BeginTime=${enc(value.start)}&CancelTime=${enc(value.end)}&+OpenGate=${value.openGate}&MasterOfCar=${enc(value.owner)}`;
24812
+ }
24813
+ function buildPlateInsertEndpoint(value) {
24814
+ return `/cgi-bin/recordUpdater.cgi?action=insert&name=${enc(value.mode)}&PlateNumber=${enc(value.plateNumber)}&VehicleType=${enc(value.vehicleType)}&VehicleColor=${enc(value.vehicleColor)}&BeginTime=${enc(value.start)}&CancelTime=${enc(value.end)}&+OpenGate=${value.openGate}&MasterOfCar=${enc(value.owner)}`;
24815
+ }
24816
+ function buildPlateRemoveEndpoint(value) {
24817
+ return `/cgi-bin/recordUpdater.cgi?action=remove&recno=${enc(value.recno)}&name=${enc(value.mode)}`;
24818
+ }
24819
+
24780
24820
  // src/services/dahua.service.ts
24781
24821
  var cameraRegistry = /* @__PURE__ */ new Map();
24782
24822
  var _savedOnDetected;
@@ -24822,7 +24862,8 @@ async function useDahuaDigestWithRetry({
24822
24862
  retryDelayMs = 0
24823
24863
  }) {
24824
24864
  let lastError;
24825
- for (let attempt = 1; attempt <= retries; attempt++) {
24865
+ const effectiveRetries = Math.min(retries, DAHUA_AUTH_ATTEMPTS_BEFORE_ALERT);
24866
+ for (let attempt = 1; attempt <= effectiveRetries; attempt++) {
24826
24867
  try {
24827
24868
  const effectiveTimeout = streaming ? 0 : timeout;
24828
24869
  const response = await useDahuaDigest({
@@ -24849,9 +24890,9 @@ async function useDahuaDigestWithRetry({
24849
24890
  }
24850
24891
  const errorText = streaming ? `HTTP ${statusCode}` : getDahuaResponseText(response);
24851
24892
  const shouldRetry = isDahuaInvalidAuthority(errorText);
24852
- if (shouldRetry && attempt < retries) {
24893
+ if (shouldRetry && attempt < effectiveRetries) {
24853
24894
  loggerDahua.warn(
24854
- `[${host}] Dahua Invalid Authority. Retrying ${attempt}/${retries}`
24895
+ `[${host}] Dahua Invalid Authority. Retrying ${attempt}/${effectiveRetries}`
24855
24896
  );
24856
24897
  if (retryDelayMs > 0) {
24857
24898
  await sleep(retryDelayMs);
@@ -24863,9 +24904,9 @@ async function useDahuaDigestWithRetry({
24863
24904
  lastError = error;
24864
24905
  const message = error?.message || String(error);
24865
24906
  const shouldRetry = isDahuaInvalidAuthority(message);
24866
- if (shouldRetry && attempt < retries) {
24907
+ if (shouldRetry && attempt < effectiveRetries) {
24867
24908
  loggerDahua.warn(
24868
- `[${host}] Dahua Invalid Authority error. Retrying ${attempt}/${retries}`
24909
+ `[${host}] Dahua Invalid Authority error. Retrying ${attempt}/${effectiveRetries}`
24869
24910
  );
24870
24911
  if (retryDelayMs > 0) {
24871
24912
  await sleep(retryDelayMs);
@@ -25276,7 +25317,6 @@ function useDahuaService() {
25276
25317
  let authFailureCount = 0;
25277
25318
  let connectionFailureCount = 0;
25278
25319
  let alertedFailureCount = 0;
25279
- const MAX_AUTH_RETRIES = 10;
25280
25320
  while (!signal.aborted) {
25281
25321
  let bufferQueue = null;
25282
25322
  let response = null;
@@ -25291,23 +25331,26 @@ function useDahuaService() {
25291
25331
  streaming: true
25292
25332
  });
25293
25333
  const statusCode = getDahuaStatusCode(response);
25294
- if (statusCode === 401) {
25295
- loggerDahua.error(`[${camera?.siteName}-${camera?.direction}] 401 Unauthorized - Handshake or Wrong Credentials`);
25296
- console.log(`[${camera?.siteName}-${camera?.direction}] 401 Unauthorized - Handshake or Wrong Credentials`);
25297
- throw new Error("401 Unauthorized - Handshake or Wrong Credentials");
25298
- } else if (statusCode === 403) {
25334
+ const verdict = classifyDahuaStatus(statusCode);
25335
+ if (verdict === "challenge") {
25336
+ loggerDahua.warn(`[${camera?.siteName}-${camera?.direction}] 401 - digest challenge not completed; will re-authenticate after backoff.`);
25337
+ throw new Error("401 Unauthorized - digest challenge not completed");
25338
+ } else if (verdict === "rejected") {
25299
25339
  loggerDahua.error(
25300
- `[${camera?.siteName}-${camera?.direction}] 403 Forbidden. Account locked or invalid permissions.`
25340
+ `[${camera?.siteName}-${camera?.direction}] 403 Forbidden - credentials rejected by the device. Not retrying.`
25301
25341
  );
25302
25342
  if (onDetected) {
25303
25343
  onDetected({
25304
25344
  site: camera?.site,
25305
25345
  direction: camera?.direction,
25346
+ // Superseded #1803's own wording: cameraCredentialsMessage already
25347
+ // says the login was refused and where to correct it, and no longer
25348
+ // claims a ten-minute lockout.
25306
25349
  messagePermanent: cameraCredentialsMessage(camera)
25307
25350
  });
25308
25351
  }
25309
25352
  return;
25310
- } else if ([400, 500].includes(statusCode)) {
25353
+ } else if (verdict === "fatal") {
25311
25354
  loggerDahua.error(`[${camera?.siteName}-${camera?.direction}] Connection error: ${statusCode}`);
25312
25355
  if (onDetected) {
25313
25356
  onDetected({ site: camera?.site, messagePermanent: cameraNotAuthorisedMessage(camera) });
@@ -25375,9 +25418,10 @@ function useDahuaService() {
25375
25418
  break;
25376
25419
  }
25377
25420
  const errMsg = String(error?.message || error);
25378
- if (errMsg.includes("403") || errMsg.includes("Forbidden")) {
25421
+ const failure = classifyDahuaFailure(errMsg);
25422
+ if (failure === "rejected") {
25379
25423
  loggerDahua.error(
25380
- `[${camera?.siteName}-${camera?.direction}] 403 Forbidden thrown by Dahua client. Account locked or invalid credentials.`
25424
+ `[${camera?.siteName}-${camera?.direction}] 403 Forbidden thrown by Dahua client - credentials rejected. Not retrying.`
25381
25425
  );
25382
25426
  if (onDetected) {
25383
25427
  onDetected({
@@ -25388,16 +25432,15 @@ function useDahuaService() {
25388
25432
  }
25389
25433
  return;
25390
25434
  }
25391
- const isAuthError = errMsg.includes("401") || errMsg.includes("Unauthorized") || errMsg.includes("Invalid Authority");
25392
- if (isAuthError) {
25435
+ if (failure === "challenge") {
25393
25436
  authFailureCount++;
25394
25437
  isAuthFailure = true;
25395
25438
  loggerDahua.warn(
25396
- `[${camera?.siteName}-${camera?.direction}] Auth attempt ${authFailureCount}/${MAX_AUTH_RETRIES}`
25439
+ `[${camera?.siteName}-${camera?.direction}] Authentication attempt ${authFailureCount} failed; next attempt in ${dahuaAuthBackoffMs(authFailureCount) / 1e3}s`
25397
25440
  );
25398
- if (authFailureCount >= MAX_AUTH_RETRIES) {
25441
+ if (authFailureCount === DAHUA_AUTH_ATTEMPTS_BEFORE_ALERT) {
25399
25442
  loggerDahua.error(
25400
- `[${camera?.siteName}-${camera?.direction}] Max auth failures reached (${MAX_AUTH_RETRIES}). Wrong credentials.`
25443
+ `[${camera?.siteName}-${camera?.direction}] ${authFailureCount} consecutive authentication failures. Slowing down and alerting the operator.`
25401
25444
  );
25402
25445
  if (onDetected) {
25403
25446
  onDetected({
@@ -25406,7 +25449,6 @@ function useDahuaService() {
25406
25449
  messagePermanent: cameraCredentialsMessage(camera)
25407
25450
  });
25408
25451
  }
25409
- return;
25410
25452
  }
25411
25453
  } else {
25412
25454
  authFailureCount = 0;
@@ -25446,7 +25488,7 @@ function useDahuaService() {
25446
25488
  }
25447
25489
  }
25448
25490
  if (!signal.aborted) {
25449
- const waitMs = isAuthFailure ? 1e3 : 1e4;
25491
+ const waitMs = isAuthFailure ? dahuaAuthBackoffMs(authFailureCount) : 1e4;
25450
25492
  await new Promise((res) => setTimeout(res, waitMs));
25451
25493
  }
25452
25494
  }
@@ -25606,7 +25648,15 @@ function useDahuaService() {
25606
25648
  const openGateString = String(value.isOpenGate);
25607
25649
  _isOpenGate = openGateString ? openGateString : "true";
25608
25650
  }
25609
- const endpoint = `/cgi-bin/recordUpdater.cgi?action=update&name=${value.mode}&recno=${value.recno}&PlateNumber=${value.plateNumber}&BeginTime=${value.start}&CancelTime=${value.end}&+OpenGate=${_isOpenGate}&MasterOfCar=${value.owner}`;
25651
+ const endpoint = buildPlateUpdateEndpoint({
25652
+ mode: value.mode,
25653
+ recno: value.recno,
25654
+ plateNumber: value.plateNumber,
25655
+ start: value.start,
25656
+ end: value.end,
25657
+ owner: value.owner,
25658
+ openGate: _isOpenGate
25659
+ });
25610
25660
  try {
25611
25661
  const response = await useDahuaDigest({
25612
25662
  host: value.host,
@@ -25639,7 +25689,10 @@ function useDahuaService() {
25639
25689
  host: value.host,
25640
25690
  username: value.username,
25641
25691
  password: value.password,
25642
- endpoint: `/cgi-bin/recordUpdater.cgi?action=remove&recno=${value.recno}&name=${value.mode}`
25692
+ endpoint: buildPlateRemoveEndpoint({
25693
+ recno: value.recno,
25694
+ mode: value.mode
25695
+ })
25643
25696
  });
25644
25697
  return response;
25645
25698
  } catch (error2) {
@@ -25695,7 +25748,16 @@ function useDahuaService() {
25695
25748
  value.vehicleColor = String(value.vehicleColor ?? "").substring(0, 31) || "unknown";
25696
25749
  const _openGate = String(value.isOpenGate);
25697
25750
  const isOpenGateString = _openGate && _openGate !== "undefined" ? _openGate : "true";
25698
- const endpoint = `/cgi-bin/recordUpdater.cgi?action=insert&name=${value.mode}&PlateNumber=${value.plateNumber}&VehicleType=${value.vehicleType}&VehicleColor=${value.vehicleColor}&BeginTime=${value.start}&CancelTime=${value.end}&+OpenGate=${isOpenGateString}&MasterOfCar=${value.owner}`;
25751
+ const endpoint = buildPlateInsertEndpoint({
25752
+ mode: value.mode,
25753
+ plateNumber: value.plateNumber,
25754
+ vehicleType: value.vehicleType,
25755
+ vehicleColor: value.vehicleColor,
25756
+ start: value.start,
25757
+ end: value.end,
25758
+ owner: value.owner,
25759
+ openGate: isOpenGateString
25760
+ });
25699
25761
  try {
25700
25762
  const response = await useDahuaDigest({
25701
25763
  host: value.host,
@@ -35022,7 +35084,57 @@ function useCustomerSiteRepo() {
35022
35084
 
35023
35085
  // src/services/customer-site.service.ts
35024
35086
  var import_node_server_utils98 = require("@7365admin1/node-server-utils");
35087
+
35088
+ // src/utils/site-name.util.ts
35025
35089
  var import_fast_levenshtein = __toESM(require("fast-levenshtein"));
35090
+ var ROMAN = /^(?=[ivx])(x{0,3})(ix|iv|v?i{0,3})$/;
35091
+ var ROMAN_VALUE = { i: 1, v: 5, x: 10 };
35092
+ function normalizeSiteName(name) {
35093
+ return String(name ?? "").normalize("NFKC").toLowerCase().replace(/[‘’']/g, "").replace(/[^a-z0-9]+/g, " ").trim();
35094
+ }
35095
+ function romanToNumber(token) {
35096
+ let total = 0;
35097
+ for (let i = 0; i < token.length; i++) {
35098
+ const value = ROMAN_VALUE[token[i]];
35099
+ const next = ROMAN_VALUE[token[i + 1]];
35100
+ total += next && next > value ? -value : value;
35101
+ }
35102
+ return total;
35103
+ }
35104
+ function distinguishingTokens(normalized) {
35105
+ if (!normalized)
35106
+ return [];
35107
+ return normalized.split(" ").filter((t) => /^\d+$/.test(t) || ROMAN.test(t) || /^[a-z]$/.test(t)).map((t) => ROMAN.test(t) ? String(romanToNumber(t)) : t);
35108
+ }
35109
+ function matchSiteName(candidate, existing) {
35110
+ const a = normalizeSiteName(candidate);
35111
+ const b = normalizeSiteName(existing);
35112
+ if (!a || !b)
35113
+ return null;
35114
+ if (a === b)
35115
+ return "duplicate";
35116
+ const tokensA = distinguishingTokens(a).join(" ");
35117
+ const tokensB = distinguishingTokens(b).join(" ");
35118
+ if (tokensA !== tokensB)
35119
+ return null;
35120
+ return import_fast_levenshtein.default.get(a, b) <= 2 ? "near-duplicate" : null;
35121
+ }
35122
+ function findSiteNameClash(candidate, existingNames) {
35123
+ for (const name of existingNames) {
35124
+ const match = matchSiteName(candidate, name);
35125
+ if (match)
35126
+ return { name, match };
35127
+ }
35128
+ return null;
35129
+ }
35130
+ function siteNameClashMessage(existingName, match) {
35131
+ if (match === "duplicate") {
35132
+ return `A site called "${existingName}" already exists in this organisation. Give the new site a different name, or edit "${existingName}" instead.`;
35133
+ }
35134
+ return `This name is within a character or two of "${existingName}", which already exists in this organisation. If it is the same property, edit "${existingName}" instead. If it is a different one, include what tells them apart \u2014 the block, tower, phase, or number \u2014 and save again.`;
35135
+ }
35136
+
35137
+ // src/services/customer-site.service.ts
35026
35138
  function useCustomerSiteService() {
35027
35139
  const { add: _add, updateCustomerSiteById, getById: _getById } = useCustomerSiteRepo();
35028
35140
  const {
@@ -35044,20 +35156,18 @@ function useCustomerSiteService() {
35044
35156
  const exactMatches = await getSiteByExactName(value.name, value.siteOrg);
35045
35157
  if (exactMatches && exactMatches.length > 0) {
35046
35158
  throw new import_node_server_utils98.BadRequestError(
35047
- "Site with this name already exists in the organization."
35159
+ siteNameClashMessage(exactMatches[0].name, "duplicate")
35048
35160
  );
35049
35161
  }
35050
- const threshold = 2;
35051
35162
  const sites = await getSiteByName(value.name);
35052
- if (sites && sites.length > 0) {
35053
- const similar = sites.filter((doc) => {
35054
- return doc.orgId && doc.name && doc.orgId.toString() === value.siteOrg.toString() && import_fast_levenshtein.default.get(value.name.toLowerCase(), doc.name.toLowerCase()) <= threshold;
35055
- });
35056
- if (similar.length > 0) {
35057
- throw new import_node_server_utils98.BadRequestError(
35058
- "Failed to add site, site with closely similar name found. Please contact seven365 support."
35059
- );
35060
- }
35163
+ const candidates = (sites ?? []).filter(
35164
+ (doc) => doc.orgId && doc.name && doc.orgId.toString() === value.siteOrg.toString()
35165
+ ).map((doc) => doc.name);
35166
+ const clash = findSiteNameClash(value.name, candidates);
35167
+ if (clash) {
35168
+ throw new import_node_server_utils98.BadRequestError(
35169
+ siteNameClashMessage(clash.name, clash.match)
35170
+ );
35061
35171
  }
35062
35172
  const siteId = await createSite(
35063
35173
  {