@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.
- package/.changeset/dahua-spec-defects.md +20 -0
- package/.changeset/site-name-similarity-check.md +26 -0
- package/dist/index.js +147 -37
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +147 -37
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/dahua-protocol.util.test.mjs +217 -0
- package/test/site-name.util.test.mjs +146 -0
|
@@ -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(`^${
|
|
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
|
-
|
|
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 <
|
|
24893
|
+
if (shouldRetry && attempt < effectiveRetries) {
|
|
24853
24894
|
loggerDahua.warn(
|
|
24854
|
-
`[${host}] Dahua Invalid Authority. Retrying ${attempt}/${
|
|
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 <
|
|
24907
|
+
if (shouldRetry && attempt < effectiveRetries) {
|
|
24867
24908
|
loggerDahua.warn(
|
|
24868
|
-
`[${host}] Dahua Invalid Authority error. Retrying ${attempt}/${
|
|
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
|
-
|
|
25295
|
-
|
|
25296
|
-
|
|
25297
|
-
throw new Error("401 Unauthorized -
|
|
25298
|
-
} else if (
|
|
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
|
|
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 (
|
|
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
|
-
|
|
25421
|
+
const failure = classifyDahuaFailure(errMsg);
|
|
25422
|
+
if (failure === "rejected") {
|
|
25379
25423
|
loggerDahua.error(
|
|
25380
|
-
`[${camera?.siteName}-${camera?.direction}] 403 Forbidden thrown by Dahua client
|
|
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
|
-
|
|
25392
|
-
if (isAuthError) {
|
|
25435
|
+
if (failure === "challenge") {
|
|
25393
25436
|
authFailureCount++;
|
|
25394
25437
|
isAuthFailure = true;
|
|
25395
25438
|
loggerDahua.warn(
|
|
25396
|
-
`[${camera?.siteName}-${camera?.direction}]
|
|
25439
|
+
`[${camera?.siteName}-${camera?.direction}] Authentication attempt ${authFailureCount} failed; next attempt in ${dahuaAuthBackoffMs(authFailureCount) / 1e3}s`
|
|
25397
25440
|
);
|
|
25398
|
-
if (authFailureCount
|
|
25441
|
+
if (authFailureCount === DAHUA_AUTH_ATTEMPTS_BEFORE_ALERT) {
|
|
25399
25442
|
loggerDahua.error(
|
|
25400
|
-
`[${camera?.siteName}-${camera?.direction}]
|
|
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 ?
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
35159
|
+
siteNameClashMessage(exactMatches[0].name, "duplicate")
|
|
35048
35160
|
);
|
|
35049
35161
|
}
|
|
35050
|
-
const threshold = 2;
|
|
35051
35162
|
const sites = await getSiteByName(value.name);
|
|
35052
|
-
|
|
35053
|
-
|
|
35054
|
-
|
|
35055
|
-
|
|
35056
|
-
|
|
35057
|
-
|
|
35058
|
-
|
|
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
|
{
|