@thuzjq/meteorcloud-device-sdk-node 0.5.1
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/README.md +45 -0
- package/docs/NODE_INTEGRATION_GUIDE.zh-CN.md +414 -0
- package/index.d.ts +930 -0
- package/index.js +945 -0
- package/lib/artifacts.js +279 -0
- package/lib/camera.js +316 -0
- package/lib/config.js +257 -0
- package/lib/connect.js +718 -0
- package/lib/durable.js +85 -0
- package/lib/errors.js +70 -0
- package/lib/http.js +188 -0
- package/lib/jobs.js +54 -0
- package/lib/jose.js +146 -0
- package/lib/journal.js +116 -0
- package/lib/keystore.js +585 -0
- package/lib/resources.js +408 -0
- package/lib/tokens.js +311 -0
- package/lib/upload.js +188 -0
- package/package.json +44 -0
- package/tools/migrate-key-to-dpapi.js +386 -0
package/lib/artifacts.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const fsp = require('node:fs/promises');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const { MeteorCloudError, fail, isObject } = require('./errors');
|
|
9
|
+
|
|
10
|
+
const ROLES = Object.freeze(['manifest', 'ecsv', 'media', 'preview']);
|
|
11
|
+
const REQUIRED_ROLES = Object.freeze(['manifest', 'ecsv', 'media']);
|
|
12
|
+
const ROLE_SET = new Set(ROLES);
|
|
13
|
+
const MAX_ARTIFACT_SIZE = 50 * 1024 * 1024 * 1024;
|
|
14
|
+
const MAX_MEDIA_SIZE = 100 * 1024 * 1024;
|
|
15
|
+
/**
|
|
16
|
+
* The server's per-role ceilings (`MsCloudUploadSessionServiceImpl.validateRoleSize`).
|
|
17
|
+
* Mirrored so an oversize artifact is refused before the SDK spends a
|
|
18
|
+
* full-file SHA-256 on it and gets back an opaque
|
|
19
|
+
* `upload session request is invalid` 400 that names no role and no limit.
|
|
20
|
+
* A full-resolution PNG preview clears 20 MiB easily, so this is not exotic.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_ROLE_SIZE = Object.freeze({
|
|
23
|
+
manifest: 1024 * 1024,
|
|
24
|
+
ecsv: 2 * 1024 * 1024 * 1024,
|
|
25
|
+
preview: 20 * 1024 * 1024,
|
|
26
|
+
media: MAX_MEDIA_SIZE
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* `MsCloudUploadSessionFileReqVO.originalFilename`, mirrored verbatim:
|
|
30
|
+
* `@Size(max = 255)` plus `@Pattern("[^\\x00\\r\\n]+")`.
|
|
31
|
+
*
|
|
32
|
+
* It used to be `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`, a rule neither the server
|
|
33
|
+
* nor the C++ SDK has — `client_impl.hpp.inc` checks non-empty, `size() > 255`
|
|
34
|
+
* and `contains_crlf_or_nul`, and nothing else. The cost was not theoretical: a
|
|
35
|
+
* Chinese filename or one with a space — `流星 2026-01-01.mp4`, which is what a
|
|
36
|
+
* capture app writes by default — was refused locally by the Node SDK while the
|
|
37
|
+
* identical event package uploaded fine from the C++ one. The value is pure
|
|
38
|
+
* metadata: `path.basename()` produces it, it is sent in the authorize body, and
|
|
39
|
+
* nothing local is built from it (the PUT URL is derived from `sessionUid` and
|
|
40
|
+
* `role`, and the journal keys off that same target path). So there is nothing
|
|
41
|
+
* for a stricter local rule to protect, and plenty for it to break.
|
|
42
|
+
*
|
|
43
|
+
* One residual difference, and it lands on the server's side: `@Size` counts
|
|
44
|
+
* Java chars and `String.length` counts UTF-16 units, so these two agree
|
|
45
|
+
* exactly, while the C++ `size()` counts UTF-8 bytes and is therefore stricter
|
|
46
|
+
* for non-ASCII. A name Node accepts and C++ refuses is one the server accepts.
|
|
47
|
+
*/
|
|
48
|
+
const FILENAME_MAX = 255;
|
|
49
|
+
const FILENAME_RE = /^[^\0\r\n]+$/;
|
|
50
|
+
/**
|
|
51
|
+
* `MsCloudUploadSessionFileReqVO.contentType`: `@Size(max = 128)` and
|
|
52
|
+
* `@Pattern("[A-Za-z0-9][A-Za-z0-9!#$&^_.+\\-/;= ]*")`, anchored here because
|
|
53
|
+
* Jakarta's `@Pattern` matches the whole value.
|
|
54
|
+
*
|
|
55
|
+
* The local check used to reject only CR, LF and NUL, so `image/*` and
|
|
56
|
+
* `text/plain; charset="utf-8"` sailed past it and came back from the server as
|
|
57
|
+
* a bare 400 `upload session request is invalid` naming neither the field nor
|
|
58
|
+
* the role — after the full-file hashing pass. The C++ SDK has applied this
|
|
59
|
+
* exact pattern and this exact ceiling all along
|
|
60
|
+
* (`client_impl.hpp.inc:1786-1791`); this is the Node half catching up.
|
|
61
|
+
*/
|
|
62
|
+
const CONTENT_TYPE_MAX = 128;
|
|
63
|
+
const CONTENT_TYPE_RE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+\-/;= ]*$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Renders a limit the way the docs and the server write it.
|
|
67
|
+
*
|
|
68
|
+
* Only exact multiples become units. Every ceiling above is round by
|
|
69
|
+
* construction so it always renders as one, while a real file size almost never
|
|
70
|
+
* is — and a rounded "20.3 MiB" would be a number the caller cannot match
|
|
71
|
+
* against anything they were told.
|
|
72
|
+
*/
|
|
73
|
+
function humanBytes(bytes) {
|
|
74
|
+
const units = [
|
|
75
|
+
[1024 * 1024 * 1024, 'GiB'],
|
|
76
|
+
[1024 * 1024, 'MiB'],
|
|
77
|
+
[1024, 'KiB']
|
|
78
|
+
];
|
|
79
|
+
for (const [unit, suffix] of units) {
|
|
80
|
+
if (bytes >= unit && bytes % unit === 0) return `${bytes / unit} ${suffix}`;
|
|
81
|
+
}
|
|
82
|
+
return `${bytes} bytes`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function sha256Path(filePath, { start = 0, end } = {}) {
|
|
86
|
+
const digest = crypto.createHash('sha256');
|
|
87
|
+
try {
|
|
88
|
+
const options = end === undefined ? { start } : { start, end };
|
|
89
|
+
const stream = fs.createReadStream(filePath, options);
|
|
90
|
+
for await (const chunk of stream) digest.update(chunk);
|
|
91
|
+
return digest.digest('hex');
|
|
92
|
+
} catch (cause) {
|
|
93
|
+
throw new MeteorCloudError(`cannot hash artifact: ${filePath}`, { kind: 'io', cause });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Hashes and measures the local artifacts that will be authorized.
|
|
99
|
+
*
|
|
100
|
+
* The digests computed here are what the server records as authoritative, and
|
|
101
|
+
* `finalize` on the local plane re-derives them from the bytes it received. A
|
|
102
|
+
* file mutated between this call and the last chunk therefore fails at finalize
|
|
103
|
+
* with an object mismatch rather than being silently ingested.
|
|
104
|
+
*/
|
|
105
|
+
async function describeArtifacts(artifacts) {
|
|
106
|
+
if (!Array.isArray(artifacts) || ![3, 4].includes(artifacts.length)) {
|
|
107
|
+
fail('exactly three or four artifacts are required');
|
|
108
|
+
}
|
|
109
|
+
const result = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const artifact of artifacts) {
|
|
112
|
+
if (!isObject(artifact) || !ROLE_SET.has(artifact.role)) fail('unsupported artifact role');
|
|
113
|
+
if (seen.has(artifact.role)) fail(`duplicate artifact role: ${artifact.role}`);
|
|
114
|
+
seen.add(artifact.role);
|
|
115
|
+
if (typeof artifact.path !== 'string' || !artifact.path) fail(`artifact path is invalid: ${artifact.role}`);
|
|
116
|
+
if (
|
|
117
|
+
typeof artifact.contentType !== 'string' ||
|
|
118
|
+
artifact.contentType.length > CONTENT_TYPE_MAX ||
|
|
119
|
+
!CONTENT_TYPE_RE.test(artifact.contentType)
|
|
120
|
+
) {
|
|
121
|
+
fail(
|
|
122
|
+
`${artifact.role} artifact contentType is invalid. The server accepts at most ` +
|
|
123
|
+
`${CONTENT_TYPE_MAX} characters matching [A-Za-z0-9][A-Za-z0-9!#$&^_.+\\-/;= ]* ` +
|
|
124
|
+
'— so no quotes, commas or asterisks: write text/plain; charset=utf-8, ' +
|
|
125
|
+
'not text/plain; charset="utf-8", and name a concrete type rather than image/*'
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const originalFilename = path.basename(artifact.path);
|
|
129
|
+
if (originalFilename.length > FILENAME_MAX || !FILENAME_RE.test(originalFilename)) {
|
|
130
|
+
fail(
|
|
131
|
+
`${artifact.role} artifact filename is invalid: the server accepts 1 to ` +
|
|
132
|
+
`${FILENAME_MAX} characters with no NUL, CR or LF`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
let stat;
|
|
136
|
+
try {
|
|
137
|
+
stat = await fsp.lstat(artifact.path);
|
|
138
|
+
} catch (cause) {
|
|
139
|
+
throw new MeteorCloudError(`artifact does not exist: ${artifact.role}`, { kind: 'io', cause });
|
|
140
|
+
}
|
|
141
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail(`artifact must be a regular file: ${artifact.role}`);
|
|
142
|
+
if (stat.size < 1 || stat.size > MAX_ARTIFACT_SIZE) fail(`artifact size is invalid: ${artifact.role}`);
|
|
143
|
+
const roleCeiling = MAX_ROLE_SIZE[artifact.role] ?? MAX_ARTIFACT_SIZE;
|
|
144
|
+
if (stat.size > roleCeiling) {
|
|
145
|
+
// Every role phrases the ceiling in the units the guide and the server
|
|
146
|
+
// use. A raw byte count makes the reader do arithmetic to find out
|
|
147
|
+
// whether they hit the limit they were told about.
|
|
148
|
+
fail(
|
|
149
|
+
`${artifact.role} artifact is ${humanBytes(stat.size)}; ` +
|
|
150
|
+
`the server accepts at most ${humanBytes(roleCeiling)}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
result.push({
|
|
154
|
+
role: artifact.role,
|
|
155
|
+
originalFilename,
|
|
156
|
+
sizeBytes: stat.size,
|
|
157
|
+
sha256: await sha256Path(artifact.path),
|
|
158
|
+
contentType: artifact.contentType
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
for (const role of REQUIRED_ROLES) {
|
|
162
|
+
if (!seen.has(role)) fail('manifest, ecsv and media roles are required');
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The manifest is the contract between client and pipeline, so it is validated
|
|
169
|
+
* locally before a session is opened: `local_event_id` must equal the idempotency
|
|
170
|
+
* key, and the bytes on disk must equal the JSON being submitted. Without the
|
|
171
|
+
* second check a client could authorize one manifest and upload another.
|
|
172
|
+
*/
|
|
173
|
+
async function validateManifest(request) {
|
|
174
|
+
if (typeof request.manifestJson !== 'string') fail('manifestJson must be a string');
|
|
175
|
+
let manifest;
|
|
176
|
+
try {
|
|
177
|
+
manifest = JSON.parse(request.manifestJson);
|
|
178
|
+
} catch (cause) {
|
|
179
|
+
throw new MeteorCloudError('manifestJson is invalid JSON', { kind: 'validation', cause });
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
!isObject(manifest) ||
|
|
183
|
+
manifest.schema !== 'mlc.manifest/2' ||
|
|
184
|
+
manifest.local_event_id !== request.clientRequestKey
|
|
185
|
+
) {
|
|
186
|
+
fail('mlc.manifest/2 local_event_id must equal clientRequestKey');
|
|
187
|
+
}
|
|
188
|
+
const manifestArtifact = request.artifacts.find((item) => item.role === 'manifest');
|
|
189
|
+
if (!manifestArtifact) fail('a manifest artifact is required');
|
|
190
|
+
const submitted = Buffer.from(request.manifestJson, 'utf8');
|
|
191
|
+
// Compare sizes before reading. `readFile` on a path the caller mislabelled
|
|
192
|
+
// as the manifest — pointing at the media file, say — used to buffer the
|
|
193
|
+
// whole thing: straight RSS on a machine that is also capturing video, and
|
|
194
|
+
// past ~2 GiB a raw ERR_FS_FILE_TOO_LARGE rather than a MeteorCloudError.
|
|
195
|
+
// Wrapped: these are the only two unguarded fs calls on the public upload
|
|
196
|
+
// path, and they run *after* describeArtifacts has hashed every artifact —
|
|
197
|
+
// minutes, on a large event. Anything that removes, replaces or exclusively
|
|
198
|
+
// locks the manifest in that window (on Windows, the capture app reopening it
|
|
199
|
+
// with FILE_SHARE_NONE gives EBUSY) used to escape as a raw fs Error, so an
|
|
200
|
+
// integrator's `catch (e) { switch (e.kind) }` saw undefined. lib/journal.js
|
|
201
|
+
// had the same hole around mkdir and it was closed for the same reason:
|
|
202
|
+
// MeteorCloudError is promised to be the only error this SDK throws.
|
|
203
|
+
let onDisk;
|
|
204
|
+
try {
|
|
205
|
+
const onDiskSize = (await fsp.stat(manifestArtifact.path)).size;
|
|
206
|
+
if (onDiskSize !== submitted.length) {
|
|
207
|
+
fail('manifest file bytes must exactly equal manifestJson');
|
|
208
|
+
}
|
|
209
|
+
onDisk = await fsp.readFile(manifestArtifact.path);
|
|
210
|
+
} catch (cause) {
|
|
211
|
+
if (cause instanceof MeteorCloudError) throw cause;
|
|
212
|
+
throw new MeteorCloudError('cannot read the manifest artifact', { kind: 'io', cause });
|
|
213
|
+
}
|
|
214
|
+
if (onDisk.length !== submitted.length || !crypto.timingSafeEqual(onDisk, submitted)) {
|
|
215
|
+
fail('manifest file bytes must exactly equal manifestJson');
|
|
216
|
+
}
|
|
217
|
+
return manifest;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Cross-checks the digests the manifest declares against the artifacts actually
|
|
222
|
+
* being submitted.
|
|
223
|
+
*
|
|
224
|
+
* The server does this too (`validateManifestDigests`) — but it answers a
|
|
225
|
+
* mismatch with **401**, which reads as a credential failure for what is a
|
|
226
|
+
* manifest content error. The digests are already computed by the time we get
|
|
227
|
+
* here, so checking locally costs nothing and turns the most confusing error on
|
|
228
|
+
* the whole plane into a plain sentence. The usual way to hit it: switching on
|
|
229
|
+
* preview generation while the manifest writer still emits `"preview"` as `"-"`.
|
|
230
|
+
*/
|
|
231
|
+
function validateManifestDigests(manifest, files) {
|
|
232
|
+
const declared = isObject(manifest.files) ? manifest.files : {};
|
|
233
|
+
for (const role of ['ecsv', 'media', 'preview']) {
|
|
234
|
+
const file = files.find((item) => item.role === role);
|
|
235
|
+
const entry = declared[role];
|
|
236
|
+
const claimed = isObject(entry) ? String(entry.sha256 || '') : entry === undefined ? undefined : String(entry);
|
|
237
|
+
if (!file) {
|
|
238
|
+
// Not supplied: the manifest must say so, by omission or by "-".
|
|
239
|
+
if (claimed !== undefined && claimed !== '-' && claimed !== '') {
|
|
240
|
+
fail(`manifest declares a ${role} digest but no ${role} artifact was supplied`);
|
|
241
|
+
}
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (claimed === undefined || claimed === '-' || claimed === '') {
|
|
245
|
+
fail(`manifest omits the ${role} digest, but a ${role} artifact was supplied`);
|
|
246
|
+
}
|
|
247
|
+
if (claimed.toLowerCase() !== file.sha256) {
|
|
248
|
+
fail(`manifest ${role} sha256 does not match the file being uploaded`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The server's own normalisation (`trim().toLowerCase()`), mirrored so a client
|
|
255
|
+
* comparing its own contentType against the echoed plan agrees with the value
|
|
256
|
+
* the server actually stored.
|
|
257
|
+
*/
|
|
258
|
+
function normalizeContentType(value) {
|
|
259
|
+
return typeof value === 'string' ? value.trim().toLowerCase() : value;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
normalizeContentType,
|
|
264
|
+
validateManifestDigests,
|
|
265
|
+
FILENAME_MAX,
|
|
266
|
+
FILENAME_RE,
|
|
267
|
+
CONTENT_TYPE_MAX,
|
|
268
|
+
CONTENT_TYPE_RE,
|
|
269
|
+
MAX_ROLE_SIZE,
|
|
270
|
+
ROLES,
|
|
271
|
+
REQUIRED_ROLES,
|
|
272
|
+
ROLE_SET,
|
|
273
|
+
MAX_ARTIFACT_SIZE,
|
|
274
|
+
MAX_MEDIA_SIZE,
|
|
275
|
+
humanBytes,
|
|
276
|
+
sha256Path,
|
|
277
|
+
describeArtifacts,
|
|
278
|
+
validateManifest
|
|
279
|
+
};
|
package/lib/camera.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { fail, isObject } = require('./errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Machine-position declaration: local validation of the `camera` block.
|
|
7
|
+
*
|
|
8
|
+
* From v0.5.0 every upload names where it came from, and the host application
|
|
9
|
+
* no longer pre-registers anything. It passes either a `camera` block (its own
|
|
10
|
+
* stable key plus the station's coordinates) or a `cameraUid` it happens to
|
|
11
|
+
* already know; the server registers on first sight and returns the uids purely
|
|
12
|
+
* as information. The SDK holds no camera list, no station list and no
|
|
13
|
+
* key-to-uid map — see contract §1.2, §7.9 and plan §0.2.
|
|
14
|
+
*
|
|
15
|
+
* This module exists because of one sentence in contract §7.6: a missing or
|
|
16
|
+
* malformed declaration is a *caller argument* error and must be caught
|
|
17
|
+
* "before reading and hashing large files". `uploadEvent` otherwise spends a
|
|
18
|
+
* full SHA-256 pass over a multi-gigabyte capture — minutes of disk on an
|
|
19
|
+
* unattended station — before the server answers 400 CAMERA_REQUIRED. So
|
|
20
|
+
* everything here is synchronous, allocation-only, and touches no file, no
|
|
21
|
+
* socket and no clock: it is safe to call as the very first statement of
|
|
22
|
+
* `uploadEvent`, ahead of `_prepareFiles`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Server-minted camera identifier, the same literal `index.js` uses. Duplicated
|
|
27
|
+
* rather than imported because this module must stay loadable without pulling
|
|
28
|
+
* in the client (and its keystore/transport); the integration point owns the
|
|
29
|
+
* dedupe.
|
|
30
|
+
*/
|
|
31
|
+
const CAMERA_RE = /^cam_[0-9a-f]{32}$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The host application's own stable key for one channel (contract §7.9: "1-64
|
|
35
|
+
* safe ASCII ... no PII").
|
|
36
|
+
*
|
|
37
|
+
* Deliberately the same character class as `EVENT_RE` in `index.js`, one
|
|
38
|
+
* length-cap shorter, because it lands in the same kind of place: a database
|
|
39
|
+
* unique index, log lines, and eventually a URL path segment. Restricting it
|
|
40
|
+
* this way is what enforces the prose rules for free — no control characters
|
|
41
|
+
* (charset), no leading or trailing whitespace (whitespace is not in the
|
|
42
|
+
* charset at all), and no `@`, so the single most likely PII smuggle, an email
|
|
43
|
+
* address, cannot even be spelled.
|
|
44
|
+
*
|
|
45
|
+
* A channel name works as a key when it is ASCII; a non-ASCII one ("北向广角")
|
|
46
|
+
* does not, and the caller should keep the display string in `camera.name` and
|
|
47
|
+
* generate the key with `newCameraKey()`. Changing the key means a new camera,
|
|
48
|
+
* so it must be something the host persists, never something it re-derives.
|
|
49
|
+
*/
|
|
50
|
+
const CAMERA_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Shape gate for an IANA zone id, applied *before* the runtime check below.
|
|
54
|
+
*
|
|
55
|
+
* `Intl` also accepts fixed offsets — `+08:00`, `-0500` — and those are exactly
|
|
56
|
+
* what must not reach the server: an offset has no DST rule, so every local
|
|
57
|
+
* timestamp this station reports across a transition is silently an hour wrong,
|
|
58
|
+
* and a meteor solved from multi-station timing has no tolerance for that. Zone
|
|
59
|
+
* ids start with a letter and are spelled from `[A-Za-z0-9_+/-]`, which keeps
|
|
60
|
+
* `Etc/GMT+8`, `W-SU` and `America/Argentina/Buenos_Aires` while rejecting
|
|
61
|
+
* anything beginning with a sign. 64 is the server column width
|
|
62
|
+
* (`MsCloudStationSaveReqVO.timezone`).
|
|
63
|
+
*/
|
|
64
|
+
const TIMEZONE_RE = /^[A-Za-z][A-Za-z0-9_+/-]{0,63}$/;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* C0 plus DEL. Names are stored and later rendered in the web console, and a
|
|
68
|
+
* stray CR or NUL in a display name is a log-forging and rendering hazard that
|
|
69
|
+
* costs nothing to refuse here. Written as a scan rather than a regex literal
|
|
70
|
+
* so the control points stay legible as code points instead of escapes.
|
|
71
|
+
*/
|
|
72
|
+
function hasControlChar(value) {
|
|
73
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
74
|
+
const code = value.charCodeAt(i);
|
|
75
|
+
if (code < 0x20 || code === 0x7f) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `displayName` on both `MsCloudStationSaveReqVO` and `MsCloudCameraSaveReqVO`. */
|
|
81
|
+
const MAX_NAME_LENGTH = 128;
|
|
82
|
+
|
|
83
|
+
/** Contract §7.9: "1-64 safe ASCII". Kept beside `CAMERA_KEY_RE`, which encodes it. */
|
|
84
|
+
const MAX_CAMERA_KEY_LENGTH = 64;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Mirrors the admin-side bean validation the registry reuses
|
|
88
|
+
* (`MsCloudStationSaveReqVO`): latitude `@DecimalMin/-90 @DecimalMax/90`,
|
|
89
|
+
* longitude `-180/180`, elevation `@Min/-500 @Max/10000` on an `Integer`.
|
|
90
|
+
* Duplicated locally so the caller learns the bound at the call site instead of
|
|
91
|
+
* from a round trip; the server stays the authority.
|
|
92
|
+
*/
|
|
93
|
+
const STATION_RANGES = Object.freeze({
|
|
94
|
+
latitude: Object.freeze([-90, 90]),
|
|
95
|
+
longitude: Object.freeze([-180, 180]),
|
|
96
|
+
elevationM: Object.freeze([-500, 10000])
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const CAMERA_FIELDS = Object.freeze(['key', 'name', 'station']);
|
|
100
|
+
const STATION_FIELDS = Object.freeze(['latitude', 'longitude', 'elevationM', 'timezone', 'name']);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Every failure in this module is the same class of failure: the caller passed
|
|
104
|
+
* something wrong, retrying it will fail identically, and no amount of waiting
|
|
105
|
+
* helps. `kind` and `retryable` are stated rather than left to `fail`'s
|
|
106
|
+
* defaults so that a later change to those defaults cannot quietly turn a
|
|
107
|
+
* caller bug into something an upload loop retries forever.
|
|
108
|
+
*
|
|
109
|
+
* The message names the field and the rule it broke and **never the value**:
|
|
110
|
+
* contract §10.1 keeps payload detail out of anything a host routinely logs,
|
|
111
|
+
* and coordinates are the location of somebody's home.
|
|
112
|
+
*/
|
|
113
|
+
function invalid(message) {
|
|
114
|
+
fail(message, { kind: 'validation', retryable: false });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Unknown keys are refused so a near miss cannot pass as an omission — the
|
|
119
|
+
* motivating case is `elevation` for `elevationM`, which would otherwise be
|
|
120
|
+
* dropped on the floor and re-read as "no elevation given", failing as a
|
|
121
|
+
* *missing* required field or, worse, registering a station at the wrong
|
|
122
|
+
* altitude if the field were ever optional.
|
|
123
|
+
*
|
|
124
|
+
* The offending key names are echoed only when they look like identifiers.
|
|
125
|
+
* A key is caller-authored text, and while it is structure rather than payload,
|
|
126
|
+
* there is no reason for this module to be the one that copies an arbitrary
|
|
127
|
+
* string into an error a host will log.
|
|
128
|
+
*/
|
|
129
|
+
function assertKnownFields(value, allowed, path) {
|
|
130
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
131
|
+
if (unknown.length === 0) return;
|
|
132
|
+
const named = unknown.map((key) => (/^[A-Za-z0-9_]{1,40}$/.test(key) ? key : '<unprintable>'));
|
|
133
|
+
invalid(`${path} has unknown field(s): ${named.join(', ')}; allowed: ${allowed.join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `undefined` means absent, `null` does not.
|
|
138
|
+
*
|
|
139
|
+
* `{ name: undefined }` is how JavaScript spells "I have no value for this",
|
|
140
|
+
* and the existing wire code already treats it that way (`_authorizePrepared`
|
|
141
|
+
* omits `cameraUid` on `!== undefined`). `null` is a decision, it survives
|
|
142
|
+
* `JSON.stringify`, and the server's `@NotBlank` would answer it with an opaque
|
|
143
|
+
* 400 — so it is caught here with a message that says which field.
|
|
144
|
+
*/
|
|
145
|
+
function isAbsent(value) {
|
|
146
|
+
return value === undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeName(value, path) {
|
|
150
|
+
if (isAbsent(value)) return undefined;
|
|
151
|
+
if (typeof value !== 'string') invalid(`${path} must be a string when present`);
|
|
152
|
+
// Blank is refused rather than treated as absent: silently rewriting a
|
|
153
|
+
// caller's value is the behaviour this module exists to avoid, and the
|
|
154
|
+
// server's @NotBlank would reject it anyway once it reached the wire.
|
|
155
|
+
if (value.trim() === '') invalid(`${path} must not be blank; omit it instead`);
|
|
156
|
+
if (value.length > MAX_NAME_LENGTH) invalid(`${path} must be at most ${MAX_NAME_LENGTH} characters`);
|
|
157
|
+
if (hasControlChar(value)) invalid(`${path} must not contain control characters`);
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Coordinates are read as numbers only. A numeric *string* is refused rather
|
|
163
|
+
* than coerced because `'36.07'` and `'36,07'` are both strings and only one of
|
|
164
|
+
* them is a latitude — `Number()` turns the other into `NaN` and, before the
|
|
165
|
+
* finiteness check, into a station somewhere undefined. Making the caller hand
|
|
166
|
+
* over a number puts that parse in their code, where the locale is known.
|
|
167
|
+
*/
|
|
168
|
+
function normalizeNumber(value, path, [min, max], { integer = false } = {}) {
|
|
169
|
+
if (isAbsent(value)) invalid(`${path} is required`);
|
|
170
|
+
if (typeof value !== 'number') {
|
|
171
|
+
invalid(`${path} must be a number; a numeric string or null is not accepted`);
|
|
172
|
+
}
|
|
173
|
+
if (!Number.isFinite(value)) invalid(`${path} must be a finite number; NaN and Infinity are not positions`);
|
|
174
|
+
if (integer && !Number.isInteger(value)) invalid(`${path} must be a whole number of metres`);
|
|
175
|
+
if (value < min || value > max) invalid(`${path} must be within [${min}, ${max}]`);
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The zone is validated for real, not merely shape-checked: `Intl` carries the
|
|
181
|
+
* ICU zone database this runtime already ships, so a retired or misspelled id
|
|
182
|
+
* ("Asia/Peking", "Mars/Olympus") throws a `RangeError` here instead of being
|
|
183
|
+
* stored and only noticed when a solved trajectory's local time makes no sense.
|
|
184
|
+
* Zero cost, zero dependencies, and it is the same database the pipeline will
|
|
185
|
+
* later use to interpret the station's timestamps.
|
|
186
|
+
*
|
|
187
|
+
* The accepted string is passed through byte-for-byte, not canonicalised. Zone
|
|
188
|
+
* identity belongs to the server (station identity is by coordinates anyway,
|
|
189
|
+
* contract §7.9), and rewriting a caller's value would be the silent
|
|
190
|
+
* normalisation this module refuses everywhere else.
|
|
191
|
+
*/
|
|
192
|
+
function normalizeTimezone(value, path) {
|
|
193
|
+
if (isAbsent(value)) invalid(`${path} is required`);
|
|
194
|
+
if (typeof value !== 'string') invalid(`${path} must be a string`);
|
|
195
|
+
if (!TIMEZONE_RE.test(value)) {
|
|
196
|
+
invalid(`${path} must be an IANA zone id such as Asia/Shanghai; a UTC offset is not accepted`);
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
new Intl.DateTimeFormat(undefined, { timeZone: value });
|
|
200
|
+
} catch {
|
|
201
|
+
invalid(`${path} is not a zone this runtime's IANA database knows`);
|
|
202
|
+
}
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function normalizeStation(input, path) {
|
|
207
|
+
if (isAbsent(input)) invalid(`${path} is required: a camera declares the position it observes from`);
|
|
208
|
+
if (!isObject(input)) invalid(`${path} must be an object`);
|
|
209
|
+
assertKnownFields(input, STATION_FIELDS, path);
|
|
210
|
+
|
|
211
|
+
// Built field by field rather than by spreading the caller's object, so the
|
|
212
|
+
// wire body can only ever contain what was validated above.
|
|
213
|
+
const station = {
|
|
214
|
+
latitude: normalizeNumber(input.latitude, `${path}.latitude`, STATION_RANGES.latitude),
|
|
215
|
+
longitude: normalizeNumber(input.longitude, `${path}.longitude`, STATION_RANGES.longitude),
|
|
216
|
+
elevationM: normalizeNumber(input.elevationM, `${path}.elevationM`, STATION_RANGES.elevationM, {
|
|
217
|
+
integer: true
|
|
218
|
+
}),
|
|
219
|
+
...(input.timezone == null || input.timezone === '' ? {} : { timezone: normalizeTimezone(input.timezone, `${path}.timezone`) })
|
|
220
|
+
};
|
|
221
|
+
// Adopted only when the station is first created (contract §7.9), so it is
|
|
222
|
+
// omitted rather than sent empty: an absent name and a blank one must not
|
|
223
|
+
// look alike to a server that will fall back to a coordinate summary.
|
|
224
|
+
const name = normalizeName(input.name, `${path}.name`);
|
|
225
|
+
if (name !== undefined) station.name = name;
|
|
226
|
+
return station;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Validates one `camera` block and returns the exact object to put on the wire.
|
|
231
|
+
*
|
|
232
|
+
* Exported on its own because `ensureCamera()` — the optional pre-registration
|
|
233
|
+
* endpoint — takes the identical block and performs the identical upsert
|
|
234
|
+
* (contract §7.9), and the two must not drift into two dialects of the same
|
|
235
|
+
* declaration.
|
|
236
|
+
*/
|
|
237
|
+
function normalizeCameraBlock(input, path = 'camera') {
|
|
238
|
+
if (!isObject(input)) invalid(`${path} must be an object`);
|
|
239
|
+
assertKnownFields(input, CAMERA_FIELDS, path);
|
|
240
|
+
|
|
241
|
+
if (isAbsent(input.key)) invalid(`${path}.key is required: the host's own stable key for this channel`);
|
|
242
|
+
if (typeof input.key !== 'string') invalid(`${path}.key must be a string`);
|
|
243
|
+
if (!CAMERA_KEY_RE.test(input.key)) {
|
|
244
|
+
invalid(
|
|
245
|
+
`${path}.key must be 1-${MAX_CAMERA_KEY_LENGTH} characters of A-Z a-z 0-9 . _ : - ` +
|
|
246
|
+
'starting with a letter or digit; use newCameraKey() for a generated one'
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const camera = { key: input.key, station: normalizeStation(input.station, `${path}.station`) };
|
|
251
|
+
const name = normalizeName(input.name, `${path}.name`);
|
|
252
|
+
if (name !== undefined) camera.name = name;
|
|
253
|
+
return camera;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Turns whatever machine position an upload request declared into the fragment
|
|
258
|
+
* to merge into the `upload-sessions/authorize` body.
|
|
259
|
+
*
|
|
260
|
+
* Accepts the whole request object and reads only `camera` and `cameraUid`:
|
|
261
|
+
* every other key (`clientRequestKey`, `manifestJson`, `artifacts`,
|
|
262
|
+
* `journalPath`, `onProgress`, …) belongs to the caller of `uploadEvent` and is
|
|
263
|
+
* none of this function's business. Unknown-field strictness applies *inside*
|
|
264
|
+
* the declaration, where a typo is silent, not at the top level, where it is
|
|
265
|
+
* not.
|
|
266
|
+
*
|
|
267
|
+
* Exactly one of the two is required; giving both is legal and both are passed
|
|
268
|
+
* through. The server resolves the pair — `cameraUid` is authoritative and it
|
|
269
|
+
* checks the registered `client_key` against `camera.key`, answering 400 on a
|
|
270
|
+
* mismatch (contract §7.9). Cross-checking them here is impossible without the
|
|
271
|
+
* camera list the SDK deliberately does not hold, and guessing would be worse
|
|
272
|
+
* than asking.
|
|
273
|
+
*
|
|
274
|
+
* @param {object} request an upload request, or any object carrying the two fields
|
|
275
|
+
* @returns {{cameraUid?: string, camera?: object}} a fresh object, safe to
|
|
276
|
+
* `Object.assign` into the authorize payload
|
|
277
|
+
* @throws {MeteorCloudError} kind `validation`, never retryable
|
|
278
|
+
*/
|
|
279
|
+
function normalizeCameraSelector(request = {}) {
|
|
280
|
+
if (!isObject(request)) invalid('upload request must be an object');
|
|
281
|
+
|
|
282
|
+
const hasUid = !isAbsent(request.cameraUid);
|
|
283
|
+
const hasBlock = !isAbsent(request.camera);
|
|
284
|
+
if (!hasUid && !hasBlock) {
|
|
285
|
+
// The local twin of the server's 400 CAMERA_REQUIRED (1030003035). Phrased
|
|
286
|
+
// as the fix rather than the symptom, because "camera is required" tells a
|
|
287
|
+
// host application nothing about where the value is supposed to come from,
|
|
288
|
+
// and there is no console page to copy it from any more.
|
|
289
|
+
invalid(
|
|
290
|
+
'an upload must declare where it came from: pass camera ' +
|
|
291
|
+
'{ key, station: { latitude, longitude, elevationM, timezone } }, ' +
|
|
292
|
+
'or a cameraUid you already know'
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const payload = {};
|
|
297
|
+
if (hasUid) {
|
|
298
|
+
if (typeof request.cameraUid !== 'string' || !CAMERA_RE.test(request.cameraUid)) {
|
|
299
|
+
invalid('cameraUid is not canonical: expected cam_ followed by 32 lowercase hex characters');
|
|
300
|
+
}
|
|
301
|
+
payload.cameraUid = request.cameraUid;
|
|
302
|
+
}
|
|
303
|
+
if (hasBlock) payload.camera = normalizeCameraBlock(request.camera);
|
|
304
|
+
return payload;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = {
|
|
308
|
+
normalizeCameraSelector,
|
|
309
|
+
normalizeCameraBlock,
|
|
310
|
+
CAMERA_RE,
|
|
311
|
+
CAMERA_KEY_RE,
|
|
312
|
+
TIMEZONE_RE,
|
|
313
|
+
STATION_RANGES,
|
|
314
|
+
MAX_NAME_LENGTH,
|
|
315
|
+
MAX_CAMERA_KEY_LENGTH
|
|
316
|
+
};
|