teleportxr 1.0.88 → 1.0.90
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/client/avatar_service.js +164 -0
- package/client/avatar_validator.js +332 -0
- package/client/client.js +4 -0
- package/client/client_manager.js +4 -0
- package/package.json +4 -1
- package/protocol/avatars.js +162 -0
- package/signaling.js +28 -1
- package/test/test_avatar_service.js +214 -0
- package/test/test_avatar_validator.js +330 -0
- package/test/test_avatars.js +140 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Per-client server-side state for avatar negotiation. Phase 3 of the
|
|
3
|
+
// implementation in plans/avatars_implementation.md: hand an offered
|
|
4
|
+
// URL to an IAvatarValidator and surface its verdict. With no
|
|
5
|
+
// validator wired the service falls back to the Phase-2 behaviour
|
|
6
|
+
// (always reply using_default), so existing deployments keep working.
|
|
7
|
+
//
|
|
8
|
+
// One AvatarService is owned by each Client; messages are dispatched in
|
|
9
|
+
// from the signaling layer.
|
|
10
|
+
|
|
11
|
+
const avatars = require('../protocol/avatars.js');
|
|
12
|
+
|
|
13
|
+
// Threshold above which a 'pending' frame is sent so the client can
|
|
14
|
+
// show progress (plan §4.1 / §9). Picked to be just under one second
|
|
15
|
+
// so the UX never feels stuck.
|
|
16
|
+
const PENDING_DELAY_MS = 750;
|
|
17
|
+
|
|
18
|
+
function envelope(type, content) {
|
|
19
|
+
return JSON.stringify({ 'teleport-signal-type': type, content });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class AvatarService {
|
|
23
|
+
constructor(clientID, sigSend, opts) {
|
|
24
|
+
this.clientID = clientID;
|
|
25
|
+
this.sigSend = sigSend;
|
|
26
|
+
this.currentPolicy = null;
|
|
27
|
+
this.lastOffer = null;
|
|
28
|
+
this.lastResult = null;
|
|
29
|
+
// Optional IAvatarValidator. When null the service keeps its
|
|
30
|
+
// Phase-2 behaviour: any offer is answered with using_default.
|
|
31
|
+
this.validator = (opts && opts.validator) || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Send (or re-send) the policy to the owning client. The client is
|
|
35
|
+
// expected to reply with an avatar-offer.
|
|
36
|
+
sendPolicy(policy) {
|
|
37
|
+
if (!policy)
|
|
38
|
+
return;
|
|
39
|
+
this.currentPolicy = policy;
|
|
40
|
+
const content = policy && typeof policy.toJSON === 'function'
|
|
41
|
+
? policy.toJSON()
|
|
42
|
+
: avatars.parseAvatarPolicy(policy).toJSON();
|
|
43
|
+
console.log('avatar-policy → client ' + this.clientID + ' policy_id=' + content.policy_id);
|
|
44
|
+
this.sigSend(envelope(avatars.TELEPORT_SIGNAL_TYPE_AVATAR_POLICY, content));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Handle an incoming avatar-offer. With no validator wired the
|
|
48
|
+
// service replies using_default exactly as Phase 2 did; with a
|
|
49
|
+
// validator the offered URL is fetched, hashed and measured, and
|
|
50
|
+
// the verdict is reported back.
|
|
51
|
+
async handleOffer(offerJson) {
|
|
52
|
+
const offer = avatars.parseAvatarOffer(offerJson);
|
|
53
|
+
this.lastOffer = offer;
|
|
54
|
+
console.log('avatar-offer ← client ' + this.clientID +
|
|
55
|
+
' policy_id=' + offer.policy_id +
|
|
56
|
+
' have_avatar=' + offer.have_avatar);
|
|
57
|
+
|
|
58
|
+
if (!this.currentPolicy ||
|
|
59
|
+
BigInt(offer.policy_id || 0n) !== BigInt(this.currentPolicy.policy_id))
|
|
60
|
+
{
|
|
61
|
+
this._reply({
|
|
62
|
+
policy_id: offer.policy_id || 0n,
|
|
63
|
+
status: 'rejected',
|
|
64
|
+
node_uid: 0n,
|
|
65
|
+
using_default: false,
|
|
66
|
+
delivery: 'import',
|
|
67
|
+
reasons: ['policy_unknown'],
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Without a validator, or without an offered URL, fall straight
|
|
73
|
+
// back to the default avatar (this is also the Phase-2 path).
|
|
74
|
+
if (!this.validator || !offer.have_avatar || !offer.url) {
|
|
75
|
+
this._reply({
|
|
76
|
+
policy_id: offer.policy_id,
|
|
77
|
+
status: 'using_default',
|
|
78
|
+
node_uid: 0n,
|
|
79
|
+
using_default: true,
|
|
80
|
+
delivery: 'import',
|
|
81
|
+
reasons: [],
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Long-running validation gets a 'pending' status so the client
|
|
87
|
+
// can show progress instead of appearing to hang (plan §4.1).
|
|
88
|
+
let pendingSent = false;
|
|
89
|
+
const pendingTimer = setTimeout(() => {
|
|
90
|
+
pendingSent = true;
|
|
91
|
+
this._reply({
|
|
92
|
+
policy_id: offer.policy_id,
|
|
93
|
+
status: 'pending',
|
|
94
|
+
node_uid: 0n,
|
|
95
|
+
using_default: false,
|
|
96
|
+
delivery: 'import',
|
|
97
|
+
reasons: [],
|
|
98
|
+
}, /*record*/ false);
|
|
99
|
+
}, PENDING_DELAY_MS);
|
|
100
|
+
|
|
101
|
+
let result;
|
|
102
|
+
try {
|
|
103
|
+
result = await this.validator.validate(offer, this.currentPolicy.requirements || {});
|
|
104
|
+
} catch (err) {
|
|
105
|
+
result = { ok: false, reasons: ['validator_error'], bytes: 0, contentHash: '', format: '' };
|
|
106
|
+
}
|
|
107
|
+
clearTimeout(pendingTimer);
|
|
108
|
+
|
|
109
|
+
if (result.ok) {
|
|
110
|
+
this._reply({
|
|
111
|
+
policy_id: offer.policy_id,
|
|
112
|
+
status: 'accepted',
|
|
113
|
+
node_uid: 0n,
|
|
114
|
+
using_default: false,
|
|
115
|
+
delivery: 'import',
|
|
116
|
+
reasons: [],
|
|
117
|
+
});
|
|
118
|
+
} else if (this.currentPolicy.default_available) {
|
|
119
|
+
this._reply({
|
|
120
|
+
policy_id: offer.policy_id,
|
|
121
|
+
status: 'using_default',
|
|
122
|
+
node_uid: 0n,
|
|
123
|
+
using_default: true,
|
|
124
|
+
delivery: 'import',
|
|
125
|
+
reasons: result.reasons || [],
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
this._reply({
|
|
129
|
+
policy_id: offer.policy_id,
|
|
130
|
+
status: 'rejected',
|
|
131
|
+
node_uid: 0n,
|
|
132
|
+
using_default: false,
|
|
133
|
+
delivery: 'import',
|
|
134
|
+
reasons: result.reasons || ['validation_failed'],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
void pendingSent;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Handle a client-initiated revoke (rare in Phase 2; provided for
|
|
141
|
+
// symmetry with later phases).
|
|
142
|
+
handleRevoke(revokeJson) {
|
|
143
|
+
const policy_id = revokeJson && revokeJson.policy_id != null
|
|
144
|
+
? BigInt(revokeJson.policy_id) : 0n;
|
|
145
|
+
console.log('avatar-revoke ← client ' + this.clientID + ' policy_id=' + policy_id);
|
|
146
|
+
// In Phase 2 a revoke from the client just drops cached state; the
|
|
147
|
+
// server keeps the same policy in force and a new offer is expected
|
|
148
|
+
// next.
|
|
149
|
+
this.lastOffer = null;
|
|
150
|
+
this.lastResult = null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_reply(result, record = true) {
|
|
154
|
+
const content = avatars.encodeAvatarResult(result);
|
|
155
|
+
if (record) this.lastResult = content;
|
|
156
|
+
console.log('avatar-result → client ' + this.clientID +
|
|
157
|
+
' status=' + content.status +
|
|
158
|
+
' delivery=' + content.delivery +
|
|
159
|
+
(content.reasons.length ? ' reasons=' + JSON.stringify(content.reasons) : ''));
|
|
160
|
+
this.sigSend(envelope(avatars.TELEPORT_SIGNAL_TYPE_AVATAR_RESULT, content));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { AvatarService };
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Phase 3 of the avatar implementation plan: server-side validation of
|
|
3
|
+
// avatar URLs supplied in avatar-offer. Goal is real download, real
|
|
4
|
+
// measurement, real rejection reasons. Bytes never leave this process
|
|
5
|
+
// — Phase 3 still has no peer visibility.
|
|
6
|
+
//
|
|
7
|
+
// IAvatarValidator is the pluggable interface; DefaultAvatarValidator is
|
|
8
|
+
// a node-only implementation that uses node:https for full control over
|
|
9
|
+
// the transport (so SSRF and byte/time limits can be enforced before any
|
|
10
|
+
// peer protocol code sees the bytes). Tests substitute a fake fetcher
|
|
11
|
+
// via the constructor so they don't have to hit the network.
|
|
12
|
+
|
|
13
|
+
const crypto = require('node:crypto');
|
|
14
|
+
const dns = require('node:dns');
|
|
15
|
+
const https = require('node:https');
|
|
16
|
+
const http = require('node:http');
|
|
17
|
+
const net = require('node:net');
|
|
18
|
+
const { URL } = require('node:url');
|
|
19
|
+
|
|
20
|
+
// SSRF blocklist ------------------------------------------------------
|
|
21
|
+
// Mirrors plans/avatars_plan.md §7 (SSRF row) and §5 V3. Refuses
|
|
22
|
+
// loopback, link-local, private, broadcast, metadata-service and the
|
|
23
|
+
// "this network" range. IPv6: refuses loopback, link-local, ULA, and
|
|
24
|
+
// IPv4-mapped addresses (which would re-enter the v4 blocklist).
|
|
25
|
+
function isBlockedIp(ip)
|
|
26
|
+
{
|
|
27
|
+
if (typeof ip !== 'string' || !ip.length)
|
|
28
|
+
return true;
|
|
29
|
+
const family = net.isIP(ip);
|
|
30
|
+
if (family === 4)
|
|
31
|
+
{
|
|
32
|
+
const o = ip.split('.').map(Number);
|
|
33
|
+
if (o.length !== 4 || o.some(b => !Number.isFinite(b) || b < 0 || b > 255))
|
|
34
|
+
return true;
|
|
35
|
+
if (o[0] === 0) return true; // "this" net
|
|
36
|
+
if (o[0] === 10) return true; // RFC1918
|
|
37
|
+
if (o[0] === 127) return true; // loopback
|
|
38
|
+
if (o[0] === 169 && o[1] === 254) return true; // link-local + AWS metadata
|
|
39
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; // RFC1918
|
|
40
|
+
if (o[0] === 192 && o[1] === 168) return true; // RFC1918
|
|
41
|
+
if (o[0] === 192 && o[1] === 0 && o[2] === 0) return true; // IETF protocol
|
|
42
|
+
if (o[0] === 198 && (o[1] === 18 || o[1] === 19)) return true; // benchmarking
|
|
43
|
+
if (o[0] >= 224) return true; // multicast + reserved
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (family === 6)
|
|
47
|
+
{
|
|
48
|
+
const lo = ip.toLowerCase();
|
|
49
|
+
if (lo === '::1' || lo === '::') return true;
|
|
50
|
+
if (lo.startsWith('fe80:') || lo.startsWith('fe80::')) return true; // link-local
|
|
51
|
+
if (lo.startsWith('fc') || lo.startsWith('fd')) return true; // ULA fc00::/7
|
|
52
|
+
if (lo.startsWith('ff')) return true; // multicast
|
|
53
|
+
if (lo.startsWith('::ffff:')) return true; // v4-mapped
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Resolve a hostname to a single IP and check it against the blocklist.
|
|
60
|
+
// Returns the resolved address on success; throws with a code that maps
|
|
61
|
+
// to one of the avatar-result reason codes on failure.
|
|
62
|
+
function resolveAndCheck(hostname)
|
|
63
|
+
{
|
|
64
|
+
return new Promise((resolve, reject) =>
|
|
65
|
+
{
|
|
66
|
+
// If the hostname is already a literal IP, validate directly.
|
|
67
|
+
if (net.isIP(hostname))
|
|
68
|
+
{
|
|
69
|
+
if (isBlockedIp(hostname))
|
|
70
|
+
return reject(Object.assign(new Error('ssrf_blocked'), { code: 'ssrf_blocked' }));
|
|
71
|
+
return resolve(hostname);
|
|
72
|
+
}
|
|
73
|
+
dns.lookup(hostname, { verbatim: true }, (err, address) =>
|
|
74
|
+
{
|
|
75
|
+
if (err)
|
|
76
|
+
return reject(Object.assign(new Error('dns_failed'), { code: 'download_failed' }));
|
|
77
|
+
if (isBlockedIp(address))
|
|
78
|
+
return reject(Object.assign(new Error('ssrf_blocked'), { code: 'ssrf_blocked' }));
|
|
79
|
+
resolve(address);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Default fetcher --------------------------------------------------
|
|
85
|
+
// Streams the response body, hashing as it goes and aborting the
|
|
86
|
+
// connection as soon as `maxBytes` would be exceeded so that a hostile
|
|
87
|
+
// origin cannot trickle gigabytes. Honours `maxRedirects` and re-runs
|
|
88
|
+
// the SSRF check at every hop. Returns { ok, status, body, sha256,
|
|
89
|
+
// finalUrl, reason } where `body` is a Buffer trimmed to maxBytes.
|
|
90
|
+
function defaultFetcher(opts)
|
|
91
|
+
{
|
|
92
|
+
const { url, maxBytes, timeoutMs, maxRedirects, allowedSchemes } = opts;
|
|
93
|
+
// Tests can inject a permissive resolver so the loopback-bound
|
|
94
|
+
// scratch server they spin up isn't rejected by the SSRF guard.
|
|
95
|
+
const resolver = opts.resolver || resolveAndCheck;
|
|
96
|
+
return new Promise((resolve) =>
|
|
97
|
+
{
|
|
98
|
+
const visited = new Set();
|
|
99
|
+
const start = Date.now();
|
|
100
|
+
|
|
101
|
+
const attempt = (currentUrl, hopsLeft) =>
|
|
102
|
+
{
|
|
103
|
+
let u;
|
|
104
|
+
try { u = new URL(currentUrl); }
|
|
105
|
+
catch (e) { return resolve({ ok: false, reason: 'invalid_url' }); }
|
|
106
|
+
|
|
107
|
+
if (!allowedSchemes.includes(u.protocol))
|
|
108
|
+
return resolve({ ok: false, reason: 'scheme_not_allowed' });
|
|
109
|
+
if (visited.has(u.href))
|
|
110
|
+
return resolve({ ok: false, reason: 'redirect_loop' });
|
|
111
|
+
visited.add(u.href);
|
|
112
|
+
|
|
113
|
+
resolver(u.hostname).then((ip) =>
|
|
114
|
+
{
|
|
115
|
+
const lib = u.protocol === 'http:' ? http : https;
|
|
116
|
+
const port = u.port ? Number(u.port) : (u.protocol === 'http:' ? 80 : 443);
|
|
117
|
+
const remaining = Math.max(1, timeoutMs - (Date.now() - start));
|
|
118
|
+
const req = lib.request({
|
|
119
|
+
host: ip,
|
|
120
|
+
port: port,
|
|
121
|
+
path: u.pathname + u.search,
|
|
122
|
+
method: 'GET',
|
|
123
|
+
servername: u.hostname, // SNI/SAN check still uses real host
|
|
124
|
+
headers: { host: u.hostname, 'user-agent': 'teleportxr-avatar-validator/1' },
|
|
125
|
+
timeout: remaining,
|
|
126
|
+
}, (res) =>
|
|
127
|
+
{
|
|
128
|
+
// Follow redirects with the same SSRF check at every hop.
|
|
129
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location)
|
|
130
|
+
{
|
|
131
|
+
res.resume();
|
|
132
|
+
if (hopsLeft <= 0)
|
|
133
|
+
return resolve({ ok: false, reason: 'too_many_redirects' });
|
|
134
|
+
const next = new URL(res.headers.location, u).href;
|
|
135
|
+
return attempt(next, hopsLeft - 1);
|
|
136
|
+
}
|
|
137
|
+
if (res.statusCode !== 200)
|
|
138
|
+
{
|
|
139
|
+
res.resume();
|
|
140
|
+
return resolve({ ok: false, reason: 'http_' + res.statusCode });
|
|
141
|
+
}
|
|
142
|
+
const hash = crypto.createHash('sha256');
|
|
143
|
+
const chunks = [];
|
|
144
|
+
let total = 0;
|
|
145
|
+
let aborted = false;
|
|
146
|
+
res.on('data', (chunk) =>
|
|
147
|
+
{
|
|
148
|
+
if (aborted) return;
|
|
149
|
+
total += chunk.length;
|
|
150
|
+
if (total > maxBytes)
|
|
151
|
+
{
|
|
152
|
+
aborted = true;
|
|
153
|
+
req.destroy();
|
|
154
|
+
return resolve({ ok: false, reason: 'file_too_large' });
|
|
155
|
+
}
|
|
156
|
+
hash.update(chunk);
|
|
157
|
+
chunks.push(chunk);
|
|
158
|
+
});
|
|
159
|
+
res.on('end', () =>
|
|
160
|
+
{
|
|
161
|
+
if (aborted) return;
|
|
162
|
+
resolve({
|
|
163
|
+
ok: true,
|
|
164
|
+
status: 200,
|
|
165
|
+
body: Buffer.concat(chunks, total),
|
|
166
|
+
sha256: hash.digest('hex'),
|
|
167
|
+
finalUrl: u.href,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
res.on('error', () =>
|
|
171
|
+
{
|
|
172
|
+
if (!aborted) resolve({ ok: false, reason: 'download_failed' });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
req.on('timeout', () => { req.destroy(); resolve({ ok: false, reason: 'fetch_timeout' }); });
|
|
176
|
+
req.on('error', () => resolve({ ok: false, reason: 'download_failed' }));
|
|
177
|
+
req.end();
|
|
178
|
+
}).catch((err) =>
|
|
179
|
+
{
|
|
180
|
+
resolve({ ok: false, reason: err.code || 'download_failed' });
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
attempt(url, maxRedirects);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// LRU cache --------------------------------------------------------
|
|
189
|
+
// Bounded by both entry count and total bytes. Maps content_hash → a
|
|
190
|
+
// frozen validation result so two clients submitting the same URL do
|
|
191
|
+
// not cause two fetches (plan §5 V5, §9). Map preserves insertion order
|
|
192
|
+
// so we can evict the oldest by simply re-inserting on access.
|
|
193
|
+
class LruCache
|
|
194
|
+
{
|
|
195
|
+
constructor(maxEntries, maxBytes)
|
|
196
|
+
{
|
|
197
|
+
this.maxEntries = Math.max(1, maxEntries | 0);
|
|
198
|
+
this.maxBytes = Math.max(0, maxBytes | 0);
|
|
199
|
+
this.map = new Map();
|
|
200
|
+
this.bytes = 0;
|
|
201
|
+
}
|
|
202
|
+
get(key)
|
|
203
|
+
{
|
|
204
|
+
if (!this.map.has(key)) return undefined;
|
|
205
|
+
const v = this.map.get(key);
|
|
206
|
+
this.map.delete(key); this.map.set(key, v);
|
|
207
|
+
return v;
|
|
208
|
+
}
|
|
209
|
+
set(key, value, bytes)
|
|
210
|
+
{
|
|
211
|
+
if (this.map.has(key))
|
|
212
|
+
{
|
|
213
|
+
this.bytes -= this.map.get(key)._bytes || 0;
|
|
214
|
+
this.map.delete(key);
|
|
215
|
+
}
|
|
216
|
+
const stored = Object.assign({}, value, { _bytes: bytes });
|
|
217
|
+
this.map.set(key, stored);
|
|
218
|
+
this.bytes += bytes;
|
|
219
|
+
while (this.map.size > this.maxEntries || this.bytes > this.maxBytes)
|
|
220
|
+
{
|
|
221
|
+
const first = this.map.keys().next().value;
|
|
222
|
+
if (first === undefined) break;
|
|
223
|
+
this.bytes -= this.map.get(first)._bytes || 0;
|
|
224
|
+
this.map.delete(first);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
get size() { return this.map.size; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Sniff the leading bytes to confirm the declared format. Phase 3
|
|
231
|
+
// supports GLB (binary glTF) by magic + glTF (JSON) by leading brace.
|
|
232
|
+
// Anything else is passed through as ok=true; the host-supplied
|
|
233
|
+
// validator can do stricter checking. Returns the canonical short name.
|
|
234
|
+
function sniffFormat(buf)
|
|
235
|
+
{
|
|
236
|
+
if (!buf || buf.length < 4) return '';
|
|
237
|
+
if (buf[0] === 0x67 && buf[1] === 0x6C && buf[2] === 0x54 && buf[3] === 0x46)
|
|
238
|
+
return 'glb';
|
|
239
|
+
// Skip BOM, whitespace.
|
|
240
|
+
let i = 0;
|
|
241
|
+
if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) i = 3;
|
|
242
|
+
while (i < buf.length && (buf[i] === 0x20 || buf[i] === 0x09 || buf[i] === 0x0A || buf[i] === 0x0D)) i++;
|
|
243
|
+
if (i < buf.length && buf[i] === 0x7B) return 'gltf';
|
|
244
|
+
return '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// IAvatarValidator -------------------------------------------------
|
|
248
|
+
// Reference interface; subclass and override `validate`. The default
|
|
249
|
+
// implementation below is sufficient for the Phase 3 exit criteria;
|
|
250
|
+
// host applications that want a stricter pipeline (e.g. real glTF
|
|
251
|
+
// parse, triangle count, proof check) supply their own.
|
|
252
|
+
class IAvatarValidator
|
|
253
|
+
{
|
|
254
|
+
async validate(/* offer, requirements */)
|
|
255
|
+
{
|
|
256
|
+
throw new Error('IAvatarValidator.validate is abstract');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
class DefaultAvatarValidator extends IAvatarValidator
|
|
261
|
+
{
|
|
262
|
+
constructor(opts = {})
|
|
263
|
+
{
|
|
264
|
+
super();
|
|
265
|
+
this.allowedSchemes = opts.allowedSchemes || ['https:'];
|
|
266
|
+
this.maxRedirects = opts.maxRedirects ?? 3;
|
|
267
|
+
this.defaultMaxBytes = opts.defaultMaxBytes || 10 * 1024 * 1024;
|
|
268
|
+
this.defaultTimeoutMs = opts.defaultTimeoutMs || 15000;
|
|
269
|
+
this.fetcher = opts.fetcher || defaultFetcher;
|
|
270
|
+
this.cache = opts.cache || new LruCache(opts.cacheEntries || 256, opts.cacheBytes || 256 * 1024 * 1024);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async validate(offer, requirements)
|
|
274
|
+
{
|
|
275
|
+
const reasons = [];
|
|
276
|
+
const req = requirements || {};
|
|
277
|
+
const formats = Array.isArray(req.formats) ? req.formats.map(s => String(s).toLowerCase()) : [];
|
|
278
|
+
const maxBytes = Number.isFinite(req.max_file_bytes) ? Math.min(req.max_file_bytes, this.defaultMaxBytes) : this.defaultMaxBytes;
|
|
279
|
+
const timeoutMs = Number.isFinite(req.fetch_timeout_ms) ? req.fetch_timeout_ms : this.defaultTimeoutMs;
|
|
280
|
+
|
|
281
|
+
// Cheap pre-flight checks before spending a TCP connection.
|
|
282
|
+
if (!offer || !offer.url || typeof offer.url !== 'string')
|
|
283
|
+
return { ok: false, reasons: ['no_url'], bytes: 0, contentHash: '', format: '' };
|
|
284
|
+
const declared = offer.declared || {};
|
|
285
|
+
if (formats.length && declared.format && !formats.includes(String(declared.format).toLowerCase()))
|
|
286
|
+
return { ok: false, reasons: ['format_not_allowed'], bytes: 0, contentHash: '', format: declared.format };
|
|
287
|
+
if (declared.file_bytes && declared.file_bytes > maxBytes)
|
|
288
|
+
return { ok: false, reasons: ['file_too_large'], bytes: declared.file_bytes, contentHash: '', format: declared.format || '' };
|
|
289
|
+
|
|
290
|
+
// Cache hit on declared content_hash short-circuits the fetch.
|
|
291
|
+
if (offer.content_hash && this.cache.get(offer.content_hash))
|
|
292
|
+
{
|
|
293
|
+
const cached = this.cache.get(offer.content_hash);
|
|
294
|
+
return { ok: cached.ok, reasons: cached.reasons.slice(), bytes: cached.bytes, contentHash: offer.content_hash, format: cached.format, fromCache: true };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const fetched = await this.fetcher({
|
|
298
|
+
url: offer.url,
|
|
299
|
+
maxBytes: maxBytes,
|
|
300
|
+
timeoutMs: timeoutMs,
|
|
301
|
+
maxRedirects: this.maxRedirects,
|
|
302
|
+
allowedSchemes: this.allowedSchemes,
|
|
303
|
+
});
|
|
304
|
+
if (!fetched.ok)
|
|
305
|
+
return { ok: false, reasons: [fetched.reason || 'download_failed'], bytes: 0, contentHash: '', format: '' };
|
|
306
|
+
|
|
307
|
+
const bytes = fetched.body.length;
|
|
308
|
+
const contentHash = 'sha256:' + fetched.sha256;
|
|
309
|
+
const sniffed = sniffFormat(fetched.body);
|
|
310
|
+
const format = sniffed || (declared.format || '').toLowerCase();
|
|
311
|
+
|
|
312
|
+
if (offer.content_hash && offer.content_hash !== contentHash)
|
|
313
|
+
reasons.push('hash_mismatch');
|
|
314
|
+
if (formats.length && format && !formats.includes(format))
|
|
315
|
+
reasons.push('format_not_allowed');
|
|
316
|
+
if (bytes > maxBytes)
|
|
317
|
+
reasons.push('file_too_large');
|
|
318
|
+
|
|
319
|
+
const ok = reasons.length === 0;
|
|
320
|
+
const result = { ok, reasons: reasons.slice(), bytes, contentHash, format };
|
|
321
|
+
this.cache.set(contentHash, result, bytes);
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports.isBlockedIp = isBlockedIp;
|
|
327
|
+
module.exports.resolveAndCheck = resolveAndCheck;
|
|
328
|
+
module.exports.defaultFetcher = defaultFetcher;
|
|
329
|
+
module.exports.LruCache = LruCache;
|
|
330
|
+
module.exports.sniffFormat = sniffFormat;
|
|
331
|
+
module.exports.IAvatarValidator = IAvatarValidator;
|
|
332
|
+
module.exports.DefaultAvatarValidator = DefaultAvatarValidator;
|
package/client/client.js
CHANGED
|
@@ -4,6 +4,7 @@ const core= require("../core/core.js");
|
|
|
4
4
|
const command= require("../protocol/command.js");
|
|
5
5
|
const message= require("../protocol/message.js");
|
|
6
6
|
const gs= require("./geometry_service.js");
|
|
7
|
+
const avatar_service= require("./avatar_service.js");
|
|
7
8
|
const node_encoder= require("../protocol/encoders/node_encoder.js");
|
|
8
9
|
const resource_encoder= require("../protocol/encoders/resource_encoder.js");
|
|
9
10
|
const WebRtcConnectionManager = require('../connections/webrtcconnectionmanager');
|
|
@@ -54,6 +55,9 @@ class Client {
|
|
|
54
55
|
this.origin_uid=0;
|
|
55
56
|
this.handshakeMessage=new message.HandshakeMessage();
|
|
56
57
|
this.geometryService=new gs.GeometryService(cid);
|
|
58
|
+
// Per-client avatar negotiation state. The host application drives
|
|
59
|
+
// when (and whether) policy is sent via this service.
|
|
60
|
+
this.avatarService=new avatar_service.AvatarService(cid, sigSend);
|
|
57
61
|
this.webRtcConnected=false;
|
|
58
62
|
this.webRtcConnection=null;
|
|
59
63
|
this.currentOriginState=new OriginState();
|
package/client/client_manager.js
CHANGED
|
@@ -111,6 +111,10 @@ class ClientManager
|
|
|
111
111
|
// then we tell the client manager to start this client.
|
|
112
112
|
var c=this.GetOrCreateClient(clientID);
|
|
113
113
|
signalingClient.receiveReliableBinaryMessage=c.receiveReliableBinaryMessage.bind(c);
|
|
114
|
+
// Route avatar-offer / avatar-revoke text frames to the per-client
|
|
115
|
+
// AvatarService. The signaling layer dispatches by message type.
|
|
116
|
+
signalingClient.handleAvatarOffer=c.avatarService.handleOffer.bind(c.avatarService);
|
|
117
|
+
signalingClient.handleAvatarRevoke=c.avatarService.handleRevoke.bind(c.avatarService);
|
|
114
118
|
//c.SetScene(this.scene);
|
|
115
119
|
c.Start();
|
|
116
120
|
return c;
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"name": "teleportxr",
|
|
18
18
|
"description": "Teleport Spatial Server on node.js",
|
|
19
|
-
"version": "1.0.
|
|
19
|
+
"version": "1.0.90",
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
22
|
"url": "git+https://github.com/simul/teleport-nodejs.git"
|
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
"./signaling": "./signaling.js",
|
|
33
33
|
"./client/client": "./client/client.js",
|
|
34
34
|
"./client/client_manager": "./client/client_manager.js",
|
|
35
|
+
"./client/avatar_service": "./client/avatar_service.js",
|
|
36
|
+
"./client/avatar_validator": "./client/avatar_validator.js",
|
|
35
37
|
"./connections/webrtcconnectionmanager": "./connections/webrtcconnectionmanager.js",
|
|
38
|
+
"./protocol/avatars": "./protocol/avatars.js",
|
|
36
39
|
"./scene/scene": "./scene/scene.js",
|
|
37
40
|
"./scene/node": "./scene/node.js",
|
|
38
41
|
"./scene/resources": "./scene/resources.js"
|