- Add boardDeviceService for managing device pairing, status, claiming, and revocation. - Introduce boardDeviceSettings for default settings and validation logic. - Create integration tests for device lifecycle, concurrent claims, and updates. - Add unit tests for device pairing, claiming, and settings validation. - Implement API tests for device endpoints with JWT authentication. - Develop in-memory repository for testing purposes. - Create frontend components for managing devices, including a form for device settings. - Style DevicesPage with CSS for better user experience.
75 lines
5.3 KiB
JavaScript
75 lines
5.3 KiB
JavaScript
const { randomBytes, randomInt, randomUUID, createHash } = require('node:crypto');
|
||
const { validSettings, deviceInfo, DeviceError } = require('./boardDeviceSettings');
|
||
const tokenHash = token => createHash('sha256').update(token).digest('hex');
|
||
function credential(token) {
|
||
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new DeviceError(401, 'invalid_device_token');
|
||
return tokenHash(token);
|
||
}
|
||
function name(value) {
|
||
if (typeof value !== 'string' || !value.trim() || value.trim().length > 100) throw new DeviceError(400, 'invalid_name', 'Укажите название устройства до 100 символов');
|
||
return value.trim();
|
||
}
|
||
function deviceId(value) { if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) throw new DeviceError(400, 'invalid_device_id'); return value; }
|
||
function publicDevice(row, now) {
|
||
return { id: row.id, name: row.name, cityId: row.city_id, cityName: row.city_name, stopName: row.stop_name, stopDirection: row.stop_direction || '', settings: row.settings,
|
||
revision: row.revision, appliedRevision: row.applied_revision, lastSeenAt: row.last_seen_at, deviceInfo: row.device_info, revokedAt: row.revoked_at,
|
||
online: !row.revoked_at && Boolean(row.last_seen_at && now - Date.parse(row.last_seen_at) < 90000) };
|
||
}
|
||
function createBoardDeviceService(repository, { now = Date.now, makeCode = () => String(randomInt(10000)).padStart(4, '0') } = {}) {
|
||
async function checked(cityId, input) {
|
||
const settings = validSettings(input);
|
||
if (!await repository.catalog(cityId, settings)) throw new DeviceError(400, 'invalid_stop_or_route', 'Остановка или маршрут не принадлежат выбранному городу и площадке');
|
||
return settings;
|
||
}
|
||
return {
|
||
async start(info) {
|
||
const token = randomBytes(32).toString('base64url');
|
||
for (let attempt = 0; attempt < 30; attempt++) {
|
||
const code = makeCode(), expiresAt = new Date(now() + 300000).toISOString();
|
||
try { await repository.createPairing({ code, token_hash: tokenHash(token), device_info: deviceInfo(info), expires_at: expiresAt }); return { token, state: 'pending', code, expiresAt, pollSeconds: 3 }; }
|
||
catch (error) { if (error.code !== '23505') throw error; }
|
||
}
|
||
throw new DeviceError(503, 'pairing_busy', 'Попробуйте подключить табло позже');
|
||
},
|
||
async status(token) {
|
||
const hash = credential(token), row = await repository.device(hash);
|
||
if (row) {
|
||
if (row.revoked_at) throw new DeviceError(410, 'device_revoked');
|
||
return { state: 'paired', device: publicDevice(row, now()), pollSeconds: 15 };
|
||
}
|
||
const pairing = await repository.pairing(hash);
|
||
if (!pairing) throw new DeviceError(401, 'invalid_device_token');
|
||
if (Date.parse(pairing.expires_at) <= now()) return { state: 'expired' };
|
||
return { state: 'pending', code: pairing.code, expiresAt: pairing.expires_at, pollSeconds: 3 };
|
||
},
|
||
async claim(cityId, input, userId) {
|
||
if (typeof input?.code !== 'string' || !/^\d{4}$/.test(input.code)) throw new DeviceError(400, 'invalid_code', 'Введите четыре цифры с экрана табло');
|
||
const settings = await checked(cityId, input.settings), label = name(input.name);
|
||
const row = await repository.claim(input.code, { id: randomUUID(), name: label, city_id: cityId, stop_id: settings.stopId, settings, created_by: userId });
|
||
if (!row) throw new DeviceError(409, 'code_expired', 'Код истёк или уже использован. Проверьте код на табло');
|
||
return { id: row.id };
|
||
},
|
||
async routes(cityId, stopId) { await checked(cityId, { stopId }); return repository.routes(cityId, stopId); },
|
||
async list(cityId) { return (await repository.list(cityId)).map(row => publicDevice(row, now())); },
|
||
async update(id, cityId, input, userId) {
|
||
deviceId(id);
|
||
if (!Number.isInteger(input?.revision) || input.revision < 1) throw new DeviceError(400, 'invalid_revision');
|
||
const settings = await checked(cityId, input.settings);
|
||
const row = await repository.update(id, cityId, { name: name(input.name), settings, revision: input.revision, userId });
|
||
if (!row) throw new DeviceError(409, 'revision_conflict', 'Устройство изменено или отвязано. Обновите список и откройте настройки заново');
|
||
return { id: row.id, revision: row.revision };
|
||
},
|
||
async revoke(id, cityId, userId) { if (!await repository.revoke(deviceId(id), cityId, userId)) throw new DeviceError(404, 'device_not_found'); return { revoked: true }; },
|
||
async heartbeat(token, input) {
|
||
const hash = credential(token);
|
||
if (!Number.isInteger(input?.appliedRevision) || input.appliedRevision < 0) throw new DeviceError(400, 'invalid_revision');
|
||
const row = await repository.device(hash);
|
||
if (!row || row.revoked_at) throw new DeviceError(410, 'device_revoked');
|
||
if (input.appliedRevision > row.revision) throw new DeviceError(400, 'invalid_revision');
|
||
if (!await repository.heartbeat(hash, input.appliedRevision, deviceInfo(input))) throw new DeviceError(410, 'device_revoked');
|
||
return { ok: true };
|
||
},
|
||
};
|
||
}
|
||
module.exports = { createBoardDeviceService, tokenHash, publicDevice };
|