diff --git a/.env.example b/.env.example index cb6f6bc..9e0ddde 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,9 @@ BOARD_PWA_URL=https://pwa.ridebus.by/ OPEN_METEO_API_KEY= PORT=5180 HOST=127.0.0.1 +# Managed displays: pair each browser in the admin's Devices section. +BOARD_REMOTE_SETTINGS=false +# Optional URL shown on the pairing screen. +BOARD_ADMIN_URL= +# Set true behind an HTTPS reverse proxy if it does not send X-Forwarded-Proto. +BOARD_COOKIE_SECURE=false diff --git a/README.md b/README.md index 5ac2ad4..5a7ee68 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,14 @@ npm start ## Возможности -- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами каждые 7 секунд с явным счётчиком. На 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием. +- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами с явным счётчиком: каждые 7 секунд для телевизора и 10 секунд для уличного табло. В стандартном профиле на 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием. +- Точная отмена выделяется непосредственно в строке рейса: время остаётся видимым, отсчёт заменяется пометкой «Отменён». Ближайшим становится следующий доступный рейс. Общие сообщения без однозначного времени и остановки показывают «Есть изменения» и сохраняют исходный текст предупреждения. +- Под ближайшим отправлением — время следующего рейса того же маршрута в том же направлении. Отменённые отправления пропускаются, завтрашняя дата отмечается один раз. Переключатель находится в настройках → «Экраны». +- При ожидании от 90 минут крупно показывается фактическое время отправления с подписью «Сегодня в» или «Завтра в». При приближении рейса возвращается обычный отсчёт. Режим можно отключить в настройках → «Экраны». +- Настройки → «Оформление» → «Читаемость»: профили «Телевизор» / «Уличное табло» и расстояние «Близко» / «Средне» / «Далеко». Для улицы и дальнего просмотра текст крупнее, одновременно видимых строк меньше. Уличный профиль использует спокойное короткое появление вместо вертикальной прокрутки текста. Информационные карточки при дальнем просмотре показываются по одной. - В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки. - Информационный экран: первый и последний рейс, погода, QR-код и предупреждения. Карточка движения показывает последний рейс сегодня и первый завтра с номером и направлением по полному расписанию, с учётом фильтра маршрута и прибытий после полуночи. Без полного расписания время не подставляется из короткого списка ближайших рейсов. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Движение», «Погода», «RideBus». - В настройках → «Экраны» → «Блоки информации» каждый из трёх блоков можно включить или отключить. Оставшиеся карточки занимают доступную ширину, отключённые пропускаются в компактном цикле. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает все блоки. -- Блок маршрутов показывает только номер и название. На широком экране список занимает 2–3 колонки, в узкой карточке — одну. Вместимость определяется доступной высотой; остальные маршруты сменяются страницами каждые 6,5 секунды с плавным переходом названий и чисел. Пауза останавливает смену страниц. Тип транспорта, повторные времена, наклонённые дорожки, фоновые рисунки и технический статус кеша убраны. - Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности. - Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла. - Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят. @@ -47,6 +50,8 @@ npm start При сбое появляется пометка сохранённых данных. Истёкшие рейсы удаляются. Если сохранённые рейсы закончились, табло честно сообщает об этом и продолжает переподключение. Предупреждения фильтруются по городу, маршрутам, срокам действия; сохраняются полный текст и детали отмены. Полный кеш расписания не подтверждает актуальность предупреждений — без свежего ответа API это явно указано. +Для сопоставления отмен используется точный формат действующего Telegram-импортера API: дата, «не выполняются рейсы», название остановки и перечень времён; поддерживаются секции отдельных маршрутов. Сохраняются отправная остановка, время и день обслуживания, чтобы отмена правильно сопоставлялась на следующих остановках и после полуночи. Неоднозначные названия площадок или несколько совпавших рейсов не помечаются отменёнными автоматически. Устаревшие, будущие и истёкшие предупреждения не исключают отправления. + QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. В карточке — большой неподвижный QR, одна инструкция и ссылка. Размер кода рассчитывается по свободному месту. QR использует скруглённые чёрные модули, белый фон и свободное поле шириной 4 модуля. Декодирование нескольких ссылок и QR на снимках страницы проверено автоматически. Погода запрашивается независимо через `/api/weather` у [Open-Meteo](https://open-meteo.com/en/docs) по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — текущая температура, состояние и прогноз температуры с крупными иконками на три следующих часа. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0. @@ -67,6 +72,22 @@ npm run probe ## Развёртывание +### Удалённое управление из polotsk-transit-api + +Сначала примените миграцию `backend/migrations/016_board_devices.sql` в базе API и разверните обновлённые backend/frontend `polotsk-transit-api`. Для существующей базы выполняйте именно новую миграцию: старый общий скрипт проекта повторяет все SQL-файлы, включая создание уже существующих таблиц. + +В `.env` табло включите `BOARD_REMOTE_SETTINGS=true`. `RIDEBUS_API_BASE_URL` указывает на обновлённый сервер API, `RIDEBUS_API_KEY` остаётся только на сервере Board. Необязательный `BOARD_ADMIN_URL` — полный HTTPS-адрес раздела устройств вашей админки. Compose передаёт все эти параметры в контейнер. После изменения переменных пересоберите/пересоздайте контейнер. + +При первом запуске каждый браузер показывает собственный четырёхзначный код, действующий 5 минут. Администратор открывает «Устройства», добавляет табло по коду, выбирает название, город (через переключатель сети), конкретную площадку и настройки. Код одноразовый; после привязки табло запускается автоматически. Смена остановки, маршрута, темы, палитры, профиля, числа строк, экранов, информационных блоков и рекламы применяется в течение следующего опроса, без обновления страницы. Деморежим удалённой конфигурации выключен. + +Во время привязки опрос каждые 3 секунды, после — каждые 15. Сигнал работы каждые 30 секунд и отдельное подтверждение после применения новой версии. Админка различает «Ожидают применения» и «Настройки применены»; после 90 секунд без связи устройство считается офлайн. Конкурирующие изменения обнаруживаются по версии, старое сохранение не перезаписывает новое. + +Идентичность устройства — отдельный профиль браузера на отдельном origin. Вкладки одного браузера с одним адресом относятся к одному устройству. Токен хранится в HttpOnly cookie и не передаётся в JavaScript или URL; на API сохраняется только SHA-256 хеш. Для сервера с HTTPS нужен `Secure` cookie: передавайте `X-Forwarded-Proto: https` через reverse proxy либо задайте `BOARD_COOKIE_SECURE=true`. Очищенные cookies, смена адреса или профиля требуют новой привязки. Не запускайте табло в приватном режиме. + +При потере управления табло использует последнюю проверенную конфигурацию и существующий кеш расписания. Локальная конфигурация автономного режима хранится отдельно. После привязки меню на телевизоре показывает состояние управления, имя устройства и версию; параметры меняются в админке. «Отвязать» отзывает токен; экран прекращает показ при следующей связи с API. Для новой привязки нажмите «Подключить заново» на табло. При отключённом `BOARD_REMOTE_SETTINGS` сохраняется прежняя локальная настройка. + +Для локальной совместной проверки после сборки обоих проектов: `node test/fixtures/device-flow-server.mjs`. Это явно обозначенный тестовый стенд с настоящими устройственными обработчиками и UI, но с памятью вместо PostgreSQL и тестовым транспортом. Админка — `http://127.0.0.1:5190/devices`, табло — `http://127.0.0.1:5191`; тестовый вход `board-fixture / fixture-only`. Этот файл не входит в Docker runtime и не предназначен для публикации. + ```sh docker compose up --build -d ``` diff --git a/artifacts/departure-features-cancellation.jpg b/artifacts/departure-features-cancellation.jpg index 005ddbf..e428e5a 100644 Binary files a/artifacts/departure-features-cancellation.jpg and b/artifacts/departure-features-cancellation.jpg differ diff --git a/artifacts/departure-features-live.jpg b/artifacts/departure-features-live.jpg index ae603c4..1a0b40d 100644 Binary files a/artifacts/departure-features-live.jpg and b/artifacts/departure-features-live.jpg differ diff --git a/artifacts/device-management-offline.jpg b/artifacts/device-management-offline.jpg new file mode 100644 index 0000000..ab6ddbf Binary files /dev/null and b/artifacts/device-management-offline.jpg differ diff --git a/artifacts/device-pairing-screen.jpg b/artifacts/device-pairing-screen.jpg new file mode 100644 index 0000000..5d00aba Binary files /dev/null and b/artifacts/device-pairing-screen.jpg differ diff --git a/artifacts/device-remote-light.jpg b/artifacts/device-remote-light.jpg new file mode 100644 index 0000000..d08e9ca Binary files /dev/null and b/artifacts/device-remote-light.jpg differ diff --git a/artifacts/device-repairing.jpg b/artifacts/device-repairing.jpg new file mode 100644 index 0000000..5f5b457 Binary files /dev/null and b/artifacts/device-repairing.jpg differ diff --git a/artifacts/devices-admin-applied.jpg b/artifacts/devices-admin-applied.jpg new file mode 100644 index 0000000..b547ebb Binary files /dev/null and b/artifacts/devices-admin-applied.jpg differ diff --git a/artifacts/verification-departure-features.md b/artifacts/verification-departure-features.md new file mode 100644 index 0000000..891def8 --- /dev/null +++ b/artifacts/verification-departure-features.md @@ -0,0 +1,26 @@ +# Проверка четырёх функций табло — 9 октября 2026 + +Добавлены отмены в строках отправлений, следующий рейс того же направления, крупное время при ожидании от 90 минут и профили читаемости. + +## Автоматические проверки + +- `npm test`: 64 теста прошли. Проверены точные и общие уведомления, несколько маршрутов, неоднозначные площадки/рейсы, устаревшие предупреждения, прибытия после полуночи, следующий рейс и сохранение настроек. +- `npm run build`: финальная TypeScript/Vite-сборка. Временная визуальная сборка исключена из итогового `dist`. +- `git diff --check`: без ошибок форматирования. +- Импорт серверных модулей из изолированной структуры runtime прошёл. В Dockerfile добавлен общий модуль обработки отмен. Docker-контейнер не запускался. + +## Браузер + +Основная страница проверена с настоящими данными остановки «Швейная фабрика»: следующий рейс того же направления отображается, завтрашнее время не дублируется. После перезапуска локального сервера API вернул расписание 22 маршрутов с метаданными отправной остановки и дня обслуживания. Временный сбой upstream вернул 502; повторный запрос завершился успешно. + +Отмена и большие интервалы проверены на явно обозначенных тестовых данных с настоящим компонентом `DeparturesScreen`; исходник сценариев находится в `test/fixtures/board-features.tsx`. Тестовые отмены не выдавались за действующие сообщения API. + +- **1079×591, 6 строк:** страницы по три строки, высота строк 73–74 px, промежуток между временем и ожиданием около 24 px, содержимое не переполняет список. +- **855×723, 4 строки:** четыре строки одновременно, высота 76–77 px, без пересечений. +- **879×593:** отменённый рейс пропускается при выборе ближайшего; ночной сценарий показывает «Завтра в 05:40» и следующий рейс «06:20 завтра». +- **390×667:** длинное ожидание, переход к отсчёту за 10 минут, длинные названия, все рейсы отменены; в профиле «улица / далеко» одна крупная строка на странице. +- **1280×900:** тёмная тема с отменами и светлая тема с крупным временем «Сегодня в 14:05». +- Автоматическая смена страниц уличного профиля наблюдалась через 10 секунд; пауза останавливает смену. +- На реальной странице профиль «улица / далеко» и выключенные новые функции сохранились после перезагрузки. В меню 879×593 новые поля доступны через постоянные кнопки «Ниже». Затем восстановлены «телевизор / близко», оба новых переключателя включены, удержание ближайшего рейса включено, 4 строки, тёмная лаймовая тема. + +Снимки `departure-features-*.jpg` находятся рядом с этим файлом. Итоговый пример отмены — `departure-features-cancellation.jpg`. Физический Xiaomi Mi Box 4 с Яндекс Браузером не проверялся. diff --git a/artifacts/verification-device-management.md b/artifacts/verification-device-management.md new file mode 100644 index 0000000..c1e0b6a --- /dev/null +++ b/artifacts/verification-device-management.md @@ -0,0 +1,37 @@ +# Удалённое управление табло — 10 октября 2026 + +Изменения сделаны совместно в `/Users/xtimms/Projects/ridebus-board` и `/Users/xtimms/Projects/polotsk-transit-api`. + +## Результат + +- При включённом `BOARD_REMOTE_SETTINGS` новое устройство показывает четырёхзначный код с TTL 5 минут; привязка выполняется администратором в разделе «Устройства» выбранного города. +- Индивидуальный токен в HttpOnly cookie, на API только хеш. Код занимается транзакционно, параметры остановки/маршрута проверяются по городу. Отдельные права устройства и администратора. +- Настройки с версиями и оптимистическим сохранением. Подтверждение применения от телевизора; состояние связи с порогом 90 секунд. +- Удалённые настройки всех существующих параметров табло, ограниченное локальное меню, кеш конфигурации, отказ от показа после получения отзыва доступа, повторное подключение по новому коду. +- Автономный режим сохранён; параметр управления по умолчанию выключен до развёртывания обновлённого API. + +## Проверки + +- Board: 66 тестов прошли; `npm run build` (TypeScript + Vite) успешен. +- API: полный набор — 96 прошли, 15 пропущены из-за отсутствия отдельной PostgreSQL БД. Новые целевые проверки — 7 прошли, 1 PostgreSQL-тест пропущен. Повторная целевая проверка после уточнения лимитов тоже успешна. +- Admin: Vite production build успешен; существующие предупреждения о Browserslist и размере общего bundle. +- `git diff --check` без ошибок в обоих проектах. +- Сервер Board импортирован из изолированного дерева с тем же набором общих файлов, который копируется в Docker runtime. Docker-контейнер не запускался: локальный daemon недоступен. + +## Совместный браузерный стенд + +Использованы настоящие обработчики API устройств, сервис, мост Board и UI обоих проектов. Репозиторий устройств в памяти и транспортные данные явно тестовые; это не доказательство работы с производственной БД. Файл стенда: `test/fixtures/device-flow-server.mjs`, тестовый сервер доступен только на loopback и остановлен после проверки. + +Проверены через интерфейс: + +1. Вход тестового администратора и раздел «Устройства». +2. Ввод кода, автоматический переход табло к расписанию, статус «В сети / Настройки применены · v1». +3. Изменение остановки на «Автовокзал», светлая зелёная тема, уличный профиль и дальний просмотр; автоматическое применение без перезагрузки, затем «Настройки применены · v2» в админке. +4. Перезагрузка сохраняет привязку; локальное меню показывает «Управляется из админки» и версию 2. +5. Отвязка в админке прекращает показ; кнопка на табло получает новый код. +6. Недоступность серверной части управления и перезагрузка страницы: восстановлены имя остановки и параметры из кеша, показаны сообщения о сохранённом расписании и отсутствии связи с управлением. +7. Восстановление связи: устройство снова «В сети», сохранённая конфигурация подтверждена. + +Снимки: `device-pairing-screen.jpg`, `devices-admin-applied.jpg`, `device-remote-light.jpg`, `device-repairing.jpg`, `device-management-offline.jpg`. + +Миграция 016 подготовлена, но к рабочей базе не применялась. Обновлённые API и админка на сервер не выкладывались. Физический Xiaomi Mi Box 4 не проверялся. Исходный локальный Board на 5180 перезапущен с новым серверным кодом, его автономные настройки сохранены; тестовые вкладки закрыты. diff --git a/compose.yaml b/compose.yaml index 869c214..3dbca0b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,5 +12,8 @@ services: BOARD_STOP_ID: ${BOARD_STOP_ID:-1} BOARD_PWA_URL: ${BOARD_PWA_URL:-https://pwa.ridebus.by/} OPEN_METEO_API_KEY: ${OPEN_METEO_API_KEY:-} + BOARD_REMOTE_SETTINGS: ${BOARD_REMOTE_SETTINGS:-false} + BOARD_ADMIN_URL: ${BOARD_ADMIN_URL:-} + BOARD_COOKIE_SECURE: ${BOARD_COOKIE_SECURE:-false} ports: - "127.0.0.1:${BOARD_PORT:-5180}:5180" diff --git a/server/device.mjs b/server/device.mjs new file mode 100644 index 0000000..61fa430 --- /dev/null +++ b/server/device.mjs @@ -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; + }, + }; +} diff --git a/server/index.mjs b/server/index.mjs index a478191..7afa760 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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; } diff --git a/src/App.tsx b/src/App.tsx index 8b9b87a..8fcf0ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,9 @@ import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, upcomingDeparture import { hasSavedSettings, readSettings, saveSettings } from './lib/storage'; import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks'; import { SettingsPanel } from './SettingsPanel'; +import { useDeviceControl } from './useDeviceControl'; +import type { ManagedState } from './useDeviceControl'; +import { DeviceSetup, ManagedPanel } from './DeviceSetup'; const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' }); const dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' }); @@ -136,12 +139,21 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config } export default function App() { + const remote = useDeviceControl(); + if (remote.control.state === 'standalone') return ; + if (remote.managed) return ; + return ; +} + +function BoardApplication({ management }: { management?: { state: ManagedState; failed: boolean; onApplied: (revision: number) => void } }) { const [settings, setSettings] = useState(() => { + if (management) return management.state.device.settings; const saved = readSettings(); if (new URLSearchParams(location.search).get('demo') === '1') saved.demo = true; return saved; }); - const [config, setConfig] = useState(fallbackConfig); + const [config, setConfig] = useState(management?.state.config || fallbackConfig); + const acknowledged = useRef(0); const [settingsOpen, setSettingsOpen] = useState(false); const controlsVisible = useIdleControls(settingsOpen); const [scene, setScene] = useState('departures'); @@ -201,6 +213,7 @@ export default function App() { catch { showNotice('Полный экран недоступен в этом браузере'); } }, [showNotice]); useEffect(() => { + if (management) return; const controller = new AbortController(); fetch('/api/config', { signal: controller.signal }).then(response => { if (!response.ok) throw new Error(); return response.json(); }).then((value: Config) => { setConfig(value); @@ -208,6 +221,17 @@ export default function App() { }).catch(() => {}); return () => controller.abort(); }, []); + useEffect(() => { + if (!management) return; + setSettings(management.state.device.settings); setConfig(management.state.config); + setSettingsOpen(false); setScene('departures'); setInfoPage(0); setStartedAt(Date.now()); + }, [management?.state.device.revision]); + useEffect(() => { + if (management && settings === management.state.device.settings && acknowledged.current !== management.state.device.revision) { + acknowledged.current = management.state.device.revision; + management.onApplied(management.state.device.revision); + } + }, [management, settings]); useEffect(() => { document.title = `${data?.stop.name || config.stopName} · RideBus Board`; }, [data?.stop.name, config.stopName]); useEffect(() => { if (held) { setScene('departures'); setStartedAt(Date.now()); } @@ -241,6 +265,7 @@ export default function App() {
{dateFormat.format(now)}
{!fresh && !settings.demo &&
{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}
} + {management?.failed &&
Нет связи с управлением · сохранённые настройки
}
{scene === 'departures' && } {scene === 'info' &&
{notice &&
{notice}
} - {settingsOpen && { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setInfoPage(0); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />} + {settingsOpen && (management ? setSettingsOpen(false)} onFullscreen={() => void toggleFullscreen()} /> : { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setInfoPage(0); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />)} ; } diff --git a/src/DeviceSetup.tsx b/src/DeviceSetup.tsx new file mode 100644 index 0000000..de21cdf --- /dev/null +++ b/src/DeviceSetup.tsx @@ -0,0 +1,23 @@ +import { useEffect, useRef, useState } from 'react'; +import logo from './assets/ridebus-logo.svg?raw'; +import { NumericText } from './NumericText'; +import { Fullscreen, Refresh, Shield } from './Icons'; +import type { ManagedState } from './useDeviceControl'; +export function DeviceSetup({ control, failed, onRetry }: { control: { state: string; code?: string; expiresAt?: string; adminUrl?: string }; failed: boolean; onRetry: () => void }) { + const [now, setNow] = useState(Date.now()); + useEffect(() => { document.title = 'Подключение · RideBus Board'; const interval = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(interval); }, []); + const remaining = Math.max(0, Math.min(300, Math.ceil((Date.parse(control.expiresAt || '') - now) / 1000))); + const pending = control.state === 'pending' && remaining > 0; + return
ridebus
RIDEBUS BOARD

{control.state === 'revoked' ? 'Устройство отвязано' : 'Подключите табло'}

+

{pending ? 'Введите этот код в админке: Устройства → Добавить устройство' : control.state === 'revoked' ? 'Для повторной настройки подключите устройство по новому коду' : control.state === 'storage-error' ? 'Разрешите браузеру сохранять cookies и данные сайта' : control.state === 'loading' ? 'Получаем код подключения…' : 'Для подключения нужен доступ к серверу'}

+ {pending && <>
Код действует ещё
} + {control.adminUrl && Открыть админку} + {failed &&
{control.state === 'storage-error' ? 'Не удалось сохранить привязку устройства' : 'Нет связи с сервером. Повторяем подключение'}
} + {!pending && control.state !== 'loading' && } +
; +} +export function ManagedPanel({ state, failed, onClose, onFullscreen }: { state: ManagedState; failed: boolean; onClose: () => void; onFullscreen: () => void }) { + const dialog = useRef(null); + useEffect(() => { dialog.current?.showModal(); }, []); + return { event.preventDefault(); onClose(); }}>

Управляется из админки

{state.device.name}

Изменяйте остановку, оформление и экраны в разделе «Устройства».

Настройки
Версия {state.device.revision}
Соединение
{failed ? 'Нет связи · сохранённые настройки' : 'Подключено'}
; +} diff --git a/src/lib/device.mjs b/src/lib/device.mjs new file mode 100644 index 0000000..4d0d45f --- /dev/null +++ b/src/lib/device.mjs @@ -0,0 +1,12 @@ +import { colorSchemes, lightColorSchemes } from './presentation.mjs'; +export const MANAGED_KEY = 'ridebus-board-managed-v1'; +export function isManagedState(value) { + const device = value?.device, s = device?.settings, config = value?.config; + if (value?.state !== 'paired' || !device || !s || !config || typeof device.id !== 'string' || typeof device.name !== 'string' || !Number.isInteger(device.revision) || device.revision < 1 || !Number.isInteger(device.cityId) || device.cityId < 1 || config.cityId !== device.cityId || config.defaultStopId !== s.stopId) return false; + if (typeof config.configured !== 'boolean' || !Number.isInteger(config.refreshSeconds) || config.refreshSeconds < 5 || config.refreshSeconds > 120 || config.timeZone !== 'Europe/Minsk') return false; + for (const [key, min, max] of [['stopId', 1, 1000000], ['mainSeconds', 5, 120], ['infoSeconds', 5, 60], ['rowCount', 2, 6], ['adSeconds', 5, 30]]) if (!Number.isInteger(s[key]) || s[key] < min || s[key] > max) return false; + if (s.demo !== false || !['dark', 'light'].includes(s.theme) || !['tv', 'street'].includes(s.displayProfile) || !['near', 'medium', 'far'].includes(s.viewingDistance)) return false; + if (!colorSchemes.some(color => color.id === s.colorScheme) || !lightColorSchemes.some(color => color.id === s.lightColorScheme)) return false; + for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof s[key] !== 'boolean') return false; + return typeof s.routeFilter === 'string' && /^(|[1-9]\d{0,8})$/.test(s.routeFilter) && typeof s.adTitle === 'string' && s.adTitle.length <= 80 && typeof s.adText === 'string' && s.adText.length <= 200 && typeof config.cityName === 'string' && typeof config.stopName === 'string'; +} diff --git a/src/styles.css b/src/styles.css index a5bb28f..16d8cf6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -515,3 +515,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1 .destination-old { visibility: hidden; } .destination-current { transform: none !important; } } + +/* Device onboarding keeps the same black/lime typography as the board. */ +.device-setup{min-height:100dvh;background:#000;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:clamp(24px,4vw,64px);text-align:center;gap:clamp(22px,4vh,48px);--accent:#d5ff70}.device-setup-brand{display:flex;align-items:center;gap:12px;font-size:32px;font-weight:800;letter-spacing:-.07em}.device-setup-brand>span{display:block;width:48px;color:var(--accent)}.device-setup-brand svg{width:100%;height:auto}.device-setup-card{width:min(850px,100%);border:1px solid #38402a;border-radius:32px;padding:clamp(22px,4vw,52px);background:radial-gradient(ellipse at top,#d5ff7011,transparent 65%)}.device-setup-card h1{font-size:clamp(30px,4.4vw,62px);margin:12px 0 18px;letter-spacing:-.055em}.device-setup-card p{font-size:clamp(18px,2vw,26px);color:#b3b9aa;max-width:640px;margin:0 auto;line-height:1.4}.device-pair-code{display:flex;justify-content:center;font-weight:750;font-size:clamp(64px,min(14vw,18vh),150px);letter-spacing:.08em;margin:22px 0;color:var(--accent)}.device-pair-expiry{display:flex;justify-content:center;align-items:center;gap:10px;font-size:clamp(18px,2vw,24px);color:#b3b9aa}.device-pair-expiry .numeric-text{color:var(--accent)}.device-setup button,.managed-panel button{display:inline-flex;align-items:center;justify-content:center;gap:10px;padding:14px 20px;font-size:18px;border:1px solid #555;border-radius:14px;color:#fff;background:#161616;min-height:50px}.device-setup-card button{margin-top:24px}.device-setup-card a{display:block;margin-top:20px;color:var(--accent);font-size:20px}.device-setup-status{color:#ffd479;margin-top:20px;font-size:18px}.managed-panel{color:var(--text);background:var(--background,#000);border:1px solid var(--border,#555);border-radius:26px;padding:32px;width:min(600px,calc(100% - 32px));max-height:calc(100dvh - 32px);overflow:auto}.managed-panel::backdrop{background:#000a;backdrop-filter:blur(8px)}.managed-panel>svg{color:var(--accent)}.managed-panel h2{font-size:28px;margin:16px 0}.managed-panel strong{font-size:22px;color:var(--accent)}.managed-panel p{font-size:18px;line-height:1.5;margin:16px 0;color:var(--muted,#aaa)}.managed-panel dl{display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:16px;margin:20px 0}.managed-panel>div{display:flex;gap:12px;flex-wrap:wrap}.managed-offline{color:var(--warning);font-size:14px;position:fixed;top:8px;left:50%;transform:translateX(-50%);background:var(--background,#000);padding:6px 12px;border-radius:10px;z-index:3;max-width:90vw;text-align:center}@media(max-height:650px){.device-setup{gap:16px;padding:20px}.device-setup-card{padding:24px}.device-pair-code{margin:14px 0}.device-setup-brand{font-size:26px}.device-setup-brand>span{width:36px}} diff --git a/src/useDeviceControl.ts b/src/useDeviceControl.ts new file mode 100644 index 0000000..a79999f --- /dev/null +++ b/src/useDeviceControl.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Config, Settings } from './types'; +import { isManagedState, MANAGED_KEY } from './lib/device.mjs'; +export type ManagedState = { state: 'paired'; enabled: true; adminUrl: string; config: Config; device: { id: string; name: string; cityId: number; revision: number; settings: Settings } }; +type Control = { state: string; enabled?: boolean; code?: string; expiresAt?: string; adminUrl?: string }; +function cached(): ManagedState | null { try { const value = JSON.parse(localStorage.getItem(MANAGED_KEY) || 'null'); return isManagedState(value) ? value : null; } catch { return null; } } +const info = () => ({ width: innerWidth, height: innerHeight, version: 'board-0.1.0' }); +export function useDeviceControl() { + const [managed, setManaged] = useState(cached), [control, setControl] = useState({ state: 'loading' }); + const [failed, setFailed] = useState(false), [retry, setRetry] = useState(0); + const applied = useRef(0), current = useRef(managed); + current.current = managed; + const onApplied = useCallback((revision: number) => { + applied.current = revision; + try { localStorage.setItem(MANAGED_KEY, JSON.stringify(current.current)); } catch { /* Keep the current screen working even if persistent storage is unavailable. */ } + void fetch('/api/device/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...info(), appliedRevision: revision }) }).catch(() => {}); + }, []); + useEffect(() => { + let stopped = false, timer: ReturnType, controller: AbortController | undefined, lastHeartbeat = 0; + let previousState = '', halted = false, running = false, firstRequest = true; + async function call(path: string, body?: object) { + controller = new AbortController(); const timeout = setTimeout(() => controller?.abort(), 12000); + try { + const response = await fetch(`/api/device/${path}`, { method: body ? 'POST' : 'GET', cache: 'no-store', signal: controller.signal, ...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}) }); + const value = await response.json(); if (!response.ok) throw new Error(value.error || 'devices_unavailable'); return value; + } finally { clearTimeout(timeout); } + } + async function load() { + if (stopped || halted || running) return; + running = true; + let delay = 15000; + try { + let value = await call(firstRequest && retry > 0 ? 'pair' : 'status', firstRequest && retry > 0 ? info() : undefined); + if (stopped) return; + firstRequest = false; + if (value.enabled === false) { halted = true; if (!stopped) { setControl({ state: 'standalone', enabled: false }); setManaged(null); setFailed(false); } return; } + if (value.state === 'unpaired' && previousState === 'pending') throw new Error('storage_unavailable'); + if (['unpaired', 'expired'].includes(value.state)) { if (!stopped) { setManaged(null); applied.current = 0; localStorage.removeItem(MANAGED_KEY); } value = await call('pair', info()); } + if (value.state === 'paired' && !value.config) value = await call('status'); + if (value.state === 'paired') { + if (!isManagedState(value)) throw new Error('invalid_settings'); + if (!stopped) { setManaged(previous => previous && previous.device.id === value.device.id && previous.device.revision === value.device.revision ? previous : value); setControl(value); setFailed(false); } + if (Date.now() - lastHeartbeat >= 30000) { await call('heartbeat', { ...info(), appliedRevision: current.current?.device.id === value.device.id ? applied.current : 0 }); lastHeartbeat = Date.now(); } + } else if (value.state === 'pending') { + if (!/^\d{4}$/.test(value.code) || !Number.isFinite(Date.parse(value.expiresAt))) throw new Error('devices_unavailable'); + delay = 3000; if (!stopped) { setControl(value); setManaged(null); setFailed(false); } + } else throw new Error('devices_unavailable'); + previousState = value.state; + } catch (error) { + if (!stopped) { + const code = error instanceof Error ? error.message : 'devices_unavailable'; + setFailed(true); + if (['device_revoked', 'device_unpaired'].includes(code)) { halted = true; setFailed(false); setManaged(null); applied.current = 0; try { localStorage.removeItem(MANAGED_KEY); } catch { /* No stored copy. */ } setControl({ state: 'revoked' }); return; } + if (code === 'storage_unavailable') halted = true; + if (!current.current) setControl(previous => ({ ...previous, state: code === 'storage_unavailable' ? 'storage-error' : previous.code ? 'pending' : 'error' })); + delay = 30000; + } + } finally { running = false; if (!stopped && !halted) timer = setTimeout(load, delay); } + } + void load(); const online = () => { clearTimeout(timer); void load(); }; window.addEventListener('online', online); + return () => { stopped = true; clearTimeout(timer); controller?.abort(); window.removeEventListener('online', online); }; + }, [retry]); + return { managed, control, failed, onApplied, retry: () => setRetry(value => value + 1) }; +} diff --git a/test/device.test.mjs b/test/device.test.mjs new file mode 100644 index 0000000..7f5b413 --- /dev/null +++ b/test/device.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { isManagedState } from '../src/lib/device.mjs'; +import { createDeviceBridge } from '../server/device.mjs'; +import { createApp } from '../server/index.mjs'; +const source = await readFile(new URL('../src/lib/storage.ts', import.meta.url), 'utf8'); +// Settings used by the actual board; no external project is required for these tests. +const body = source.match(/export const defaultSettings: Settings = ([\s\S]*?);\nexport function/)[1]; +const defaults = Function(`return (${body})`)(); +const value = { state: 'paired', enabled: true, adminUrl: '', device: { id: 'test-device', name: 'Табло', cityId: 2, revision: 1, settings: defaults }, config: { configured: true, cityId: 2, cityName: 'Новополоцк', stopName: 'Остановка', defaultStopId: 1, refreshSeconds: 20, timeZone: 'Europe/Minsk' } }; +test('managed configuration validation rejects damaged, mismatched or unsupported settings', () => { + assert.equal(isManagedState(value), true); + for (const patch of [{ rowCount: 8 }, { demo: true }, { theme: 'sepia' }, { stopId: 2 }, { colorScheme: 'sky' }, { routeFilter: '-1' }, { holdNear: null }]) assert.equal(isManagedState({ ...value, device: { ...value.device, settings: { ...defaults, ...patch } } }), false); + assert.equal(isManagedState({ ...value, config: { ...value.config, refreshSeconds: 0 } }), false); + assert.equal(isManagedState(null), false); +}); +test('pairing bridge keeps secrets in HttpOnly cookies, binds data to the individual device and rejects cross-site writes', async t => { + const tokenA = 'A'.repeat(43), tokenB = 'B'.repeat(43), calls = []; + let revoked = false; + const bridge = createDeviceBridge({ enabled: true, baseUrl: 'https://api.example/', fetchData: async (url, init) => { + calls.push({ path: url.pathname, token: init.headers['X-Board-Token'], body: init.body }); + if (url.pathname.endsWith('/pairings')) return Response.json({ token: tokenA, state: 'pending', code: '0847', expiresAt: new Date(Date.now()+300000).toISOString() }); + if (revoked) return Response.json({ error: 'secret raw upstream' }, { status: 410 }); + if (url.pathname.endsWith('/heartbeat')) return Response.json({ ok: true }); + const cityId = init.headers['X-Board-Token'] === tokenB ? 2 : 1; + return Response.json({ state: 'paired', device: { ...value.device, cityId, cityName: `City ${cityId}`, stopName: 'Stop', settings: { ...defaults, stopId: cityId } } }); + } }); + const factoryCalls = [], factory = ({cityId}) => ({ config: () => ({cityId}), board: async stopId => ({cityId,stop:{id:stopId}}) }); + const defaultClient = { config: () => ({ ...value.config, cityId: 1 }), board: async () => assert.fail('managed requests must use the per-city client') }; + const server = createApp(defaultClient, {}, bridge, options => { factoryCalls.push(options.cityId); return factory(options); }).listen(0,'127.0.0.1'); + await new Promise((r,j)=>{server.once('listening',r);server.once('error',j)});t.after(()=>new Promise(r=>server.close(r))); + const base=`http://127.0.0.1:${server.address().port}`; + const request=(path, opts={})=>fetch(base+path,opts); + assert.equal((await request('/api/board?stopId=1')).status,401); + const pair = await request('/api/device/pair', { method:'POST',headers:{'Content-Type':'application/json',Origin:base},body:'{}' }); + assert.equal(pair.status,200); assert.equal((await pair.json()).token,undefined); + const cookie = pair.headers.get('set-cookie');assert.match(cookie,/HttpOnly/);assert.match(cookie,/SameSite=Strict/); + assert.equal((await request('/api/device/pair',{method:'POST',headers:{'Content-Type':'application/json',Origin:'https://evil.example'},body:'{}'})).status,403); + const a={Cookie:`ridebus_board_device=${tokenA}`},b={Cookie:`ridebus_board_device=${tokenB}`}; + assert.equal((await (await request('/api/device/status',{headers:a})).json()).config.cityId,1); + assert.equal((await (await request('/api/board?stopId=1',{headers:a})).json()).cityId,1); + assert.equal((await (await request('/api/board?stopId=2',{headers:b})).json()).cityId,2); + assert.deepEqual(factoryCalls,[1,2]);assert.equal((await request('/api/board?stopId=2',{headers:a})).status,409); + assert.equal((await request('/api/device/heartbeat',{method:'POST',headers:{...a,'Content-Type':'application/json'},body:'{"appliedRevision":1}'})).status,200); + revoked=true; const result=await request('/api/device/status',{headers:a}); assert.equal(result.status,410);assert.deepEqual(await result.json(),{error:'device_revoked'}); + assert.equal((await request('/api/board?stopId=1',{headers:a})).status,410); + assert.ok(calls.every(c=>!c.path.includes('/admin'))); +}); diff --git a/test/fixtures/device-flow-server.mjs b/test/fixtures/device-flow-server.mjs new file mode 100644 index 0000000..6ec0943 --- /dev/null +++ b/test/fixtures/device-flow-server.mjs @@ -0,0 +1,43 @@ +// Local end-to-end fixture: real device service, router, admin UI and board bridge. +// The database and transport data are explicit test fixtures. Never deploy this file. +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { readFile } from 'node:fs/promises'; +import { createApp } from '../../server/index.mjs'; +import { createDeviceBridge } from '../../server/device.mjs'; +import { createDemo } from '../../src/lib/demo.ts'; +const apiRequire = createRequire(new URL('../../../polotsk-transit-api/backend/package.json', import.meta.url)); +process.env.JWT_SECRET = 'local-board-fixture-only-never-production'; +apiRequire.cache[apiRequire.resolve('./src/config/database.js')] = { exports: { query: async () => ({ rows: [] }) } }; +const express = apiRequire('express'), jwt = apiRequire('jsonwebtoken'); +const { createBoardDevicesRouter } = apiRequire('./src/routes/boardDevices.js'); +const { createBoardDeviceService } = apiRequire('./src/services/boardDeviceService.js'); +const { memoryRepository } = apiRequire('./test/helpers/boardDeviceMemoryRepository.js'); +const repository = memoryRepository(), service = createBoardDeviceService(repository); +const api = express(); api.use(express.json()); +let managementUnavailable = false; +api.post('/__fixture/connection', (req,res) => { managementUnavailable = req.body.offline === true; res.json({ offline: managementUnavailable }); }); +api.use('/api/v1/board-devices', (_req,res,next) => managementUnavailable ? res.status(503).json({error:'fixture_offline'}) : next()); +const user = { id: 1, username: 'board-fixture', full_name: 'Тестовый оператор', role: 'admin' }; +api.post('/api/v1/auth/login', (req, res) => { if(req.body.username !== 'board-fixture' || req.body.password !== 'fixture-only') return res.status(401).json({error:'Тестовый логин: board-fixture / fixture-only'}); res.json({ user, accessToken:jwt.sign(user,process.env.JWT_SECRET,{expiresIn:'1h'}),refreshToken:'local-fixture-refresh' }); }); +api.get('/api/v1/auth/me', (_req,res)=>res.json(user)); +api.post('/api/v1/auth/logout', (_req,res)=>res.json({ok:true})); +api.use('/api/v1/board-devices', createBoardDevicesRouter(service, { cityMiddleware: (req,res,next) => { const id=Number(req.get('X-City-Id')||1);if(![1,2].includes(id))return res.status(404).json({error:'City not found'});req.city={id};next(); } })); +api.get('/api/v1/cities', (_req,res)=>res.json([{id:1,name:'Полоцк',slug:'polotsk',emblem:'🏛️'},{id:2,name:'Новополоцк',slug:'novopolotsk',emblem:'🏙️'}])); +api.get('/api/v1/stops', (req,res)=>res.json(repository.stops.filter(s=>s.city_id===Number(req.get('X-City-Id')||1)))); +api.get('/api/v1/routes', (req,res)=>res.json(repository.routesData.filter(s=>s.city_id===Number(req.get('X-City-Id')||1)))); +api.get('/api/v1/alerts/active',(_req,res)=>res.json([]));api.get('/api/v1/holidays/upcoming',(_req,res)=>res.json([]));api.get('/api/v1/sync/status',(_req,res)=>res.json({status:'ready',database:{schedules_count:2}})); +const adminDist=fileURLToPath(new URL('../../../polotsk-transit-api/frontend/dist/',import.meta.url)); +api.use(express.static(adminDist));api.get('*',async(_req,res)=>res.type('html').send((await readFile(`${adminDist}/index.html`,'utf8')).replace('','
Тестовый стенд · без рабочей БД
'))); +await new Promise(r=>api.listen(5190,'127.0.0.1',r)); +const makeClient=({cityId=1,cityName='Полоцк',defaultStopId=1,stopName='Швейная фабрика'}={})=>({ + config:()=>({cityId,cityName,defaultStopId,stopName,configured:true,refreshSeconds:20,timeZone:'Europe/Minsk',passengerBaseUrl:'https://pwa.ridebus.by/'}), + stops:async()=>repository.stops.filter(s=>s.city_id===cityId), + board:async stopId=>({...createDemo(),cityId,cityName,stop:{id:stopId,name:repository.stops.find(s=>s.id===stopId)?.name||stopName,direction:'Тестовые данные'},mode:'live'}), + timetable:async()=>{throw new Error('no timetable in fixture');},coordinates:async()=>null, +}); +const bridge=createDeviceBridge({enabled:true,baseUrl:'http://127.0.0.1:5190/',adminUrl:'http://127.0.0.1:5190/devices'}); +const board=createApp(makeClient(),{weather:async()=>{throw new Error('no weather in fixture');}},bridge,makeClient); +const boardDist=fileURLToPath(new URL('../../dist/',import.meta.url));board.use(express.static(boardDist));board.get('/{*path}',(_req,res)=>res.sendFile(`${boardDist}/index.html`)); +await new Promise(r=>board.listen(5191,'127.0.0.1',r)); +console.log('Device flow fixture: admin http://127.0.0.1:5190/devices; board http://127.0.0.1:5191/');