feat: add remote device management and pairing functionality
- Implemented device pairing with a four-digit code and TTL for remote settings. - Introduced automatic checks for device status and configuration validation. - Developed UI components for device setup and management, including error handling and retry options. - Created a device bridge for secure communication between the device and the server. - Added tests for managed state validation and device pairing logic. - Established a local end-to-end fixture for testing device service and API interactions.
This commit is contained in:
59
server/device.mjs
Normal file
59
server/device.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ApiError } from './board.mjs';
|
||||
|
||||
const COOKIE = 'ridebus_board_device';
|
||||
const tokenFrom = req => { const value = req.headers.cookie?.split(';').map(item => item.trim()).find(item => item.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1); return /^[A-Za-z0-9_-]{43}$/.test(value || '') ? value : ''; };
|
||||
export function createDeviceBridge({ enabled = process.env.BOARD_REMOTE_SETTINGS === 'true', baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/', adminUrl = process.env.BOARD_ADMIN_URL || '', fetchData = fetch } = {}) {
|
||||
const cache = new Map();
|
||||
async function upstream(path, token, body) {
|
||||
let response;
|
||||
try { response = await fetchData(new URL(`api/v1/board-devices/${path}`, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`), {
|
||||
method: body === undefined ? 'GET' : 'POST', headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(token ? { 'X-Board-Token': token } : {}) },
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}), redirect: 'error', signal: AbortSignal.timeout(10000),
|
||||
}); } catch { throw new ApiError(502, 'devices_unavailable'); }
|
||||
if (!response.ok) {
|
||||
const codes = { 401: 'device_unpaired', 410: 'device_revoked', 429: 'device_rate_limit' };
|
||||
throw new ApiError(codes[response.status] ? response.status : 502, codes[response.status] || 'devices_unavailable');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
async function status(req, force = false) {
|
||||
const token = tokenFrom(req);
|
||||
if (!token) return { state: 'unpaired' };
|
||||
const cached = cache.get(token);
|
||||
if (!force && cached?.until > Date.now()) return cached.value;
|
||||
try {
|
||||
const value = await upstream('self', token);
|
||||
if (cache.size >= 1000) cache.delete(cache.keys().next().value);
|
||||
cache.set(token, { value, until: Date.now() + 15000 }); return value;
|
||||
} catch (error) { cache.delete(token); if (error.code === 'device_unpaired') return { state: 'unpaired' }; throw error; }
|
||||
}
|
||||
return {
|
||||
enabled,
|
||||
async status(req, config) {
|
||||
if (!enabled) return { enabled: false };
|
||||
const value = await status(req, true);
|
||||
return { ...value, enabled: true, adminUrl, ...(value.state === 'paired' ? { config: { ...config, cityId: value.device.cityId, cityName: value.device.cityName, stopName: value.device.stopName, defaultStopId: value.device.settings.stopId } } : {}) };
|
||||
},
|
||||
async start(req, res) {
|
||||
if (!enabled) throw new ApiError(404, 'not_found');
|
||||
const previous = await status(req, true).catch(error => { if (error.code === 'device_revoked') return { state: 'revoked' }; throw error; });
|
||||
if (['paired', 'pending'].includes(previous.state)) return { ...previous, enabled: true, adminUrl };
|
||||
const value = await upstream('pairings', '', req.body || {});
|
||||
if (!/^[A-Za-z0-9_-]{43}$/.test(value.token || '')) throw new ApiError(502, 'devices_unavailable');
|
||||
const oldToken = tokenFrom(req); if (oldToken) cache.delete(oldToken);
|
||||
res.cookie(COOKIE, value.token, { httpOnly: true, sameSite: 'strict', secure: req.secure || req.get('X-Forwarded-Proto') === 'https' || process.env.BOARD_COOKIE_SECURE === 'true', path: '/api', maxAge: 365 * 86400000 });
|
||||
const { token: _token, ...publicValue } = value;
|
||||
return { ...publicValue, enabled: true, adminUrl };
|
||||
},
|
||||
async heartbeat(req) {
|
||||
if (!enabled || !tokenFrom(req)) throw new ApiError(401, 'device_unpaired');
|
||||
return upstream('self/heartbeat', tokenFrom(req), req.body || {});
|
||||
},
|
||||
async context(req) {
|
||||
if (!enabled) return null;
|
||||
const value = await status(req);
|
||||
if (value.state !== 'paired') throw new ApiError(401, 'device_unpaired');
|
||||
return value.device;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,37 +4,58 @@ import { fileURLToPath } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { ApiError, createBoardClient } from './board.mjs';
|
||||
import { createWeatherClient } from './weather.mjs';
|
||||
import { createDeviceBridge } from './device.mjs';
|
||||
|
||||
export function createApp(client = createBoardClient(), weatherClient = createWeatherClient()) {
|
||||
export function createApp(client = createBoardClient(), weatherClient = createWeatherClient(), deviceBridge = createDeviceBridge(), clientFactory = createBoardClient) {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); next(); });
|
||||
app.get('/healthz', (_req, res) => res.json({ status: 'ok' }));
|
||||
app.use('/api/device', express.json({ limit: '8kb' }), (req, res, next) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (req.method !== 'GET') {
|
||||
let origin; try { origin = req.get('Origin') ? new URL(req.get('Origin')) : null; } catch { return res.status(403).json({ error: 'invalid_origin' }); }
|
||||
if (req.get('Sec-Fetch-Site') === 'cross-site' || origin && origin.host !== req.get('Host') || !req.is('application/json')) return res.status(403).json({ error: 'invalid_origin' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.get('/api/device/status', async (req, res) => res.json(await deviceBridge.status(req, client.config())));
|
||||
app.post('/api/device/pair', async (req, res) => res.json(await deviceBridge.start(req, res)));
|
||||
app.post('/api/device/heartbeat', async (req, res) => res.json(await deviceBridge.heartbeat(req)));
|
||||
const clients = new Map();
|
||||
app.use('/api', (req, res, next) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); res.status(405).json({ error: 'method_not_allowed' }); return; }
|
||||
next();
|
||||
});
|
||||
app.use('/api', async (req, _res, next) => {
|
||||
if (!deviceBridge.enabled || req.path === '/config') { req.boardClient = client; return next(); }
|
||||
const device = await deviceBridge.context(req);
|
||||
if (!clients.has(device.cityId)) clients.set(device.cityId, clientFactory({ cityId: device.cityId, cityName: device.cityName, stopName: device.stopName, defaultStopId: device.settings.stopId }));
|
||||
req.boardClient = clients.get(device.cityId);
|
||||
if (req.query.stopId && req.query.stopId !== String(device.settings.stopId)) throw new ApiError(409, 'settings_changed');
|
||||
next();
|
||||
});
|
||||
app.get('/api/config', (_req, res) => res.json(client.config()));
|
||||
app.get('/api/stops', async (_req, res) => { res.json(await client.stops()); });
|
||||
app.get('/api/stops', async (req, res) => { res.json(await req.boardClient.stops()); });
|
||||
app.get('/api/board', async (req, res) => {
|
||||
const raw = req.query.stopId;
|
||||
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw)) throw new ApiError(400, 'invalid_stop');
|
||||
const data = await client.board(Number(raw));
|
||||
const data = await req.boardClient.board(Number(raw));
|
||||
res.json({ ...data, serverTime: new Date().toISOString() });
|
||||
});
|
||||
const stopId = req => {
|
||||
if (typeof req.query.stopId !== 'string' || !/^[1-9]\d*$/.test(req.query.stopId) || !Number.isSafeInteger(Number(req.query.stopId))) throw new ApiError(400, 'invalid_stop');
|
||||
return Number(req.query.stopId);
|
||||
};
|
||||
app.get('/api/timetable', async (req, res) => res.json(await client.timetable(stopId(req))));
|
||||
app.get('/api/timetable', async (req, res) => res.json(await req.boardClient.timetable(stopId(req))));
|
||||
app.get('/api/weather', async (req, res) => {
|
||||
const id = stopId(req);
|
||||
res.json(await weatherClient.weather(client.config().cityId, id, await client.coordinates(id)));
|
||||
res.json(await weatherClient.weather(req.boardClient.config().cityId, id, await req.boardClient.coordinates(id)));
|
||||
});
|
||||
app.use('/api', (_req, res) => res.status(404).json({ error: 'not_found' }));
|
||||
// Never return upstream bodies, stack traces or API credentials.
|
||||
app.use((error, _req, res, _next) => { res.status(error instanceof ApiError ? error.status : 502).json({ error: error instanceof ApiError ? error.code : 'data_unavailable' }); });
|
||||
app.use((error, _req, res, _next) => { res.status(error.type === 'entity.parse.failed' ? 400 : error.type === 'entity.too.large' ? 413 : error instanceof ApiError ? error.status : 502).json({ error: ['entity.parse.failed', 'entity.too.large'].includes(error.type) ? 'invalid_request' : error instanceof ApiError ? error.code : 'data_unavailable' }); });
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user