diff --git a/.env.example b/.env.example index 172aa19..18b2fb4 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest -PULSE_SIGNAGE_SHARED_SECRET=9f4c2d8b7a1e4c0f8b2d6a9e1c7f3b5d4a8e6c1f0b9d7a3c5e2f1a6b8d4c0e7 +PULSE_SIGNAGE_SHARED_SECRET= DB_HOST=mysql DB_PORT=3306 @@ -8,11 +8,10 @@ DB_USER=pulse-signage DB_PASSWORD=signage_password MYSQL_ROOT_PASSWORD=root_password -PLAYER_INTERNAL_BASE_URL=http://player:8081 PLAYER_PUBLIC_BASE_URL=http://localhost:8081 +PLAYER_INTERNAL_BASE_URL=http://player:8081 SESSION_MAX_AGE_DAYS=14 -DASHBOARD_REFRESH_INTERVAL_MS=2000 DEFAULT_ADMIN_USERNAME=admin DEFAULT_ADMIN_NAME=Admin DEFAULT_ADMIN_PASSWORD=admin diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml similarity index 100% rename from .gitea/ISSUE_TEMPLATE/bug_report.yml rename to .github/ISSUE_TEMPLATE/bug_report.yml diff --git a/.gitea/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml similarity index 100% rename from .gitea/ISSUE_TEMPLATE/config.yml rename to .github/ISSUE_TEMPLATE/config.yml diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml similarity index 100% rename from .gitea/ISSUE_TEMPLATE/feature_request.yml rename to .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..387e36a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,14 @@ +# Repository Instructions + +## Versioning and releases + +- Treat `package.json` as the source of truth for the application version. +- When the app version changes, update `CHANGELOG.md` in the same change. +- Keep database migration versions aligned with the release they actually belong to. +- If only part of a migration batch belongs to a newer release, split that batch into a separate migration entry instead of relabeling the earlier release. +- Do not bump migration versions just because the app version changed. + +## Before finishing a versioned change + +- Confirm the package version, changelog entry, and migration tags are consistent. +- If a release note mentions a feature or fix, make sure the matching code path or migration block is tagged to the same release. \ No newline at end of file diff --git a/.gitea/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml similarity index 100% rename from .gitea/workflows/docker-publish.yml rename to .github/workflows/docker-publish.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c9f62c..3e2a385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,66 @@ All notable changes to this project will be documented in this file. +## 2.2.0 - 2026-08-01 + +### Added + +- Announcement management is now wired for the admin UI and player overlay, with color, template, and expiry controls. +- Time / Date regions now render live in the editor and player. + +### Changed + +- Announcement lists now default-sort by Description and show the affected screens for each announcement. +- Role add and edit pages now include a Cancel action alongside the save buttons. +- Announcement publishing now only targets explicitly assigned screens; unassigned announcements no longer publish globally. + +### Fixed + +- Announcement screen labels now correctly distinguish No Screens, partial assignments, and All Screens. +- Role duplicate-name validation on edit now checks the correct RBAC table. + +## 2.1.1 - 2026-08-01 + +### Added + +- API source latest-response viewing now supports collapsing and expanding all JSON sections from the edit page. +- Placeholder transforms now support basic string helpers like `.upper()`, `.title()`, and `.lower()`. + +### Changed + +- The API source add page now matches the edit page’s section titles and layout more closely. +- API source placeholder suggestions now stay leaf-only, so container paths and arrays are no longer suggested as placeholders. +- Slide editor and player placeholder resolution now share the same parsing and transform logic. +- API source and RSS editor helper text now shows the new placeholder transform syntax. +- API sources, RSS feeds, slides, users, and roles now render their delete actions in a disabled outlined-danger state when deletion is blocked, matching the rest of the admin tables. +- The users edit page no longer wraps all sections in one outer card, so the profile, password, and roles cards render as separate sections. + +### Fixed + +- API source item-path overrides continue to treat the final path as an array source, while nested object fields remain available for placeholders. +- The API source latest-response panel now opens as a tree view by default instead of a flat JSON blob. + +## 2.1.0 - 2026-07-30 + +### Added + +- Screen and dashboard player links now render the full player URL instead of only the player base host. + +### Changed + +- The player registry now uses a singleton `d_players` row and `d_screens.player_id` points at that shared player record. +- Player startup is now responsible for creating the shared player record, while onboarding and state polling no longer create extra player rows. +- The screen/player binding path was simplified so opening a screen binds it to the shared player record without trying to re-register the player. +- Added notes near the singleton-player code paths to make the future multi-player migration work easier to revisit. +- Managed fonts now have dedicated editor/player sync handling, including a scheduled font sweep, sorted font-family lists, the TinyMCE fullscreen button, and shared font stylesheet loading in the WYSIWYG editor. +- Text and RSS region types now own their own default style and fallback behavior instead of relying on shared slide helper fallbacks. +- Image and video region handling was also pushed further into modular per-type modules, keeping their editor, preview, and player logic closer to the region itself instead of the shared slide helper layer. + +### Fixed + +- Removed several onboarding and runtime code paths that were still inserting or refreshing `d_players` rows during normal player playback. +- Fixed the screen-binding flow so it no longer writes a device UUID into `d_screens.player_id`, which avoided foreign key errors under the shared-player model. + ## 2.0.0 - 2026-07-28 ### Breaking Changes diff --git a/README.md b/README.md index 1029083..efba43c 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,6 @@ The app reads its settings from environment variables. The list below matches th ### Web App - `SESSION_MAX_AGE_DAYS` - admin session lifetime in days, default `14` -- `DASHBOARD_REFRESH_INTERVAL_MS` - dashboard refresh interval in milliseconds, default `2000` - `DEFAULT_ADMIN_USERNAME` - username used for the initial admin account, default `admin` - `DEFAULT_ADMIN_NAME` - display name used for the initial admin account, default `Admin` - `DEFAULT_ADMIN_PASSWORD` - password used for the initial admin account, default `admin` diff --git a/docker-compose.yml b/docker-compose.yml index 50ad355..e4146d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,6 @@ services: DB_NAME: ${DB_NAME:-signage} DB_USER: ${DB_USER:-signage_user} DB_PASSWORD: ${DB_PASSWORD:-signage_password} - PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:3001} - PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14} DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000} diff --git a/docs/schema.md b/docs/schema.md index 30e644c..cd7ec33 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -47,7 +47,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ### `a_sessions` -- `session_hash`, `user_id`, `expires_at`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `session_hash`, `user_id`, `expires_at`, `created_at`, `created_by`, `last_used_at`, `modified_by` - `session_hash` is the primary key. - Foreign key: - `user_id` -> `a_users.id` @@ -94,6 +94,17 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ - Foreign keys: - `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE` - `slide_id` -> `c_slides.id` with `ON DELETE CASCADE` + +## Devices + +- `d_players` - player registry and connection metadata. +- `d_screens` - screen records and playlist/player assignment. +- `d_onboarding_devices` - device-to-screen bindings and onboarded client names. + +### `d_players` + +- `device_id`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at` +- `device_id` is the primary key. ### `d_screens` @@ -101,11 +112,9 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ - `slug` is unique. - Foreign keys: - `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL` - - `player_id` -> `d_players.id` with `ON DELETE SET NULL` + - `player_id` -> `d_players.id` with `ON DELETE RESTRICT` -## Onboarding - -- `d_onboarding_devices` - device-to-screen bindings and onboarded client names. +- `player_id` is required and defaults to `'1'` for the current singleton-player model. ### `d_onboarding_devices` @@ -114,6 +123,10 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ - Foreign key: - `screen_id` -> `d_screens.id` with `ON DELETE SET NULL` +## Onboarding + +- The onboarding flow uses `d_onboarding_devices` to bind a device to a screen and persist the client name. + ## Integrations - `i_rss_feeds` - RSS feed definitions and refresh cadence. @@ -134,7 +147,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ### `i_api_sources` -- `id`, `name`, `api_url`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `id`, `name`, `api_url`, `auth_method`, `auth_username`, `auth_password`, `auth_bearer_token`, `auth_header_name`, `auth_header_value`, `items_path`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by` ## Operations diff --git a/docs/websocket.md b/docs/websocket.md index 4d0bb07..682f484 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -24,6 +24,13 @@ This is the server-to-dashboard snapshot stream for live player connection state Access: internal-only. The web backend subscribes with signed request headers; browsers should not connect directly. +### `GET /ws/screens/{slug}/announcements` +Player announcement channel. + +This is the server-to-player push channel used to wake the player when an announcement changes. The browser player reconnects automatically and refreshes its announcement state when it receives an `announcement-refresh` message. + +Access: the player page must include a valid `auth` query parameter signed with the shared secret. + ## Player Control Channel ### Messages from player to server @@ -113,6 +120,26 @@ Moves to the next slide. Updates the client name associated with the player session and onboarding record. The command payload should include `clientName` and may include `deviceId` when the caller is updating a specific onboarding binding. +## Announcement Channel + +### Messages from server to player + +The server sends a lightweight refresh notification. + +#### `announcement-refresh` + +Example payload: + +```json +{ + "type": "announcement-refresh", + "slug": "demo", + "sentAt": "2026-08-01T12:00:00.000Z" +} +``` + +When the player receives this message, it refetches the current announcement state over HTTP. + ## Snapshot Channel ### Messages from server to client diff --git a/package-lock.json b/package-lock.json index e17e279..dc59762 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index b19c4f2..921e9ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.0.0", + "version": "2.2.0", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/auth.js b/src/auth.js index 1b53a10..882689a 100644 --- a/src/auth.js +++ b/src/auth.js @@ -1,3 +1,5 @@ +// Password hashing and session token helpers. + const crypto = require('crypto'); const PASSWORD_ITERATIONS = Number(process.env.PASSWORD_HASH_ITERATIONS || 310000); diff --git a/src/common.js b/src/common.js index 9c2af50..f53fd6d 100644 --- a/src/common.js +++ b/src/common.js @@ -1,20 +1,32 @@ -const dbCommon = require('./db/common'); -const db = require('./db'); -const dbBootstrap = require('./db/bootstrap'); -const data = require('./data'); -const player = require('./player/render'); +// Module-resolution shims for the app's `#root` and `#src` aliases. -function getSearchQuery(req) { - return String(req && req.query && req.query.search || '').trim(); -} +const Module = require('module'); +const path = require('path'); -function getSortQuery(req) { - return String(req && req.query && req.query.sort || '').trim(); -} +const rootDir = path.resolve(__dirname, '..'); +const srcDir = __dirname; +const resolveFilename = Module._resolveFilename; -function getSortDirectionQuery(req) { - return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; -} +Module._resolveFilename = function (request, parent, isMain, options) { + if (request === '#root') { + request = rootDir; + } else if (request.startsWith('#root/')) { + request = path.join(rootDir, request.slice('#root/'.length)); + } else if (request === '#src') { + request = srcDir; + } else if (request.startsWith('#src/')) { + request = path.join(srcDir, request.slice('#src/'.length)); + } + + return resolveFilename.call(this, request, parent, isMain, options); +}; + +const dbCommon = require('#src/db/common'); +const db = require('#src/db'); +const dbBootstrap = require('#src/db/bootstrap'); +const data = require('#src/data'); +const player = require('#src/player/render'); +const listQuery = require('#src/web/lib/list-query'); module.exports = { createPool: dbCommon.createPool, @@ -30,9 +42,22 @@ module.exports = { fetchTemplatesPage: data.fetchTemplatesPage, fetchCanvasSizesPage: data.fetchCanvasSizesPage, fetchScreensPage: data.fetchScreensPage, - getSearchQuery: getSearchQuery, - getSortQuery: getSortQuery, - getSortDirectionQuery: getSortDirectionQuery, + ANNOUNCEMENT_TYPES: data.ANNOUNCEMENT_TYPES, + ANNOUNCEMENT_COLORS: data.ANNOUNCEMENT_COLORS, + ANNOUNCEMENT_ICONS: data.ANNOUNCEMENT_ICONS, + ANNOUNCEMENT_ICON_OPTIONS: data.ANNOUNCEMENT_ICON_OPTIONS, + ANNOUNCEMENT_ICON_LABELS: data.ANNOUNCEMENT_ICON_LABELS, + DEFAULT_ANNOUNCEMENT_ICON: data.DEFAULT_ANNOUNCEMENT_ICON, + normalizeAnnouncementType: data.normalizeAnnouncementType, + normalizeAnnouncementColor: data.normalizeAnnouncementColor, + normalizeAnnouncementIcon: data.normalizeAnnouncementIcon, + fetchAnnouncementsPage: data.fetchAnnouncementsPage, + fetchAnnouncementById: data.fetchAnnouncementById, + fetchActiveAnnouncement: data.fetchActiveAnnouncement, + buildAnnouncementPayload: data.buildAnnouncementPayload, + getSearchQuery: listQuery.getSearchQuery, + getSortQuery: listQuery.getSortQuery, + getSortDirectionQuery: listQuery.getSortDirectionQuery, fetchPlaylistById: data.fetchPlaylistById, fetchApiSourcesData: data.fetchApiSourcesData, fetchApiSourcesPage: data.fetchApiSourcesPage, @@ -49,6 +74,8 @@ module.exports = { replaceRssFeedItems: data.replaceRssFeedItems, fetchScreenById: data.fetchScreenById, fetchScreenEditData: data.fetchScreenEditData, + fetchScreenPlayerUrls: data.fetchScreenPlayerUrls, + fetchScreenPlayerRecord: data.fetchScreenPlayerRecord, fetchTemplateById: data.fetchTemplateById, fetchSlideById: data.fetchSlideById, fetchTemplatesData: data.fetchTemplatesData, diff --git a/src/data/admin.js b/src/data/admin.js index 422e10e..83ea93d 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -1,3 +1,5 @@ +// Admin data aggregation helpers for dashboards, playlists, slides, templates, and screens. + const { fetchPagedRows } = require('./utils'); async function fetchAdminData(pool) { @@ -10,7 +12,7 @@ async function fetchAdminData(pool) { LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY st.id DESC `); - const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); + const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); const [slides] = await pool.query(` SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height, (SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count @@ -39,7 +41,8 @@ async function fetchAdminData(pool) { async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by, - (SELECT COUNT(*) FROM c_playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count + (SELECT COUNT(*) FROM c_playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count, + (SELECT COUNT(*) FROM d_screens s WHERE s.playlist_id = p.id) AS screen_count FROM c_playlists p ORDER BY p.id DESC`, countSql: 'SELECT COUNT(*) AS count FROM c_playlists', diff --git a/src/data/announcement-icons.js b/src/data/announcement-icons.js new file mode 100644 index 0000000..0801d1d --- /dev/null +++ b/src/data/announcement-icons.js @@ -0,0 +1,74 @@ +const ANNOUNCEMENT_ICON_OPTIONS = [ + { value: 'megaphone-fill', label: 'Megaphone' }, + { value: 'megaphone', label: 'Megaphone outline' }, + { value: 'bell-fill', label: 'Bell' }, + { value: 'bell', label: 'Bell outline' }, + { value: 'exclamation-triangle-fill', label: 'Warning' }, + { value: 'exclamation-triangle', label: 'Warning outline' }, + { value: 'info-circle-fill', label: 'Info' }, + { value: 'info-circle', label: 'Info outline' }, + { value: 'check-circle-fill', label: 'Success' }, + { value: 'check-circle', label: 'Success outline' }, + { value: 'lightbulb-fill', label: 'Idea' }, + { value: 'lightbulb', label: 'Idea outline' }, + { value: 'calendar-event-fill', label: 'Calendar' }, + { value: 'calendar-event', label: 'Calendar outline' }, + { value: 'clock-fill', label: 'Clock' }, + { value: 'clock', label: 'Clock outline' }, + { value: 'wifi-off', label: 'Wi-Fi Offline' }, + { value: 'wifi', label: 'Wi-Fi' }, + { value: 'hdd-network', label: 'Network' }, + { value: 'hdd-network-fill', label: 'Network fill' }, + { value: 'speaker-fill', label: 'Speaker' }, + { value: 'speaker', label: 'Speaker outline' }, + { value: 'shield-fill', label: 'Shield' }, + { value: 'shield', label: 'Shield outline' }, + { value: 'collection-play-fill', label: 'Playlist' }, + { value: 'collection-play', label: 'Playlist outline' }, + { value: 'broadcast', label: 'Broadcast' }, + { value: 'broadcast-pin', label: 'Broadcast pin' }, + { value: 'plug-fill', label: 'Plug' }, + { value: 'plug', label: 'Plug outline' }, + { value: 'lightning-charge-fill', label: 'Urgent' }, + { value: 'lightning-charge', label: 'Urgent outline' }, + { value: 'car-front-fill', label: 'Car Front' }, + { value: 'car-front', label: 'Car Front outline' }, + { value: 'lamp-fill', label: 'Lamp' }, + { value: 'lamp', label: 'Lamp outline' }, + { value: 'envelope-fill', label: 'Message' }, + { value: 'envelope', label: 'Message outline' }, + { value: 'people-fill', label: 'Audience' }, + { value: 'people', label: 'Audience outline' }, + { value: 'browser-chrome', label: 'Browser Chrome' }, + { value: 'browser-edge', label: 'Browser Edge' }, + { value: 'browser-firefox', label: 'Browser Firefox' }, + { value: 'browser-safari', label: 'Browser Safari' }, + { value: 'cone', label: 'Cone' }, + { value: 'cone-striped', label: 'Cone striped' }, + { value: 'cup-straw', label: 'Cup straw' }, + { value: 'fire', label: 'Fire' } +]; + +const ANNOUNCEMENT_ICON_KEYS = ANNOUNCEMENT_ICON_OPTIONS.map(function (option) { + return option.value; +}); + +const ANNOUNCEMENT_ICON_LABELS = ANNOUNCEMENT_ICON_OPTIONS.reduce(function (labels, option) { + labels[option.value] = option.label; + return labels; +}, Object.create(null)); + +const DEFAULT_ANNOUNCEMENT_ICON = 'megaphone-fill'; + +function normalizeAnnouncementIcon(value) { + const normalized = String(value || '').trim().toLowerCase(); + return ANNOUNCEMENT_ICON_KEYS.includes(normalized) ? normalized : DEFAULT_ANNOUNCEMENT_ICON; +} + +module.exports = { + ANNOUNCEMENT_ICON_OPTIONS, + ANNOUNCEMENT_ICON_KEYS, + ANNOUNCEMENT_ICON_LABELS, + DEFAULT_ANNOUNCEMENT_ICON, + normalizeAnnouncementIcon +}; \ No newline at end of file diff --git a/src/data/announcements.js b/src/data/announcements.js new file mode 100644 index 0000000..786b362 --- /dev/null +++ b/src/data/announcements.js @@ -0,0 +1,189 @@ +// Announcement data access helpers. + +const { fetchPagedRows } = require('./utils'); +const { + ANNOUNCEMENT_ICON_KEYS, + DEFAULT_ANNOUNCEMENT_ICON, + normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig +} = require('./announcement-icons'); + +const ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner']; +const ANNOUNCEMENT_COLORS = ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light']; + +function normalizeAnnouncementType(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'center-card') { + return 'fullscreen'; + } + return ANNOUNCEMENT_TYPES.includes(normalized) ? normalized : 'lower-third'; +} + +function normalizeAnnouncementColor(value) { + const normalized = String(value || '').trim().toLowerCase(); + return ANNOUNCEMENT_COLORS.includes(normalized) ? normalized : 'primary'; +} + +function normalizeAnnouncementMessage(value) { + return String(value || '').replace(/\r\n/g, '\n').trim(); +} + +function normalizeAnnouncementShortLabel(value) { + return String(value || '').replace(/\r\n/g, ' ').trim(); +} + +function normalizeAnnouncementDurationSeconds(value) { + const seconds = Math.max(0, Math.round(Number(value) || 0)); + return seconds > 0 ? seconds : null; +} + +function normalizeAnnouncementScreenIds(value) { + if (Array.isArray(value)) { + return value.map(function (screenId) { + return Number(screenId) || 0; + }).filter(function (screenId) { + return screenId > 0; + }); + } + + return String(value || '') + .split(',') + .map(function (screenId) { + return Number(screenId) || 0; + }) + .filter(function (screenId) { + return screenId > 0; + }); +} + +function buildAnnouncementPayload(input) { + const message = normalizeAnnouncementMessage(input && input.message); + const shortLabel = normalizeAnnouncementShortLabel(input && input.short_label); + const announcementType = normalizeAnnouncementType(input && input.announcement_type); + const colorKey = normalizeAnnouncementColor(input && input.color_key); + const iconKey = normalizeAnnouncementIconFromConfig(input && input.icon_key); + const durationSeconds = normalizeAnnouncementDurationSeconds(input && input.duration_seconds); + + return { + message, + shortLabel, + announcementType, + colorKey, + iconKey, + durationSeconds, + expiresAt: durationSeconds ? new Date(Date.now() + (durationSeconds * 1000)) : null + }; +} + +function normalizeAnnouncementRow(row) { + if (!row) { + return null; + } + + return { + id: Number(row.id) || null, + message: normalizeAnnouncementMessage(row.message), + short_label: normalizeAnnouncementShortLabel(row.short_label), + announcement_type: normalizeAnnouncementType(row.announcement_type), + color_key: normalizeAnnouncementColor(row.color_key), + icon_key: normalizeAnnouncementIconFromConfig(row.icon_key), + duration_seconds: row.duration_seconds === null || row.duration_seconds === undefined + ? null + : Math.max(0, Math.round(Number(row.duration_seconds) || 0)), + expires_at: row.expires_at || null, + screen_target_count: row.screen_target_count === null || row.screen_target_count === undefined + ? null + : Math.max(0, Math.round(Number(row.screen_target_count) || 0)), + total_screen_count: row.total_screen_count === null || row.total_screen_count === undefined + ? null + : Math.max(0, Math.round(Number(row.total_screen_count) || 0)), + screen_targets_label: String(row.screen_targets_label || '').trim(), + screen_ids: normalizeAnnouncementScreenIds(row.screen_ids), + created_at: row.created_at || null, + modified_at: row.modified_at || null, + created_by: row.created_by || null, + modified_by: row.modified_by || null + }; +} + +async function fetchAnnouncementById(pool, id) { + const [rows] = await pool.query(` + SELECT a.id, a.message, a.short_label, a.announcement_type, a.color_key, a.icon_key, a.duration_seconds, a.expires_at, a.created_at, a.modified_at, a.created_by, a.modified_by, + GROUP_CONCAT(DISTINCT aas.screen_id ORDER BY aas.screen_id SEPARATOR ',') AS screen_ids + FROM d_announcements a + LEFT JOIN d_announcement_screens aas ON aas.announcement_id = a.id + WHERE a.id = ? + GROUP BY a.id + `, [id]); + return normalizeAnnouncementRow(rows[0] || null); +} + +async function fetchAnnouncementsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { + const paged = await fetchPagedRows(pool, { + selectSql: `SELECT a.id, a.message, a.short_label, a.announcement_type, a.color_key, a.icon_key, a.duration_seconds, a.expires_at, a.created_at, a.modified_at, a.created_by, a.modified_by, + COUNT(DISTINCT aas.screen_id) AS screen_target_count, + (SELECT COUNT(*) FROM d_screens) AS total_screen_count, + COALESCE( + GROUP_CONCAT(DISTINCT COALESCE(NULLIF(TRIM(s.name), ''), s.slug) ORDER BY COALESCE(NULLIF(TRIM(s.name), ''), s.slug) SEPARATOR ', '), + '' + ) AS screen_targets_label + FROM d_announcements a + LEFT JOIN d_announcement_screens aas ON aas.announcement_id = a.id + LEFT JOIN d_screens s ON s.id = aas.screen_id + GROUP BY a.id`, + countSql: 'SELECT COUNT(*) AS count FROM d_announcements', + searchColumns: ['message', 'short_label', 'announcement_type', 'color_key'], + searchTerm: searchTerm, + sortColumns: { + description: 'short_label', + message: 'message', + type: 'announcement_type', + color: 'color_key', + status: 'expires_at', + expires: 'expires_at', + created: 'created_at', + modified: 'modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, + page: page, + pageSize: pageSize + }); + + return Object.assign({ announcements: (paged.rows || []).map(normalizeAnnouncementRow) }, paged); +} + +async function fetchActiveAnnouncement(pool, screenSlug) { + const [rows] = await pool.query(` + SELECT a.id, a.message, a.short_label, a.announcement_type, a.color_key, a.icon_key, a.duration_seconds, a.expires_at, a.created_at, a.modified_at, a.created_by, a.modified_by, + GROUP_CONCAT(DISTINCT aas.screen_id ORDER BY aas.screen_id SEPARATOR ',') AS screen_ids + FROM d_announcements a + LEFT JOIN d_announcement_screens aas ON aas.announcement_id = a.id + WHERE (a.expires_at IS NULL OR a.expires_at > CURRENT_TIMESTAMP) + AND EXISTS ( + SELECT 1 + FROM d_announcement_screens restriction + JOIN d_screens screen ON screen.id = restriction.screen_id + WHERE restriction.announcement_id = a.id + AND screen.slug = ? + ) + GROUP BY a.id + ORDER BY a.modified_at DESC, a.created_at DESC, a.id DESC + LIMIT 1 + `, [screenSlug]); + return normalizeAnnouncementRow(rows[0] || null); +} + +module.exports = { + ANNOUNCEMENT_TYPES, + ANNOUNCEMENT_COLORS, + ANNOUNCEMENT_ICONS: ANNOUNCEMENT_ICON_KEYS, + DEFAULT_ANNOUNCEMENT_ICON, + normalizeAnnouncementType, + normalizeAnnouncementColor, + normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig, + normalizeAnnouncementShortLabel, + buildAnnouncementPayload, + fetchAnnouncementById, + fetchAnnouncementsPage, + fetchActiveAnnouncement +}; \ No newline at end of file diff --git a/src/data/api-sources.js b/src/data/api-sources.js index 747a9fe..1c05237 100644 --- a/src/data/api-sources.js +++ b/src/data/api-sources.js @@ -1,3 +1,5 @@ +// API source data access, pagination, and remote fetch helpers. + const http = require('http'); const https = require('https'); const { fetchPagedRows } = require('./utils'); @@ -7,9 +9,44 @@ function normalizeUpdateIntervalUnit(value) { return unit === 'seconds' ? 'seconds' : 'minutes'; } +function normalizeAuthMethod(value) { + const method = String(value || '').trim().toLowerCase(); + return ['basic', 'bearer', 'api_key_header'].includes(method) ? method : 'none'; +} + +function getItemsPath(source) { + return String(source && (source.items_path || source.itemsPath) || '').trim(); +} + +function buildAuthHeaders(source) { + const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod)); + const headers = {}; + + if (method === 'basic') { + const username = String(source && (source.auth_username || source.authUsername) || '').trim(); + const password = String(source && (source.auth_password || source.authPassword) || ''); + if (username || password) { + headers.Authorization = 'Basic ' + Buffer.from(username + ':' + password, 'utf8').toString('base64'); + } + } else if (method === 'bearer') { + const token = String(source && (source.auth_bearer_token || source.authBearerToken) || '').trim(); + if (token) { + headers.Authorization = 'Bearer ' + token; + } + } else if (method === 'api_key_header') { + const headerName = String(source && (source.auth_header_name || source.authHeaderName) || 'X-API-Key').trim() || 'X-API-Key'; + const headerValue = String(source && (source.auth_header_value || source.authHeaderValue) || '').trim(); + if (headerValue) { + headers[headerName] = headerValue; + } + } + + return headers; +} + async function fetchApiSourcesData(pool) { const [apiSources] = await pool.query( - 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC' + 'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC' ); return { apiSources: apiSources }; @@ -17,7 +54,7 @@ async function fetchApiSourcesData(pool) { async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC', + selectSql: 'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC', countSql: 'SELECT COUNT(*) AS count FROM i_api_sources', searchColumns: ['name', 'api_url', 'last_pull_error'], searchTerm: searchTerm, @@ -41,20 +78,21 @@ async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, so async function fetchApiSourceById(pool, id) { const [rows] = await pool.query( - 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?', + 'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?', [id] ); return rows[0] || null; } -async function loadUrlText(urlValue) { +async function loadUrlText(urlValue, requestOptions) { + const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {}; if (typeof fetch === 'function') { const response = await fetch(urlValue, { - headers: { + headers: Object.assign({ Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', 'User-Agent': 'Pulse Signage API Reader' - } + }, extraHeaders) }); return { @@ -68,12 +106,13 @@ async function loadUrlText(urlValue) { return await new Promise(function (resolve, reject) { const url = new URL(urlValue); const transport = url.protocol === 'https:' ? https : http; - const request = transport.get(url, { - headers: { - Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', - 'User-Agent': 'Pulse Signage API Reader' - } - }, function (response) { + const requestHeaders = Object.assign({ + Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', + 'User-Agent': 'Pulse Signage API Reader' + }, extraHeaders); + const request = transport.get(url, Object.assign({}, requestOptions || {}, { + headers: requestHeaders + }), function (response) { response.setEncoding('utf8'); let body = ''; response.on('data', function (chunk) { @@ -94,8 +133,11 @@ async function loadUrlText(urlValue) { }); } -async function fetchApiSourceResponse(apiUrl) { - const response = await loadUrlText(apiUrl); +async function fetchApiSourceResponse(apiSource) { + const source = apiSource && typeof apiSource === 'object' ? apiSource : { api_url: apiSource }; + const response = await loadUrlText(source.api_url, { + headers: buildAuthHeaders(source) + }); if (!response.ok) { throw new Error(`Unable to load API response (${response.statusCode}).`); } @@ -121,8 +163,23 @@ async function fetchApiSourceResponse(apiUrl) { function buildApiSourcePayload(req, existingApiSource) { const fallback = existingApiSource || {}; + const body = req && req.body ? req.body : {}; + function readBodyValue(fieldName, fallbackValue) { + if (Object.prototype.hasOwnProperty.call(body, fieldName)) { + return body[fieldName]; + } + return fallbackValue; + } + const name = String(req.body.name || fallback.name || '').trim(); const apiUrl = String(req.body.api_url || req.body.apiUrl || fallback.api_url || '').trim(); + const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none'))); + const authUsername = String(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '').trim(); + const authPassword = String(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || ''); + const authBearerToken = String(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '').trim(); + const authHeaderName = String(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key').trim() || 'X-API-Key'; + const authHeaderValue = String(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '').trim(); + const itemsPath = String(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '').trim(); const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60)); const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes'); @@ -159,9 +216,34 @@ function buildApiSourcePayload(req, existingApiSource) { throw error; } + if (authMethod === 'basic' && !authUsername && !authPassword) { + const error = new Error('Basic auth requires a username or password.'); + error.statusCode = 400; + throw error; + } + + if (authMethod === 'bearer' && !authBearerToken) { + const error = new Error('Bearer auth requires a token.'); + error.statusCode = 400; + throw error; + } + + if (authMethod === 'api_key_header' && !authHeaderValue) { + const error = new Error('API key auth requires a header value.'); + error.statusCode = 400; + throw error; + } + return { name: name, apiUrl: parsedUrl.toString(), + authMethod: authMethod, + authUsername: authUsername, + authPassword: authPassword, + authBearerToken: authBearerToken, + authHeaderName: authHeaderName, + authHeaderValue: authHeaderValue, + itemsPath: itemsPath, updateIntervalValue: Math.floor(updateIntervalValue), updateIntervalUnit: updateIntervalUnit }; diff --git a/src/data/canvas-sizes.js b/src/data/canvas-sizes.js index d40845b..47d5584 100644 --- a/src/data/canvas-sizes.js +++ b/src/data/canvas-sizes.js @@ -1,3 +1,5 @@ +// Canvas size data access and pagination helpers. + const { fetchPagedRows } = require('./utils'); async function fetchCanvasSizesData(pool) { diff --git a/src/data/client-name-check.js b/src/data/client-name-check.js index 40f0634..531c122 100644 --- a/src/data/client-name-check.js +++ b/src/data/client-name-check.js @@ -1,3 +1,5 @@ +// Client name reservation helpers for onboarding and live connection checks. + const crypto = require('crypto'); function normalizeClientName(value) { diff --git a/src/data/index.js b/src/data/index.js index 372d481..c34161c 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -1,8 +1,12 @@ +// Aggregated exports for the shared data access layer. + const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin'); +const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements'); +const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons'); const { fetchPlaylistById } = require('./playlists'); const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); -const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens'); +const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens'); const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates'); const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes'); const { fetchSlideById, buildSlidePayload } = require('./slides'); @@ -18,6 +22,19 @@ module.exports = { fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage, + ANNOUNCEMENT_TYPES, + ANNOUNCEMENT_COLORS, + ANNOUNCEMENT_ICONS, + ANNOUNCEMENT_ICON_OPTIONS, + ANNOUNCEMENT_ICON_LABELS, + DEFAULT_ANNOUNCEMENT_ICON, + normalizeAnnouncementType, + normalizeAnnouncementColor, + normalizeAnnouncementIcon, + fetchAnnouncementsPage, + fetchAnnouncementById, + fetchActiveAnnouncement, + buildAnnouncementPayload, fetchPlaylistById, fetchApiSourcesData, fetchApiSourcesPage, @@ -34,6 +51,8 @@ module.exports = { replaceRssFeedItems, fetchScreenById, fetchScreenEditData, + fetchScreenPlayerUrls, + fetchScreenPlayerRecord, fetchTemplateById, fetchSlideById, fetchTemplatesData, diff --git a/src/data/playlists.js b/src/data/playlists.js index b187862..444cbe0 100644 --- a/src/data/playlists.js +++ b/src/data/playlists.js @@ -1,3 +1,5 @@ +// Playlist data access helpers. + async function fetchPlaylistById(pool, id) { const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]); return rows[0] || null; diff --git a/src/data/rss-feeds.js b/src/data/rss-feeds.js index a1e2f4c..7d85d67 100644 --- a/src/data/rss-feeds.js +++ b/src/data/rss-feeds.js @@ -1,3 +1,5 @@ +// RSS feed data access, pagination, and item normalization helpers. + const http = require('http'); const https = require('https'); const { fetchPagedRows } = require('./utils'); @@ -50,7 +52,7 @@ async function fetchRssFeedById(pool, id) { async function fetchRssFeedItemsByFeedId(pool, rssFeedId) { const [rows] = await pool.query( `SELECT id, rss_feed_id, position, item_json, created_at, modified_at - FROM rss_feed_items + FROM i_rss_feed_items WHERE rss_feed_id = ? ORDER BY position ASC, id ASC`, [rssFeedId] @@ -229,7 +231,7 @@ async function fetchRssFeedItems(feedUrl, itemLimit) { async function replaceRssFeedItems(connection, rssFeedId, items) { const normalizedItems = Array.isArray(items) ? items : []; - await connection.query('DELETE FROM rss_feed_items WHERE rss_feed_id = ?', [rssFeedId]); + await connection.query('DELETE FROM i_rss_feed_items WHERE rss_feed_id = ?', [rssFeedId]); if (!normalizedItems.length) { return 0; @@ -244,7 +246,7 @@ async function replaceRssFeedItems(connection, rssFeedId, items) { }); await connection.query( - 'INSERT INTO rss_feed_items (rss_feed_id, position, item_json) VALUES ?', + 'INSERT INTO i_rss_feed_items (rss_feed_id, position, item_json) VALUES ?', [insertValues] ); diff --git a/src/data/screens.js b/src/data/screens.js index e632e2e..6589d13 100644 --- a/src/data/screens.js +++ b/src/data/screens.js @@ -1,3 +1,5 @@ +// Screen slug helpers and screen lookup utilities. + function slugify(value) { return String(value || '') .toLowerCase() @@ -7,6 +9,60 @@ function slugify(value) { .replace(/-{2,}/g, '-'); } +function normalizePlayerBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); +} + +function buildScreenPlayerUrl(screen, fallbackBaseUrl) { + const slug = String(screen && screen.slug || '').trim(); + if (!slug) { + return null; + } + + const baseUrl = normalizePlayerBaseUrl(fallbackBaseUrl); + if (!baseUrl) { + return null; + } + + return `${baseUrl}/screen/${encodeURIComponent(slug)}`; +} + +async function fetchScreenPlayerUrls(pool) { + const [rows] = await pool.query(` + SELECT s.slug, p.public_base_url + FROM d_screens s + LEFT JOIN d_players p ON p.device_id = s.player_id + WHERE p.public_base_url IS NOT NULL + AND TRIM(p.public_base_url) <> '' + ORDER BY p.modified_at DESC, s.slug ASC + `); + + const playerUrls = {}; + rows.forEach(function (row) { + const slug = String(row && row.slug || '').trim(); + const publicBaseUrl = normalizePlayerBaseUrl(row && row.public_base_url); + const playerUrl = buildScreenPlayerUrl({ slug: slug }, publicBaseUrl); + if (slug && playerUrl && !playerUrls[slug]) { + playerUrls[slug] = playerUrl; + } + }); + + return playerUrls; +} + +async function fetchScreenPlayerRecord(pool, slug) { + const [rows] = await pool.query(` + SELECT p.device_id, p.public_base_url, p.internal_base_url, p.last_seen_at + FROM d_screens s + JOIN d_players p ON p.device_id = s.player_id + WHERE s.slug = ? + ORDER BY p.modified_at DESC, p.device_id ASC + LIMIT 1 + `, [slug]); + + return rows[0] || null; +} + async function uniqueScreenSlug(pool, baseSlug, excludeId) { const start = baseSlug || `screen-${Date.now()}`; let candidate = start; @@ -44,6 +100,10 @@ async function fetchScreenEditData(pool) { module.exports = { slugify, + normalizePlayerBaseUrl, + buildScreenPlayerUrl, + fetchScreenPlayerUrls, + fetchScreenPlayerRecord, uniqueScreenSlug, fetchScreenById, fetchScreenEditData diff --git a/src/data/slides.js b/src/data/slides.js index 0c28088..ec9d957 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -1,3 +1,5 @@ +// Slide data access helpers, including rich-text normalization and payload building. + const { fetchTemplateById } = require('./templates'); const { parseJsonSafe } = require('./utils'); @@ -25,6 +27,25 @@ function sanitizeRichText(html) { }); } +function normalizePlainText(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/<\s*br\s*\/?\s*>/gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/ /gi, ' ') + .trim(); +} + +function stripEditorOnlyMarkup(value) { + return String(value || '') + .replace(/
]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '') + .replace(/]*class="[^"]*api-region-sample-accordion[^"]*"[^>]*>[\s\S]*?<\/details>/gi, '') + .trim(); +} + +function normalizeEditorMarkup(value) { + return String(value === undefined || value === null ? '' : value).trim(); +} + async function fetchSlideById(pool, id) { const [slides] = await pool.query(` SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, @@ -53,6 +74,14 @@ function getFilesByField(files) { return map; } +function getSubmittedValue(body, key, fallback) { + if (body && Object.prototype.hasOwnProperty.call(body, key)) { + return String(body[key] || '').trim(); + } + + return fallback; +} + function sanitizeTextColor(value, fallback) { const raw = String(value || '').trim(); if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) { @@ -88,9 +117,10 @@ function buildTemplateContent(template, body, filesByField, existingContent) { if (region.region_type === 'image') { const uploaded = filesByField[`region_image_${region.id}`]; const existing = body[`existing_region_image_${region.id}`]; + const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : ''; content[region.region_key] = { type: 'image', - value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '') + value: uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_image_${region.id}`, current) }; } else if (region.region_type === 'video') { const uploaded = filesByField[`region_video_${region.id}`]; @@ -99,9 +129,10 @@ function buildTemplateContent(template, body, filesByField, existingContent) { const existingDuration = existingContent && existingContent[region.region_key] ? Number(existingContent[region.region_key].duration_seconds || 0) : 0; const parsedDuration = Number(durationValue || existingDuration || 0); const normalizedDuration = Math.round(parsedDuration * 1000) / 1000; + const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : ''; content[region.region_key] = { type: 'video', - value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : ''), + value: uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_video_${region.id}`, current), duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null }; } else if (region.region_type === 'webpage') { @@ -126,6 +157,15 @@ function buildTemplateContent(template, body, filesByField, existingContent) { type: 'html', value: submitted === undefined ? current : String(submitted || '') }; + } else if (region.region_type === 'time-date') { + const submitted = body[`region_text_${region.id}`]; + const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; + const timezoneValue = body[`region_timezone_${region.id}`]; + content[region.region_key] = { + type: 'time-date', + value: submitted === undefined ? String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '') : String(submitted || ''), + timezone: timezoneValue === undefined || timezoneValue === null ? String(current.timezone || current.time_zone || '') : String(timezoneValue || '').trim() + }; } else if (region.region_type === 'rss') { const submitted = body[`region_text_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; @@ -135,7 +175,7 @@ function buildTemplateContent(template, body, filesByField, existingContent) { const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1)); content[region.region_key] = { type: 'rss', - value: submitted === undefined ? String(current.value || '') : String(submitted || ''), + value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))), feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId), item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, variable_name: 'item', @@ -149,24 +189,61 @@ function buildTemplateContent(template, body, filesByField, existingContent) { const style = getTextRegionStyle(body, region, existingContent); const sourceId = body[`region_api_source_id_${region.id}`]; const itemNumber = body[`region_api_item_number_${region.id}`]; + const itemsPath = body[`region_api_items_path_${region.id}`]; const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1)); content[region.region_key] = { type: 'api', - value: submitted === undefined ? String(current.value || '') : String(submitted || ''), + value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))), source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId), item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, + items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(), variable_name: 'item', font_family: style.font_family, font_size: style.font_size, font_color: style.font_color }; + } else if (!['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'].includes(String(region.region_type || '').trim())) { + const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {}; + const suffix = '_' + region.id; + const generic = {}; + + Object.keys(body || {}).forEach((key) => { + if (!key.startsWith('region_') || !key.endsWith(suffix)) { + return; + } + const field = key.slice('region_'.length, -suffix.length); + if (!field || field === 'type' || field === 'key' || field === 'name' || field === 'label') { + return; + } + generic[field] = body[key]; + }); + + Object.keys(filesByField || {}).forEach((fieldName) => { + if (!fieldName.startsWith('region_') || !fieldName.endsWith(suffix)) { + return; + } + const field = fieldName.slice('region_'.length, -suffix.length); + if (!field) { + return; + } + generic[field] = `/media/uploads/${filesByField[fieldName].filename}`; + }); + + Object.keys(current).forEach((key) => { + if (generic[key] === undefined) { + generic[key] = current[key]; + } + }); + + generic.type = region.region_type; + content[region.region_key] = generic; } else { const submitted = body[`region_text_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : ''; const style = getTextRegionStyle(body, region, existingContent); content[region.region_key] = { type: 'text', - value: submitted === undefined ? current : String(submitted), + value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || ''))), font_family: style.font_family, font_size: style.font_size, font_color: style.font_color diff --git a/src/data/templates.js b/src/data/templates.js index c4e5d72..4184be5 100644 --- a/src/data/templates.js +++ b/src/data/templates.js @@ -1,7 +1,6 @@ -const { parseJsonSafe, readFormArray } = require('./utils'); +// Template data access helpers and region normalization logic. -const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api']; -const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api']; +const { parseJsonSafe, readFormArray } = require('./utils'); function sanitizeBackgroundColor(value) { const raw = String(value || '').trim(); @@ -13,7 +12,7 @@ function sanitizeBackgroundColor(value) { function normalizeTemplateRegionType(value) { const rawType = String(value || 'text').trim(); - return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text'; + return rawType || 'text'; } function normalizeTemplateRegionLockRatio(value) { @@ -58,7 +57,7 @@ async function fetchTemplateById(pool, id) { return null; } const template = templates[0]; - const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]); + const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]); template.regions = regions; return template; } @@ -71,7 +70,7 @@ async function fetchTemplatesData(pool) { LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY st.id DESC `); - const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); + const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); return { templates, templateRegions }; } @@ -86,7 +85,6 @@ function extractTemplateRegions(body) { region_key: String(region.region_name || region.region_key || region.label || '').trim(), region_type: regionType, label: String(region.region_name || region.label || region.region_key || '').trim(), - font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(region.font_family || '').trim() || null : null, lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio), x: Number(region.x || 0), y: Number(region.y || 0), @@ -108,7 +106,6 @@ function extractTemplateRegions(body) { const widths = readFormArray(body, 'region_width[]'); const heights = readFormArray(body, 'region_height[]'); const zs = readFormArray(body, 'region_z[]'); - const fonts = readFormArray(body, 'font_family[]'); const regions = []; for (let i = 0; i < keys.length; i += 1) { @@ -122,7 +119,6 @@ function extractTemplateRegions(body) { region_key: name, region_type: regionType, label: name, - font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null, lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]), x: Number(xs[i] || 0), y: Number(ys[i] || 0), @@ -135,6 +131,43 @@ function extractTemplateRegions(body) { return regions; } +function extractGenericRegionContent(region, body, filesByField, existingContent) { + const content = {}; + const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {}; + const suffix = '_' + region.id; + + Object.keys(body || {}).forEach((key) => { + if (!key.startsWith('region_') || !key.endsWith(suffix)) { + return; + } + const field = key.slice('region_'.length, -suffix.length); + if (!field || field === 'type' || field === 'key' || field === 'name' || field === 'label') { + return; + } + content[field] = body[key]; + }); + + Object.keys(filesByField || {}).forEach((fieldName) => { + if (!fieldName.startsWith('region_') || !fieldName.endsWith(suffix)) { + return; + } + const field = fieldName.slice('region_'.length, -suffix.length); + if (!field) { + return; + } + content[field] = `/media/uploads/${filesByField[fieldName].filename}`; + }); + + Object.keys(current).forEach((key) => { + if (content[key] === undefined) { + content[key] = current[key]; + } + }); + + content.type = region.region_type; + return content; +} + function getFilesByField(files) { const map = {}; (files || []).forEach((file) => { diff --git a/src/data/utils.js b/src/data/utils.js index 00647d6..3411050 100644 --- a/src/data/utils.js +++ b/src/data/utils.js @@ -1,3 +1,5 @@ +// Shared helpers for parsing JSON, reading form arrays, and duplicate-name checks. + function parseJsonSafe(value) { if (!value) { return null; diff --git a/src/db/index.js b/src/db/index.js index b3aca5f..191e5b1 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -1,3 +1,4 @@ +// Snapshot only: keep this file aligned with the current schema state. async function ensureSchema(pool, options) { await pool.query(` CREATE TABLE IF NOT EXISTS c_canvas_sizes ( @@ -122,6 +123,40 @@ async function ensureSchema(pool, options) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool.query(` + CREATE TABLE IF NOT EXISTS d_announcements ( + id INT AUTO_INCREMENT PRIMARY KEY, + message TEXT NOT NULL, + short_label VARCHAR(255) NOT NULL DEFAULT '', + announcement_type VARCHAR(32) NOT NULL DEFAULT 'lower-third', + color_key VARCHAR(32) NOT NULL DEFAULT 'primary', + icon_key VARCHAR(64) NOT NULL DEFAULT 'megaphone-fill', + duration_seconds INT NULL, + expires_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INT NULL, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + modified_by INT NULL, + INDEX idx_announcements_expires_at (expires_at), + INDEX idx_announcements_modified_at (modified_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS d_announcement_screens ( + announcement_id INT NOT NULL, + screen_id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INT NULL, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + modified_by INT NULL, + PRIMARY KEY (announcement_id, screen_id), + INDEX idx_announcement_screens_screen_id (screen_id), + CONSTRAINT fk_announcement_screens_announcement FOREIGN KEY (announcement_id) REFERENCES d_announcements(id) ON DELETE CASCADE, + CONSTRAINT fk_announcement_screens_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` CREATE TABLE IF NOT EXISTS i_rss_feeds ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -287,6 +322,9 @@ async function ensureSchema(pool, options) { INDEX idx_background_tasks_type (task_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + + const { runMigrations } = require('./migrations'); + await runMigrations(pool, options); } module.exports = { diff --git a/src/db/migrations.js b/src/db/migrations.js index e5c81a5..5675b02 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -1,7 +1,184 @@ -const { version: appVersion } = require('../../package.json'); +const { version: appVersion } = require('#root/package.json'); -async function runMigrations(_pool, _options) { - return; +const VERSIONED_MIGRATIONS = [ + { + version: '2.1.0', + label: 'v2.1.0 player registry schema', + run: async function (pool) { + // Keep the player registry as the source of truth for device metadata. + if (!(await columnExists(pool, 'd_players', 'public_base_url'))) { + await pool.query(` + CREATE TABLE IF NOT EXISTS d_players ( + device_id VARCHAR(128) PRIMARY KEY, + public_base_url VARCHAR(512) NULL, + internal_base_url VARCHAR(512) NULL, + last_seen_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + } + + // Seed the singleton player row used by the current one-player model. + // For multi-player support, this seed and the hardcoded screen_id/player_id mapping will need to be replaced. + await pool.query(`INSERT IGNORE INTO d_players (device_id) VALUES ('1')`); + + // Store the player pointer on screens so we can resolve the player without needing a player-side screen_id. + // This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'. + if (!(await columnExists(pool, 'd_screens', 'player_id'))) { + await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id"); + } else { + await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'"); + + const [playerColumnNullableRows] = await pool.query( + `SELECT COUNT(*) AS nullable_count + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'd_screens' + AND COLUMN_NAME = 'player_id' + AND IS_NULLABLE = 'YES'` + ); + if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) { + await pool.query("ALTER TABLE d_screens MODIFY COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id"); + } + } + + const [screenPlayerUniqueRows] = await pool.query( + `SELECT COUNT(*) AS index_count + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'd_screens' + AND INDEX_NAME = 'uq_screens_player_id'` + ); + if (Number(screenPlayerUniqueRows && screenPlayerUniqueRows[0] && screenPlayerUniqueRows[0].index_count) > 0) { + await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id'); + } + + // Recreate the screen-to-player foreign key after the column exists and legacy data is copied over. + const [screenPlayerFkRows] = await pool.query( + `SELECT COUNT(*) AS fk_count + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'd_screens' + AND CONSTRAINT_NAME = 'fk_screens_player'` + ); + if (Number(screenPlayerFkRows && screenPlayerFkRows[0] && screenPlayerFkRows[0].fk_count) === 0) { + await pool.query('ALTER TABLE d_screens ADD CONSTRAINT fk_screens_player FOREIGN KEY (player_id) REFERENCES d_players(device_id) ON DELETE RESTRICT'); + } + + if (await columnExists(pool, 'c_template_regions', 'font_family')) { + await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family'); + } + + } + }, + { + version: '2.1.1', + label: 'v2.1.1 API source schema', + run: async function (pool) { + await ensureColumn(pool, 'i_api_sources', 'auth_method', "VARCHAR(32) NOT NULL DEFAULT 'none'", 'api_url'); + await ensureColumn(pool, 'i_api_sources', 'auth_username', 'VARCHAR(255) NULL', 'auth_method'); + await ensureColumn(pool, 'i_api_sources', 'auth_password', 'MEDIUMTEXT NULL', 'auth_username'); + await ensureColumn(pool, 'i_api_sources', 'auth_bearer_token', 'MEDIUMTEXT NULL', 'auth_password'); + await ensureColumn(pool, 'i_api_sources', 'auth_header_name', 'VARCHAR(255) NULL', 'auth_bearer_token'); + await ensureColumn(pool, 'i_api_sources', 'auth_header_value', 'MEDIUMTEXT NULL', 'auth_header_name'); + await ensureColumn(pool, 'i_api_sources', 'items_path', 'VARCHAR(255) NULL', 'auth_header_value'); + } + }, + { + version: '2.2.0', + label: 'v2.2.0 announcement schema', + run: async function (pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS d_announcements ( + id INT AUTO_INCREMENT PRIMARY KEY, + message TEXT NOT NULL, + short_label VARCHAR(255) NOT NULL DEFAULT '', + announcement_type VARCHAR(32) NOT NULL DEFAULT 'lower-third', + color_key VARCHAR(32) NOT NULL DEFAULT 'primary', + icon_key VARCHAR(64) NOT NULL DEFAULT 'megaphone-fill', + duration_seconds INT NULL, + expires_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INT NULL, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + modified_by INT NULL, + INDEX idx_announcements_expires_at (expires_at), + INDEX idx_announcements_modified_at (modified_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS d_announcement_screens ( + announcement_id INT NOT NULL, + screen_id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INT NULL, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + modified_by INT NULL, + PRIMARY KEY (announcement_id, screen_id), + INDEX idx_announcement_screens_screen_id (screen_id), + CONSTRAINT fk_announcement_screens_announcement FOREIGN KEY (announcement_id) REFERENCES d_announcements(id) ON DELETE CASCADE, + CONSTRAINT fk_announcement_screens_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + } + } +]; + +async function columnExists(pool, tableName, columnName) { + const [rows] = await pool.query( + `SELECT COUNT(*) AS column_count + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ?`, + [tableName, columnName] + ); + + return Number(rows && rows[0] && rows[0].column_count) > 0; +} + +async function ensureColumn(pool, tableName, columnName, columnDefinition, afterColumn) { + if (await columnExists(pool, tableName, columnName)) { + return; + } + + const afterClause = afterColumn ? ' AFTER ' + afterColumn : ''; + await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause); +} + +function compareVersions(leftVersion, rightVersion) { + const leftParts = String(leftVersion || '0.0.0').split('.').map(function (value) { + return Number(value) || 0; + }); + const rightParts = String(rightVersion || '0.0.0').split('.').map(function (value) { + return Number(value) || 0; + }); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const leftPart = leftParts[index] || 0; + const rightPart = rightParts[index] || 0; + if (leftPart > rightPart) { + return 1; + } + if (leftPart < rightPart) { + return -1; + } + } + return 0; +} + +async function runMigrations(pool, options) { + // Only run migrations that are newer than the installed schema version and not beyond the app version. + const targetVersion = String(appVersion || '0.0.0').trim(); + const currentVersion = String(options && options.currentVersion || '0.0.0').trim(); + + for (const migration of VERSIONED_MIGRATIONS) { + if (compareVersions(migration.version, currentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) { + await migration.run(pool); + } + } } module.exports = { diff --git a/src/player.js b/src/player.js index e2fb655..40050f8 100644 --- a/src/player.js +++ b/src/player.js @@ -9,6 +9,7 @@ const { createRtmpStreamService } = require('./player/modules/rtmp-streams'); const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding'); const { createOnboardingStore } = require('./player/onboarding/store'); const { registerPlayerRoutes } = require('./player/routes'); +const { ensureFontLibrary } = require('#src/web/lib/media/font-library'); // Player runtime, media API, and websocket wiring. @@ -16,6 +17,9 @@ async function start() { const app = express(); const pool = common.createPool(); const PORT = Number(process.env.PLAYER_PORT || 8081); + const PLAYER_PUBLIC_BASE_URL = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const PLAYER_INTERNAL_BASE_URL = String(process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const PLAYER_IDENTIFIER = '1'; const ASSET_DIR = path.join(__dirname, 'player', 'public'); const MEDIA_DIR = path.join(__dirname, '..', 'media'); const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json'); @@ -41,6 +45,8 @@ async function start() { common: common, playerRuntime: playerRuntime, onboardingStore: onboardingStore, + playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, + playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, QRCode: require('qrcode') }); registerPlayerRoutes(app, { @@ -50,7 +56,10 @@ async function start() { assetDir: ASSET_DIR, playerRuntime: playerRuntime, playerPlaylistService: playerPlaylistService, - rtmpStreamService: rtmpStreamService + rtmpStreamService: rtmpStreamService, + playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, + playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, + playerIdentifier: PLAYER_IDENTIFIER }); app.use(function (error, _req, res, _next) { @@ -59,6 +68,7 @@ async function start() { }); fs.mkdirSync(MEDIA_DIR, { recursive: true }); + await ensureFontLibrary(MEDIA_DIR); server.listen(PORT, function () { console.log(`Pulse Signage app listening on port ${PORT}`); @@ -89,6 +99,12 @@ async function start() { } await syncDatabaseState(); + + if (PLAYER_IDENTIFIER) { + const { upsertPlayerRegistration } = require('./player/onboarding'); + await upsertPlayerRegistration(pool, PLAYER_IDENTIFIER, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL, null); + } + setInterval(function () { syncDatabaseState().catch(function (error) { console.error(error); diff --git a/src/player/announcement-templates/fullscreen.template.html b/src/player/announcement-templates/fullscreen.template.html new file mode 100644 index 0000000..d0bb5d1 --- /dev/null +++ b/src/player/announcement-templates/fullscreen.template.html @@ -0,0 +1,55 @@ + ++ \ No newline at end of file diff --git a/src/player/announcement-templates/lower-third.template.html b/src/player/announcement-templates/lower-third.template.html new file mode 100644 index 0000000..febcaa3 --- /dev/null +++ b/src/player/announcement-templates/lower-third.template.html @@ -0,0 +1,197 @@ + ++++ + +++ + \ No newline at end of file diff --git a/src/player/announcement-templates/top-banner.template.html b/src/player/announcement-templates/top-banner.template.html new file mode 100644 index 0000000..4b56a92 --- /dev/null +++ b/src/player/announcement-templates/top-banner.template.html @@ -0,0 +1,199 @@ + + + \ No newline at end of file diff --git a/src/player/modules/rtmp-streams.js b/src/player/modules/rtmp-streams.js index 4134f32..42cb44e 100644 --- a/src/player/modules/rtmp-streams.js +++ b/src/player/modules/rtmp-streams.js @@ -1,3 +1,5 @@ +// RTMP stream session management and ffmpeg probe orchestration. + const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); diff --git a/src/player/onboarding/index.js b/src/player/onboarding/index.js index 7a49995..77cac9c 100644 --- a/src/player/onboarding/index.js +++ b/src/player/onboarding/index.js @@ -1,5 +1,7 @@ -const { isClientNameAvailable, withClientNameReservation } = require('../../data/client-name-check'); -const { getSharedSecret, verifyPageAuthToken } = require('../../request-auth'); +// Player onboarding routes and signup flow helpers. + +const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check'); +const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth'); const { isTransientDbError } = require('./store'); const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000; @@ -10,8 +12,8 @@ function normalizeDeviceId(value) { return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128); } -function getPublicBaseUrl(req) { - const configured = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); +function getPublicBaseUrl(req, configuredUrl) { + const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); if (configured) { return configured; } @@ -40,6 +42,15 @@ function getOnboardingLimitKey(req, deviceId) { return [getRequestIp(req), normalizeDeviceId(deviceId) || 'anonymous'].join('|'); } +function getPlayerPublicBaseUrl(req, configuredUrl) { + return getPublicBaseUrl(req, configuredUrl); +} + +function getPlayerInternalBaseUrl(configuredUrl) { + const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + return configured || null; +} + function clearOnboardingSignupAttempts() { if (onboardingSignupAttempts.size > 1000) { onboardingSignupAttempts.clear(); @@ -121,6 +132,62 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam }); } +async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl, screenSlug) { + const normalizedDeviceId = normalizeDeviceId(deviceId); + const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, ''); + const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, ''); + if (!normalizedDeviceId) { + return null; + } + + await pool.query( + `INSERT INTO d_players (device_id, public_base_url, internal_base_url, last_seen_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE public_base_url = VALUES(public_base_url), internal_base_url = VALUES(internal_base_url), last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP`, + [normalizedDeviceId, normalizedPublicBaseUrl || null, normalizedInternalBaseUrl || null] + ); + + return { + device_id: normalizedDeviceId, + public_base_url: normalizedPublicBaseUrl || null, + internal_base_url: normalizedInternalBaseUrl || null + }; +} + +async function bindPlayerToScreen(pool, deviceId, screenSlug) { + const normalizedDeviceId = normalizeDeviceId(deviceId); + const normalizedScreenSlug = String(screenSlug || '').trim(); + if (!normalizedDeviceId || !normalizedScreenSlug) { + return null; + } + + const [screenRows] = await pool.query('SELECT id FROM d_screens WHERE slug = ? LIMIT 1', [normalizedScreenSlug]); + if (!screenRows.length) { + return null; + } + + const screenId = Number(screenRows[0].id); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + // Current model: every screen binds to the shared player row '1'. + // If we introduce multiple players, resolve the correct player row here instead of hardcoding it. + await connection.query("UPDATE d_screens SET player_id = '1', modified_at = CURRENT_TIMESTAMP WHERE id = ?", [screenId]); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + + return { + screen_id: screenId, + screen_slug: normalizedScreenSlug, + player_id: '1' + }; +} + async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) { const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function' ? playerRuntime.snapshotAllConnections() @@ -184,9 +251,13 @@ function registerPlayerOnboardingRoutes(app, options) { res.send(common.renderPlayerOnboardingLandingPage()); }); - app.get('/onboard', function (req, res) { + app.get('/onboard', async function (req, res, next) { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); - res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim())); + try { + res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim())); + } catch (error) { + next(error); + } }); app.get('/api/onboarding/status', function (req, res, next) { @@ -231,7 +302,7 @@ function registerPlayerOnboardingRoutes(app, options) { if (!deviceId) { return res.status(400).json({ error: 'Device ID is required' }); } - const onboardingUrl = `${getPublicBaseUrl(req)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; + const onboardingUrl = `${getPublicBaseUrl(req, options && options.playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; const svg = await QRCode.toString(onboardingUrl, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' }); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); res.set('Cache-Control', 'no-store'); @@ -262,6 +333,7 @@ function registerPlayerOnboardingRoutes(app, options) { } const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore); + await bindPlayerToScreen(pool, deviceId, screenSlug); res.json({ deviceId: deviceId, clientName: status ? status.client_name : clientName, @@ -280,8 +352,12 @@ function registerPlayerOnboardingRoutes(app, options) { module.exports = { normalizeDeviceId: normalizeDeviceId, getPublicBaseUrl: getPublicBaseUrl, + getPlayerPublicBaseUrl: getPlayerPublicBaseUrl, + getPlayerInternalBaseUrl: getPlayerInternalBaseUrl, getOnboardingStatus: getOnboardingStatus, commitDeviceBinding: commitDeviceBinding, + upsertPlayerRegistration: upsertPlayerRegistration, + bindPlayerToScreen: bindPlayerToScreen, bindDeviceToScreen: bindDeviceToScreen, registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes }; \ No newline at end of file diff --git a/src/player/onboarding/store.js b/src/player/onboarding/store.js index 2fcd07e..eda38ae 100644 --- a/src/player/onboarding/store.js +++ b/src/player/onboarding/store.js @@ -1,3 +1,5 @@ +// File-backed storage for onboarding device mappings. + const fs = require('fs'); const path = require('path'); diff --git a/src/player/player-page-announcements.js b/src/player/player-page-announcements.js new file mode 100644 index 0000000..6c14c5d --- /dev/null +++ b/src/player/player-page-announcements.js @@ -0,0 +1,261 @@ +// Announcement overlay polling and rendering helpers. + +(function () { + var announcementLayer = null; + var announcementRefreshTimer = null; + var announcementPollTimer = null; + var announcementSocket = null; + var announcementReconnectTimer = null; + var announcementRequestId = 0; + var announcementEtag = ''; + var announcementHideTimer = null; + var announcementRevealTimer = null; + var announcementFadeDurationMs = 220; + var announcementSocketPath = '/ws/screens/' + encodeURIComponent(window.slug || '') + '/announcements'; + + function normalizeAnnouncementType(value) { + var normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'center-card') { + return 'fullscreen'; + } + if (['lower-third', 'fullscreen', 'top-banner'].indexOf(normalized) !== -1) { + return normalized; + } + return 'lower-third'; + } + + function getAnnouncementLayer() { + if (announcementLayer) { + return announcementLayer; + } + announcementLayer = document.getElementById('player-announcement-layer'); + if (!announcementLayer) { + announcementLayer = document.createElement('div'); + announcementLayer.id = 'player-announcement-layer'; + announcementLayer.className = 'player-announcement-layer'; + announcementLayer.setAttribute('aria-live', 'polite'); + announcementLayer.setAttribute('aria-atomic', 'true'); + document.body.appendChild(announcementLayer); + } + return announcementLayer; + } + + function clearAnnouncementHideTimer() { + if (announcementHideTimer) { + window.clearTimeout(announcementHideTimer); + announcementHideTimer = null; + } + } + + function clearAnnouncementRevealTimer() { + if (announcementRevealTimer) { + window.cancelAnimationFrame(announcementRevealTimer); + announcementRevealTimer = null; + } + } + + function activateInjectedScripts(layer) { + if (!layer) { + return; + } + + var scripts = layer.querySelectorAll('script'); + scripts.forEach(function (originalScript) { + var replacement = document.createElement('script'); + + Array.prototype.slice.call(originalScript.attributes || []).forEach(function (attribute) { + replacement.setAttribute(attribute.name, attribute.value); + }); + + replacement.text = originalScript.textContent || ''; + originalScript.parentNode.replaceChild(replacement, originalScript); + }); + } + + function hideAnnouncement() { + var layer = getAnnouncementLayer(); + clearAnnouncementRevealTimer(); + clearAnnouncementHideTimer(); + layer.classList.add('is-closing'); + layer.classList.remove('is-visible'); + announcementHideTimer = window.setTimeout(function () { + layer.innerHTML = ''; + layer.hidden = true; + layer.classList.remove('is-closing'); + announcementHideTimer = null; + }, announcementFadeDurationMs); + } + + function renderAnnouncement(announcement) { + var layer = getAnnouncementLayer(); + if (!announcement || !announcement.message) { + hideAnnouncement(); + return; + } + + var type = normalizeAnnouncementType(announcement.announcement_type); + + clearAnnouncementRevealTimer(); + clearAnnouncementHideTimer(); + layer.hidden = false; + layer.className = 'player-announcement-layer player-announcement-layer--' + type; + layer.classList.remove('is-closing'); + layer.innerHTML = window.renderPlayerAnnouncementMarkup ? window.renderPlayerAnnouncementMarkup(announcement) : ''; + activateInjectedScripts(layer); + announcementRevealTimer = window.requestAnimationFrame(function () { + announcementRevealTimer = null; + layer.classList.add('is-visible'); + }); + } + + function scheduleAnnouncementRefresh(delayMs) { + if (announcementRefreshTimer) { + window.clearTimeout(announcementRefreshTimer); + } + announcementRefreshTimer = window.setTimeout(function () { + announcementRefreshTimer = null; + void refreshAnnouncementOverlay(); + }, Math.max(0, Number(delayMs || 0))); + } + + async function refreshAnnouncementOverlay() { + var requestId = ++announcementRequestId; + var request = new XMLHttpRequest(); + var url = window.location.origin + '/api/screens/' + encodeURIComponent(window.slug || '') + '/announcement?ts=' + Date.now(); + request.open('GET', url, true); + request.timeout = 2500; + if (window.__pulsePageAuthToken) { + request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken); + } + if (announcementEtag) { + request.setRequestHeader('If-None-Match', announcementEtag); + } + request.onreadystatechange = function () { + if (request.readyState !== 4 || requestId !== announcementRequestId) { + return; + } + if (request.status === 304) { + return; + } + if (request.status < 200 || request.status >= 300) { + scheduleAnnouncementRefresh(15000); + return; + } + + try { + var responseEtag = String(request.getResponseHeader('ETag') || '').trim(); + var data = JSON.parse(request.responseText || '{}'); + var announcement = data && data.announcement ? data.announcement : null; + + if (responseEtag) { + announcementEtag = responseEtag; + } + + if (!announcement) { + hideAnnouncement(); + } else { + renderAnnouncement(announcement); + } + } catch (_error) { + scheduleAnnouncementRefresh(15000); + } + }; + request.onerror = function () { + scheduleAnnouncementRefresh(15000); + }; + request.ontimeout = function () { + scheduleAnnouncementRefresh(15000); + }; + request.send(); + } + + function scheduleAnnouncementPolling() { + if (announcementPollTimer) { + window.clearInterval(announcementPollTimer); + } + announcementPollTimer = window.setInterval(function () { + void refreshAnnouncementOverlay(); + }, 15000); + } + + function scheduleAnnouncementReconnect() { + if (announcementReconnectTimer) { + return; + } + announcementReconnectTimer = window.setTimeout(function () { + announcementReconnectTimer = null; + connectAnnouncementSocket(); + }, 5000); + } + + function handleAnnouncementSocketMessage(rawMessage) { + var payload; + try { + payload = JSON.parse(String(rawMessage || '')); + } catch (_error) { + return; + } + + if (!payload || payload.type !== 'announcement-refresh') { + return; + } + + void refreshAnnouncementOverlay(); + } + + function connectAnnouncementSocket() { + if (!window.WebSocket) { + return; + } + if (announcementSocket && (announcementSocket.readyState === WebSocket.OPEN || announcementSocket.readyState === WebSocket.CONNECTING)) { + return; + } + + var socketUrl = new URL(announcementSocketPath, window.location.origin); + if (window.__pulsePageAuthToken) { + socketUrl.searchParams.set('auth', window.__pulsePageAuthToken); + } + + var socket = new WebSocket(socketUrl.toString()); + announcementSocket = socket; + + socket.onopen = function () { + void refreshAnnouncementOverlay(); + }; + + socket.onmessage = function (event) { + handleAnnouncementSocketMessage(event.data); + }; + + socket.onclose = function () { + announcementSocket = null; + scheduleAnnouncementReconnect(); + }; + + socket.onerror = function () { + try { + socket.close(); + } catch (_error) { + // ignore socket close errors + } + }; + } + + window.refreshAnnouncementOverlay = refreshAnnouncementOverlay; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + void refreshAnnouncementOverlay(); + scheduleAnnouncementPolling(); + connectAnnouncementSocket(); + }, { once: true }); + } else { + void refreshAnnouncementOverlay(); + scheduleAnnouncementPolling(); + connectAnnouncementSocket(); + } + + window.addEventListener('focus', function () { + void refreshAnnouncementOverlay(); + }); +})(); \ No newline at end of file diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html index 4253eac..957b76a 100644 --- a/src/player/player-page.script.html +++ b/src/player/player-page.script.html @@ -1,7 +1,41 @@ '; const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() }); + const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR); const script = getPlayerPageScript()({ SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)), INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)), @@ -118,26 +129,31 @@ function renderPlayerPage(slug, initialData) { return renderPage(template, { title: 'Screen ' + slug, body: '+ +++ ++', - script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '' + '' + '' + '' + '' + onboardingScript + script + stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [], + script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '' + '' + '' + '' + '' + '' + '' + '' + onboardingScript + script + '' }); } function renderPlayerOnboardingLandingPage() { const pageAuthToken = createPageAuthBundle({ scope: 'onboarding' }); + const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR); return renderPage(getPlayerPageTemplate(), { title: 'Onboard player', bodyClass: 'onboarding-page', body: renderOnboardingLandingBody(), + stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [], script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript() }); } function renderPlayerOnboardingFormPage(deviceId) { const pageAuthToken = createPageAuthBundle({ scope: 'onboarding', deviceId: String(deviceId || '').trim() }); + const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR); return renderPage(getPlayerPageTemplate(), { title: 'Onboard screen', bodyClass: 'onboarding-page', body: renderOnboardingFormBody(deviceId), + stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [], script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingFormScript(deviceId) }); } diff --git a/src/player/routes.js b/src/player/routes.js index 383f825..3f8784f 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -1,3 +1,5 @@ +// Player route registration and filesystem-backed media helpers. + const fs = require('fs'); const express = require('express'); const path = require('path'); @@ -18,6 +20,9 @@ function registerPlayerRoutes(app, options) { const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null; const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null; + const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + const playerIdentifier = String(options && options.playerIdentifier || '1').trim() || '1'; if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) { throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and rtmpStreamService.'); @@ -75,6 +80,7 @@ function registerPlayerRoutes(app, options) { } app.use('/assets', express.static(assetDir)); + app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons'))); app.use('/media', express.static(mediaDir)); app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist'))); @@ -207,6 +213,13 @@ function registerPlayerRoutes(app, options) { app.get('/screen/:slug', function (req, res) { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Pragma', 'no-cache'); + const { bindPlayerToScreen } = require('./onboarding'); + if (playerIdentifier) { + void bindPlayerToScreen(pool, playerIdentifier, req.params.slug) + .catch(function (error) { + console.error(error); + }); + } playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) { res.send(common.renderPlayerPage(req.params.slug, data)); }).catch(function (error) { @@ -273,6 +286,45 @@ function registerPlayerRoutes(app, options) { } }); + app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) { + try { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + const announcement = typeof common.fetchActiveAnnouncement === 'function' + ? await common.fetchActiveAnnouncement(pool, req.params.slug) + : null; + const revision = announcement + ? [announcement.id, announcement.modified_at || '', announcement.expires_at || '', announcement.enabled ? '1' : '0'].join(':') + : 'none'; + const etag = '"' + String(revision || 'none') + '"'; + res.set('ETag', etag); + if (String(req.headers['if-none-match'] || '').split(',').map(function (value) { + return String(value || '').trim(); + }).includes(etag)) { + return res.status(304).end(); + } + res.json({ + announcement: announcement, + revision: revision + }); + } catch (error) { + if (isTransientDbError(error)) { + return res.status(503).json({ error: 'Announcement state unavailable.' }); + } + next(error); + } + }); + + app.post('/api/screens/:slug/announcements/refresh', requireRequestAuth, async function (req, res, next) { + try { + const sent = typeof playerRuntime.broadcastAnnouncementRefresh === 'function' + ? playerRuntime.broadcastAnnouncementRefresh(req.params.slug) + : 0; + res.json({ ok: true, screenSlug: req.params.slug, sent: sent }); + } catch (error) { + next(error); + } + }); + app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], requireRequestAuth, async function (req, res, next) { try { const connections = playerRuntime.snapshotConnections(req.params.slug); diff --git a/src/player/runtime.js b/src/player/runtime.js index 55a71c7..8e8df4e 100644 --- a/src/player/runtime.js +++ b/src/player/runtime.js @@ -1,8 +1,23 @@ +// Player runtime state, websocket connections, and request-auth enforcement. + const crypto = require('crypto'); const { WebSocketServer, WebSocket } = require('ws'); const { isClientNameAvailable } = require('../data/client-name-check'); const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth'); +function normalizePlayerPublicBaseUrl(pageUrl) { + const value = String(pageUrl || '').trim(); + if (!value) { + return null; + } + + try { + return new URL(value).origin.replace(/\/$/, ''); + } catch (_error) { + return null; + } +} + function createPlayerRuntime(options) { const pool = options && options.pool ? options.pool : null; const normalizeDeviceId = typeof options.normalizeDeviceId === 'function' @@ -12,6 +27,7 @@ function createPlayerRuntime(options) { }; const connectionsBySlug = new Map(); const dashboardListenersBySlug = new Map(); + const announcementListenersBySlug = new Map(); const wss = new WebSocketServer({ noServer: true }); function normalizeClientIp(value) { @@ -72,6 +88,29 @@ function createPlayerRuntime(options) { } } + function getAnnouncementListenerBucket(slug) { + const key = String(slug || '').trim(); + if (!key) { + return null; + } + if (!announcementListenersBySlug.has(key)) { + announcementListenersBySlug.set(key, new Set()); + } + return announcementListenersBySlug.get(key); + } + + function removeAnnouncementListener(slug, socket) { + const key = String(slug || '').trim(); + const bucket = announcementListenersBySlug.get(key); + if (!bucket) { + return; + } + bucket.delete(socket); + if (!bucket.size) { + announcementListenersBySlug.delete(key); + } + } + function buildClientLabel(connection) { const clientName = String(connection.clientName || '').trim(); const clientId = String(connection.clientId || '').trim(); @@ -123,6 +162,7 @@ function createPlayerRuntime(options) { userAgent: connection.userAgent || null, viewport: connection.viewport || null, page: connection.page || null, + playerPublicBaseUrl: connection.playerPublicBaseUrl || null, currentSlide: connection.currentSlide || null, paused: Boolean(connection.paused), blackout: Boolean(connection.blackout), @@ -178,6 +218,30 @@ function createPlayerRuntime(options) { }); } + function broadcastAnnouncementRefresh(slug) { + const key = String(slug || '').trim(); + const bucket = announcementListenersBySlug.get(key); + if (!bucket || !bucket.size) { + return 0; + } + + const payload = JSON.stringify({ + type: 'announcement-refresh', + slug: key, + sentAt: new Date().toISOString() + }); + + let sent = 0; + bucket.forEach(function (socket) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(payload); + sent += 1; + } + }); + + return sent; + } + async function sendCommandToConnection(slug, connectionId, commandOrPayload) { const bucket = connectionsBySlug.get(String(slug || '').trim()); if (!bucket || !bucket.size) { @@ -235,7 +299,9 @@ function createPlayerRuntime(options) { const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/); const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/); - if (!dashboardMatch && !playerMatch) { + const announcementMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/announcements$/); + + if (!dashboardMatch && !playerMatch && !announcementMatch) { socket.destroy(); return; } @@ -256,9 +322,18 @@ function createPlayerRuntime(options) { } } - const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]); + if (announcementMatch) { + const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim(); + const payload = verifyPageAuthToken(authToken); + if (!payload || String(payload.scope || '').trim() !== 'player') { + socket.destroy(); + return; + } + } + + const slug = decodeURIComponent((dashboardMatch || playerMatch || announcementMatch)[1]); wss.handleUpgrade(request, socket, head, function (ws) { - wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player'); + wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : announcementMatch ? 'announcements' : 'player'); }); } @@ -288,6 +363,30 @@ function createPlayerRuntime(options) { return; } + if (role === 'announcements') { + const listenerBucket = getAnnouncementListenerBucket(slug); + if (!listenerBucket) { + socket.close(); + return; + } + + listenerBucket.add(socket); + socket.send(JSON.stringify({ + type: 'announcement-ready', + slug: String(slug || '').trim(), + sentAt: new Date().toISOString() + })); + + socket.on('close', function () { + removeAnnouncementListener(slug, socket); + }); + + socket.on('error', function () { + removeAnnouncementListener(slug, socket); + }); + return; + } + const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null; const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]); const normalizedRemoteAddress = normalizeClientIp(remoteAddress); @@ -304,6 +403,7 @@ function createPlayerRuntime(options) { page: null, paused: false, blackout: false, + playerPublicBaseUrl: null, clientIp: forwardedFor || normalizedRemoteAddress, remoteAddress: normalizedRemoteAddress, label: forwardedFor || normalizedRemoteAddress || 'connected client', @@ -341,6 +441,10 @@ function createPlayerRuntime(options) { connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent; connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport; connection.page = payload.page ? String(payload.page).trim() : connection.page; + const nextPlayerPublicBaseUrl = normalizePlayerPublicBaseUrl(payload.page); + if (nextPlayerPublicBaseUrl && nextPlayerPublicBaseUrl !== connection.playerPublicBaseUrl) { + connection.playerPublicBaseUrl = nextPlayerPublicBaseUrl; + } connection.paused = Boolean(payload.paused); connection.blackout = Boolean(payload.blackout); connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp; @@ -372,6 +476,7 @@ function createPlayerRuntime(options) { return { installWebsocket: installWebsocket, + broadcastAnnouncementRefresh: broadcastAnnouncementRefresh, snapshotConnections: snapshotConnections, snapshotAllConnections: snapshotAllConnections, isClientNameAvailableOnScreen: isClientNameAvailableOnScreen, diff --git a/src/player/thumbnail-preview.js b/src/player/thumbnail-preview.js index 732997c..0d9d25b 100644 --- a/src/player/thumbnail-preview.js +++ b/src/player/thumbnail-preview.js @@ -1,3 +1,5 @@ +// Thumbnail preview data and bootstrap script helpers for slide thumbnails. + function buildThumbnailPreviewData(slide) { return { thumbnailPreview: true, diff --git a/src/rbac.js b/src/rbac.js index 30a426d..b1c134a 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -1,3 +1,5 @@ +// Permission section definitions and normalization helpers for RBAC. + const PERMISSION_SECTIONS = [ { key: 'dashboard', @@ -31,6 +33,18 @@ const PERMISSION_SECTIONS = [ { key: 'delete', name: 'Delete', description: 'Delete screens.' } ] }, + { + key: 'announcements', + order: 35, + name: 'Announcements', + sectionName: 'Content', + actions: [ + { key: 'read', name: 'Read', description: 'View announcements.' }, + { key: 'create', name: 'Create', description: 'Create new announcements.' }, + { key: 'update', name: 'Update', description: 'Update announcements.' }, + { key: 'delete', name: 'Delete', description: 'Delete announcements.' } + ] + }, { key: 'playlists', order: 40, @@ -103,6 +117,17 @@ const PERMISSION_SECTIONS = [ { key: 'delete', name: 'Delete', description: 'Delete API sources.' } ] }, + { + key: 'fonts', + order: 95, + name: 'Fonts', + sectionName: 'Settings', + actions: [ + { key: 'read', name: 'Read', description: 'View managed fonts.' }, + { key: 'create', name: 'Create', description: 'Upload managed fonts.' }, + { key: 'delete', name: 'Delete', description: 'Remove managed fonts.' } + ] + }, { key: 'background-tasks', order: 100, @@ -236,15 +261,22 @@ function hasAnyPermission(currentUser, permissionKeys) { }); } -function requirePermission(permissionKey) { +function requirePermission(permissionKey, options) { const normalizedPermissionKey = normalizePermissionKey(permissionKey); if (!normalizedPermissionKey) { throw new Error('requirePermission requires a permission key.'); } + const setAuthMessageCookie = options && options.setAuthMessageCookie; + return function (req, res, next) { if (!req.currentUser) { - return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.')); + if (typeof setAuthMessageCookie === 'function') { + setAuthMessageCookie(res, 'Please sign in to continue.'); + return res.redirect('/login'); + } + + return res.redirect('/login'); } if (hasPermission(req.currentUser, normalizedPermissionKey)) { diff --git a/src/request-auth.js b/src/request-auth.js index ac266b3..62c5bf3 100644 --- a/src/request-auth.js +++ b/src/request-auth.js @@ -1,3 +1,5 @@ +// Page and request authentication signing helpers. + const crypto = require('crypto'); const PAGE_TOKEN_HEADER = 'x-pulse-page-auth'; diff --git a/src/web.js b/src/web.js index 911cd2a..85248aa 100644 --- a/src/web.js +++ b/src/web.js @@ -1,32 +1,29 @@ +// Main web application bootstrap, route wiring, and lifecycle setup. + const express = require('express'); const http = require('http'); const multer = require('multer'); const fs = require('fs'); -const path = require('path'); const crypto = require('crypto'); const common = require('./common'); -const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth'); -const pages = require('./web/pages'); -const registerAuthRoutes = require('./web/routes/auth'); -const registerAdminPagesRoutes = require('./web/routes/admin/pages'); -const registerAdminAccountRoutes = require('./web/routes/admin/account'); -const registerAdminUsersRoutes = require('./web/routes/admin/users'); -const registerAdminManageRoutes = require('./web/routes/admin/manage'); -const registerAdminScreenCommandRoutes = require('./web/routes/admin/client-commands'); -const registerAdminContentRoutes = require('./web/routes/admin/content'); -const registerAdminDataSourceRoutes = require('./web/routes/data-sources'); -const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks'); -const { createBackgroundTaskQueue } = require('./web/lib/background-task-queue'); -const { createBackgroundTaskSetup } = require('./web/lib/background-task-setup'); -const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails'); -const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh'); -const { createWebBootstrap } = require('./web/bootstrap'); -const { requirePermission } = require('./rbac'); -const rbacData = require('./web/lib/rbac-data'); -const { createPlayerActionService } = require('./web/lib/player-actions'); -const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check'); -const { createSessionService } = require('./web/lib/session'); -const { hasAnyPermission } = require('./rbac'); +const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('#src/auth'); +const pages = require('#src/web/pages'); +const registerMiddleware = require('#src/web/middleware'); +const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue'); +const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks'); +const { captureSlideThumbnail } = require('#src/web/lib/media'); +const { createWebBootstrap } = require('#src/web/bootstrap'); +const { registerRoutes } = require('#src/web/routes/register'); +const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens'); +const { initializeWebServer } = require('#src/web/lib/web-startup'); +const { createWebConfig } = require('#src/web/lib/config'); +const { requirePermission: createRequirePermission } = require('#src/rbac'); +const { rbacData } = require('#src/web/lib/auth'); +const { createPlayerActionService } = require('#src/web/lib/player-actions'); +const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check'); +const { createSessionService } = require('#src/web/lib/auth'); +const { hasAnyPermission } = require('#src/rbac'); +const { ensureFontLibrary } = require('#src/web/lib/media/font-library'); const { formatDashboardDate, readArrayField, @@ -41,57 +38,34 @@ const { fetchScreensByTemplateId, fetchOrderedPlaylistSlides, redirectAfterSave -} = require('./web/lib/helpers'); -const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, ''); -const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:8081').replace(/\/$/, ''); -const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws'); -const SESSION_COOKIE_NAME = 'digital_signage_session'; -const SESSION_MAX_AGE_DAYS = Number(process.env.SESSION_MAX_AGE_DAYS || 14); -const SESSION_MAX_AGE_MS = (Number.isFinite(SESSION_MAX_AGE_DAYS) && SESSION_MAX_AGE_DAYS > 0 ? SESSION_MAX_AGE_DAYS : 14) * 24 * 60 * 60 * 1000; +} = require('#src/web/lib/helpers'); async function start() { + // Core app and filesystem setup. const app = express(); const server = http.createServer(app); const pool = common.createPool(); - const PORT = Number(process.env.WEB_PORT || 8080); - const MEDIA_DIR = path.join(__dirname, '..', 'media'); - const UPLOADS_DIR = path.join(MEDIA_DIR, 'uploads'); - const THUMBNAILS_DIR = path.join(MEDIA_DIR, 'thumbnails'); - const ASSET_DIR = path.join(__dirname, 'web', 'public'); - const WEB_BASE_URL = (process.env.WEB_PUBLIC_BASE_URL || process.env.WEB_BASE_URL || ('http://127.0.0.1:' + PORT)).replace(/\/$/, ''); - const DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250)); + const webConfig = createWebConfig(); + + // Background services and request forwarding helpers. const backgroundTaskQueue = createBackgroundTaskQueue({ pool: pool, maxConcurrent: 1 }); const playerActionService = createPlayerActionService({ + pool: pool, common: common, - playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL + playerInternalBaseUrl: webConfig.playerInternalBaseUrl }); - const notifyPlayerScreens = function (slugs, commandOrPayload) { - const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) { - return String(slug || '').trim(); - }).filter(Boolean))); + const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand); - if (!uniqueSlugs.length) { - return Promise.resolve(0); - } - - return Promise.allSettled(uniqueSlugs.map(function (slug) { - return playerActionService.forwardPlayerCommand(slug, commandOrPayload || 'refresh'); - })).then(function (results) { - return results.filter(function (result) { - return result.status === 'fulfilled'; - }).length; - }); - }; + // Centralized dashboard/player bootstrap. const webBootstrap = createWebBootstrap({ pool: pool, common: common, - playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, - playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, - uploadDir: UPLOADS_DIR, - dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000), + playerInternalBaseUrl: webConfig.playerInternalBaseUrl, + playerPublicBaseUrl: webConfig.playerPublicBaseUrl, + uploadDir: webConfig.uploadsDir, formatDashboardDate: formatDashboardDate, notifyPlayerScreens: notifyPlayerScreens, backgroundTaskQueue: backgroundTaskQueue, @@ -102,214 +76,80 @@ async function start() { const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate; const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload; const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles; - const collectUploadPathsFromDirectory = webBootstrap.collectUploadPathsFromDirectory; const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange; - const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer; - const runMediaSyncTask = webBootstrap.runMediaSyncTask; - const backgroundTaskSetup = createBackgroundTaskSetup({ - pool: pool, - common: common, - backgroundTaskQueue: backgroundTaskQueue, - collectUploadPathsFromDirectory: collectUploadPathsFromDirectory, - removeUnusedUploadFiles: removeUnusedUploadFiles, - captureSlideThumbnail: captureSlideThumbnail, - refreshApiSource: refreshApiSource, - refreshRssFeed: refreshRssFeed, - runMediaSyncTask: runMediaSyncTask, - mediaDir: MEDIA_DIR, - uploadsDir: UPLOADS_DIR, - playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, - dataSourceStartupRefreshStaggerMs: DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS - }); + + // Background task initialization. + // Session and auth helpers for route registration. const broadcastDashboardState = webBootstrap.broadcastDashboardState; const sessionService = createSessionService({ - sessionCookieName: SESSION_COOKIE_NAME, - sessionMaxAgeMs: SESSION_MAX_AGE_MS, + sessionCookieName: webConfig.sessionCookieName, + sessionMaxAgeMs: webConfig.sessionMaxAgeMs, hashSessionToken: hashSessionToken, createSessionToken: createSessionToken }); const parseCookies = sessionService.parseCookies; + const createUserSession = sessionService.createUserSession; const clearSessionCookie = sessionService.clearSessionCookie; const setSessionCookie = sessionService.setSessionCookie; + const setAuthMessageCookie = sessionService.setAuthMessageCookie; + const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie; const loadCurrentUser = sessionService.loadCurrentUser; - const createUserSession = sessionService.createUserSession; const requireAuth = sessionService.requireAuth; - - app.use(express.urlencoded({ extended: true })); - app.use(express.json()); - app.use('/assets', express.static(ASSET_DIR)); - app.use('/assets/vendor/cropperjs', express.static(path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist'))); - app.use('/media', express.static(MEDIA_DIR)); - fs.mkdirSync(UPLOADS_DIR, { recursive: true }); - fs.mkdirSync(THUMBNAILS_DIR, { recursive: true }); - - app.use(async function (req, _res, next) { - try { - req.currentUser = await loadCurrentUser(pool, req); - next(); - } catch (error) { - next(error); - } - }); - - app.use(function (req, res, next) { - if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) { - return next(); - } - - return requireAuth(req, res, next); - }); - - registerAuthRoutes(app, { + // Middleware and route registration. + registerMiddleware(app, { pool: pool, + loadCurrentUser: loadCurrentUser, + requireAuth: requireAuth, + MEDIA_DIR: webConfig.mediaDir, + UPLOADS_DIR: webConfig.uploadsDir, + THUMBNAILS_DIR: webConfig.thumbnailsDir, + ASSET_DIR: webConfig.assetDir + }); + + registerRoutes(app, { + pool: pool, + common: common, pages: pages, + upload: upload, + uploadDir: webConfig.uploadsDir, createUserSession: createUserSession, setSessionCookie: setSessionCookie, clearSessionCookie: clearSessionCookie, + setAuthMessageCookie: setAuthMessageCookie, + consumeAuthMessageCookie: consumeAuthMessageCookie, parseCookies: parseCookies, hashSessionToken: hashSessionToken, verifyPassword: verifyPassword, - sessionCookieName: SESSION_COOKIE_NAME - }); - - registerAdminPagesRoutes(app, { - pool: pool, - common: common, - pages: pages, - requirePermission: requirePermission, - buildDashboardState: webBootstrap.buildDashboardState - }); - - registerAdminAccountRoutes(app, { - pool: pool, - common: common, - pages: pages, - formatDashboardDate: formatDashboardDate, - getAuditUserId: getAuditUserId, - verifyPassword: verifyPassword, - hashPassword: hashPassword, - createUserSession: createUserSession, - setSessionCookie: setSessionCookie - }); - - registerAdminUsersRoutes(app, { - pool: pool, - common: common, - pages: pages, + sessionCookieName: webConfig.sessionCookieName, formatDashboardDate: formatDashboardDate, getAuditUserId: getAuditUserId, hashPassword: hashPassword, readArrayField: readArrayField, rbacData: rbacData, - requirePermission: requirePermission - }); - - registerAdminManageRoutes(app, { - pool: pool, - common: common, - pages: pages, fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides, fetchScreensByPlaylistId: fetchScreensByPlaylistId, + fetchScreensBySlideId: fetchScreensBySlideId, + fetchScreensByTemplateId: fetchScreensByTemplateId, fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature, getCanvasSignature: getCanvasSignature, normalizeScheduleMode: normalizeScheduleMode, parseDateTimeLocal: parseDateTimeLocal, parseTimeLocal: parseTimeLocal, - readArrayField: readArrayField, - getAuditUserId: getAuditUserId, redirectAfterSave: redirectAfterSave, notifyPlayerScreens: notifyPlayerScreens, broadcastDashboardState: broadcastDashboardState, - getScreenDeleteBlockMessage: playerActionService.getScreenDeleteBlockMessage, - getScreenConnections: playerActionService.getScreenConnections, - getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage, - forwardPlayerCommand: playerActionService.forwardPlayerCommand, - playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, - requirePermission: requirePermission - }); - - registerAdminScreenCommandRoutes(app, { - pool: pool, - forwardPlayerCommand: playerActionService.forwardPlayerCommand, - getScreenConnections: playerActionService.getScreenConnections, + playerActionService: playerActionService, isClientNameAvailable: isClientNameAvailable, withClientNameReservation: withClientNameReservation, - broadcastDashboardState: broadcastDashboardState, - requirePermission: requirePermission - }); - - const registerAdminRbacRoutes = require('./web/routes/admin/rbac'); - registerAdminRbacRoutes(app, { - pool: pool, - common: common, - pages: pages, - getAuditUserId: getAuditUserId, - rbacData: rbacData, - readArrayField: readArrayField, - permissions: require('./rbac').PERMISSIONS, - normalizePermissionKeys: require('./rbac').normalizePermissionKeys, - requirePermission: requirePermission - }); - - registerAdminContentRoutes(app, { - pool: pool, - common: common, - pages: pages, - upload: upload, - uploadDir: UPLOADS_DIR, - fetchScreensBySlideId: fetchScreensBySlideId, - fetchScreensByTemplateId: fetchScreensByTemplateId, + requirePermission: createRequirePermission, + hasAnyPermission: hasAnyPermission, + backgroundTaskQueue: backgroundTaskQueue, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, removeUnusedUploadFiles: removeUnusedUploadFiles, syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, - getAuditUserId: getAuditUserId, - redirectAfterSave: redirectAfterSave, - notifyPlayerScreens: notifyPlayerScreens, - broadcastDashboardState: broadcastDashboardState, - backgroundTaskQueue: backgroundTaskQueue, - hasAnyPermission: hasAnyPermission, - getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage, - getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage, - getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage, - requirePermission: requirePermission - }); - - registerAdminDataSourceRoutes(app, { - pool: pool, - common: common, - pages: pages, - formatDashboardDate: formatDashboardDate, - getAuditUserId: getAuditUserId, - redirectAfterSave: redirectAfterSave, - fetchRssFeedItems: common.fetchRssFeedItems, - backgroundTaskQueue: backgroundTaskQueue, - requirePermission: requirePermission - }); - - registerAdminSettingsRoutes(app, { - pages: pages, - backgroundTaskQueue: backgroundTaskQueue - }); - - - app.use(function (req, res, next) { - const pathName = String(req.originalUrl || ''); - const wantsHtml = !pathName.startsWith('/api/') && (!req.accepts || req.accepts('html')); - - if (!wantsHtml) { - return next(); - } - - return res.status(404).send(pages.renderErrorPage({ - statusCode: 404, - title: 'Not found', - errorTitle: 'Oops! Page not found.', - message: 'We could not find the page you were looking for.', - backUrl: req.currentUser ? '/dashboard' : '/login', - backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in' - }, req.currentUser)); + buildDashboardState: webBootstrap.buildDashboardState }); app.use(function (error, req, res, _next) { @@ -343,20 +183,24 @@ async function start() { res.status(statusCode).send(statusCode >= 500 ? 'Internal server error' : String(error && error.message ? error.message : 'Error')); }); - // Ensure schema and mirror media before the web service starts handling traffic. - await common.ensureSchema(pool, { mediaDir: MEDIA_DIR }); - await common.bootstrapDatabase(pool); - await backgroundTaskQueue.initialize(); - await backgroundTaskSetup.initialize(); - - fs.mkdirSync(MEDIA_DIR, { recursive: true }); - await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) { - console.warn('Unable to sync existing media to player:', error); + // Bootstrap and startup tasks. + fs.mkdirSync(webConfig.mediaDir, { recursive: true }); + await ensureFontLibrary(webConfig.mediaDir); + await initializeWebServer({ + common: common, + pool: pool, + mediaDir: webConfig.mediaDir, + backgroundTaskQueue: backgroundTaskQueue, + webBootstrap: webBootstrap, + loadCurrentUser: loadCurrentUser, + initializeBackgroundTasks: initializeBackgroundTasks, + captureSlideThumbnail: captureSlideThumbnail, + server: server, + dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs }); - webBootstrap.installDashboardWebsocket(server, loadCurrentUser); - server.listen(PORT, function () { - console.log(`Pulse Signage app listening on port ${PORT}`); + server.listen(webConfig.port, function () { + console.log(`Pulse Signage app listening on port ${webConfig.port}`); }); } diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js index abe04c3..3a77c77 100644 --- a/src/web/bootstrap.js +++ b/src/web/bootstrap.js @@ -1,15 +1,17 @@ +// Web-to-player websocket bridge and dashboard state synchronizer. + const { WebSocketServer, WebSocket } = require('ws'); const { createDashboardStateService } = require('./lib/dashboard-state'); -const { createUploadSyncService } = require('./lib/upload-sync'); +const { createUploadSyncService } = require('./lib/media'); const { createRequestAuthHeaders } = require('../request-auth'); function createWebBootstrap(options) { const pool = options && options.pool; const common = options && options.common; - const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); + const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, ''); const uploadDir = String(options && options.uploadDir || '').trim(); - const dashboardRefreshIntervalMs = Number(options && options.dashboardRefreshIntervalMs || 2000); + const dashboardRefreshIntervalMs = 5000; const formatDashboardDate = options && options.formatDashboardDate; const notifyPlayerScreens = options && options.notifyPlayerScreens; const backgroundTaskQueue = options && options.backgroundTaskQueue; @@ -24,9 +26,54 @@ function createWebBootstrap(options) { const playerSnapshotSockets = new Map(); let dashboardRefreshInFlight = null; let broadcastDashboardState = null; + let playerInternalBaseUrl = null; + let playerInternalBaseUrlPromise = null; - function getPlayerSnapshotSocketUrl(slug) { - const url = new URL(playerInternalBaseUrl.replace(/^http/, 'ws')); + async function getPlayerInternalBaseUrl() { + if (playerInternalBaseUrl) { + return playerInternalBaseUrl; + } + + if (playerInternalBaseUrlPromise) { + return playerInternalBaseUrlPromise; + } + + playerInternalBaseUrlPromise = (async function () { + if (!pool) { + return configuredPlayerInternalBaseUrl || null; + } + + try { + const [rows] = await pool.query( + `SELECT internal_base_url + FROM d_players + WHERE device_id = '1' + LIMIT 1` + ); + const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, ''); + return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null; + } catch (_error) { + return configuredPlayerInternalBaseUrl || null; + } + })().then(function (baseUrl) { + playerInternalBaseUrl = baseUrl || null; + playerInternalBaseUrlPromise = null; + return playerInternalBaseUrl; + }, function () { + playerInternalBaseUrlPromise = null; + return configuredPlayerInternalBaseUrl || null; + }); + + return playerInternalBaseUrlPromise; + } + + async function getPlayerSnapshotSocketUrl(slug) { + const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); + if (!resolvedPlayerInternalBaseUrl) { + throw new Error('Unable to resolve the player internal base URL.'); + } + + const url = new URL(resolvedPlayerInternalBaseUrl.replace(/^http/, 'ws')); url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`; url.search = ''; return url.toString(); @@ -53,47 +100,53 @@ function createWebBootstrap(options) { return; } - const socketUrl = getPlayerSnapshotSocketUrl(key); + playerSnapshotSockets.set(key, null); + const socketUrlPromise = getPlayerSnapshotSocketUrl(key); const authHeaders = createRequestAuthHeaders({ method: 'GET', pathname: `/ws/screens/${encodeURIComponent(key)}/events` }); - const socket = new WebSocket(socketUrl, { - headers: authHeaders - }); - playerSnapshotSockets.set(key, socket); + socketUrlPromise.then(function (socketUrl) { + const socket = new WebSocket(socketUrl, { + headers: authHeaders + }); + playerSnapshotSockets.set(key, socket); - socket.onmessage = function (event) { - try { - const payload = JSON.parse(String(event.data || '{}')); - if (!payload || payload.type !== 'snapshot' || payload.slug !== key) { - return; + socket.onmessage = function (event) { + try { + const payload = JSON.parse(String(event.data || '{}')); + if (!payload || payload.type !== 'snapshot' || payload.slug !== key) { + return; + } + storePlayerSnapshot(key, payload.connections || []); + if (broadcastDashboardState) { + broadcastDashboardState().catch(function (error) { + console.error(error); + }); + } + } catch (_error) { + // Ignore malformed player snapshot payloads. } - storePlayerSnapshot(key, payload.connections || []); - if (broadcastDashboardState) { - broadcastDashboardState().catch(function (error) { - console.error(error); - }); - } - } catch (_error) { - // Ignore malformed player snapshot payloads. - } - }; + }; - socket.onclose = function () { + socket.onclose = function () { + clearPlayerSnapshotSocket(key); + setTimeout(function () { + ensurePlayerSnapshotSubscription(key); + }, 2000); + }; + + socket.onerror = function () { + try { + socket.close(); + } catch (_error) { + // ignore close errors + } + }; + }).catch(function (error) { clearPlayerSnapshotSocket(key); - setTimeout(function () { - ensurePlayerSnapshotSubscription(key); - }, 2000); - }; - - socket.onerror = function () { - try { - socket.close(); - } catch (_error) { - // ignore close errors - } - }; + console.error(error); + }); } const dashboardStateService = createDashboardStateService({ @@ -110,7 +163,7 @@ function createWebBootstrap(options) { const uploadSyncService = createUploadSyncService({ pool: pool, common: common, - playerInternalBaseUrl: playerInternalBaseUrl, + playerInternalBaseUrl: configuredPlayerInternalBaseUrl, playerSnapshotCache: playerSnapshotCache, notifyPlayerScreens: notifyPlayerScreens, backgroundTaskQueue: backgroundTaskQueue @@ -123,7 +176,6 @@ function createWebBootstrap(options) { const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles; const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory; const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange; - const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer; const runMediaSyncTask = uploadSyncService.runMediaSyncTask; async function sendDashboardStateToSocket(socket) { @@ -208,6 +260,7 @@ function createWebBootstrap(options) { return { upload: upload, + uploadSyncService: uploadSyncService, buildDashboardState: buildDashboardState, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, @@ -215,7 +268,6 @@ function createWebBootstrap(options) { removeUnusedUploadFiles: removeUnusedUploadFiles, collectUploadPathsFromDirectory: collectUploadPathsFromDirectory, syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, - syncExistingUploadsToPlayer: syncExistingUploadsToPlayer, runMediaSyncTask: runMediaSyncTask, broadcastDashboardState: broadcastDashboardState, installDashboardWebsocket: installDashboardWebsocket diff --git a/src/web/handlebars.js b/src/web/handlebars.js new file mode 100644 index 0000000..9e0d42c --- /dev/null +++ b/src/web/handlebars.js @@ -0,0 +1,144 @@ +const fs = require('fs'); +const path = require('path'); + +// General-purpose helpers used across the admin UI. +function registerGeneralHelpers(Handlebars) { + Handlebars.registerHelper('eq', function (left, right) { + return left === right; + }); + + Handlebars.registerHelper('gt', function (left, right) { + return left > right; + }); + + Handlebars.registerHelper('lt', function (left, right) { + return left < right; + }); + + Handlebars.registerHelper('json', function (value) { + return new Handlebars.SafeString(JSON.stringify(value).replace(/', + ``, + hasSecondaryActions ? `` : '', + hasSecondaryActions ? '' : '', + '', + ].join('')); + }); +} + +// Shared partials are registered once at startup. +function registerPartials(Handlebars, viewsRoot) { + Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8')); + Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8')); +} + +// One entry point keeps Handlebars bootstrap centralized. +function registerHandlebars(Handlebars, deps) { + const viewsRoot = String(deps && deps.viewsRoot || '').trim(); + const hasPermission = deps && deps.hasPermission; + const hasAnyPermission = deps && deps.hasAnyPermission; + + if (!Handlebars || !viewsRoot || typeof hasPermission !== 'function' || typeof hasAnyPermission !== 'function') { + throw new Error('registerHandlebars requires Handlebars, viewsRoot, hasPermission, and hasAnyPermission.'); + } + + registerGeneralHelpers(Handlebars); + registerUrlHelpers(Handlebars); + registerPermissionHelpers(Handlebars, { hasPermission: hasPermission, hasAnyPermission: hasAnyPermission }); + registerUserHelpers(Handlebars); + registerActionHelpers(Handlebars); + registerPartials(Handlebars, viewsRoot); +} + +module.exports = { + registerHandlebars: registerHandlebars +}; \ No newline at end of file diff --git a/src/web/lib/auth/index.js b/src/web/lib/auth/index.js new file mode 100644 index 0000000..ed712bb --- /dev/null +++ b/src/web/lib/auth/index.js @@ -0,0 +1,6 @@ +// Entry point for the web auth helper modules. + +module.exports = { + createSessionService: require('./session').createSessionService, + rbacData: require('./rbac-data') +}; \ No newline at end of file diff --git a/src/web/lib/rbac-data.js b/src/web/lib/auth/rbac-data.js similarity index 97% rename from src/web/lib/rbac-data.js rename to src/web/lib/auth/rbac-data.js index a8fd585..7c8706f 100644 --- a/src/web/lib/rbac-data.js +++ b/src/web/lib/auth/rbac-data.js @@ -1,5 +1,7 @@ -const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac'); -const { fetchPagedRows } = require('../../data/utils'); +// RBAC data access helpers for roles, permissions, and user-role links. + +const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac'); +const { fetchPagedRows } = require('#src/data/utils'); function parseCsvIds(value) { return String(value || '') diff --git a/src/web/lib/session.js b/src/web/lib/auth/session.js similarity index 73% rename from src/web/lib/session.js rename to src/web/lib/auth/session.js index edf48a1..61eda43 100644 --- a/src/web/lib/session.js +++ b/src/web/lib/auth/session.js @@ -1,10 +1,13 @@ -const { normalizePermissionKeys } = require('../../rbac'); +// Session cookie and current-user helpers for the web auth flow. + +const { normalizePermissionKeys } = require('#src/rbac'); function createSessionService(options) { const sessionCookieName = String(options && options.sessionCookieName || '').trim(); const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs); const hashSessionToken = options && options.hashSessionToken; const createSessionToken = options && options.createSessionToken; + const authMessageCookieName = 'pulse_auth_message'; if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') { throw new Error('createSessionService requires the session dependencies.'); @@ -39,12 +42,37 @@ function createSessionService(options) { return parts.join('; '); } + function appendCookieHeader(res, cookieValue) { + const existingCookieHeader = res.getHeader('Set-Cookie'); + if (!existingCookieHeader) { + res.setHeader('Set-Cookie', cookieValue); + return; + } + + const cookies = Array.isArray(existingCookieHeader) ? existingCookieHeader.slice() : [existingCookieHeader]; + cookies.push(cookieValue); + res.setHeader('Set-Cookie', cookies); + } + function clearSessionCookie(res) { - res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, '', { maxAge: 0 })); + appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 })); } function setSessionCookie(res, token) { - res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs })); + appendCookieHeader(res, serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs })); + } + + function setAuthMessageCookie(res, message) { + appendCookieHeader(res, serializeCookie(authMessageCookieName, message, { maxAge: 60 * 1000 })); + } + + function consumeAuthMessageCookie(req, res) { + const cookies = parseCookies(req.headers.cookie || ''); + const message = String(cookies[authMessageCookieName] || '').trim(); + if (message) { + appendCookieHeader(res, serializeCookie(authMessageCookieName, '', { maxAge: 0 })); + } + return message; } async function loadCurrentUser(pool, req) { @@ -113,7 +141,8 @@ function createSessionService(options) { if (req.currentUser) { return next(); } - res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.')); + setAuthMessageCookie(res, 'Please sign in to continue.'); + res.redirect('/login'); } return { @@ -121,6 +150,8 @@ function createSessionService(options) { serializeCookie: serializeCookie, clearSessionCookie: clearSessionCookie, setSessionCookie: setSessionCookie, + setAuthMessageCookie: setAuthMessageCookie, + consumeAuthMessageCookie: consumeAuthMessageCookie, loadCurrentUser: loadCurrentUser, createUserSession: createUserSession, requireAuth: requireAuth diff --git a/src/web/lib/background-task-handlers.js b/src/web/lib/background-task-handlers.js deleted file mode 100644 index 69e5500..0000000 --- a/src/web/lib/background-task-handlers.js +++ /dev/null @@ -1,93 +0,0 @@ -function registerBackgroundTaskHandlers(options) { - const pool = options && options.pool; - const common = options && options.common; - const backgroundTaskQueue = options && options.backgroundTaskQueue; - const captureSlideThumbnail = options && options.captureSlideThumbnail; - const refreshApiSource = options && options.refreshApiSource; - const refreshRssFeed = options && options.refreshRssFeed; - const runMediaSyncTask = options && options.runMediaSyncTask; - const mediaDir = String(options && options.mediaDir || '').trim(); - const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').trim(); - - if (!pool || !common || !backgroundTaskQueue || !captureSlideThumbnail || !refreshApiSource || !refreshRssFeed || !runMediaSyncTask || !mediaDir || !playerInternalBaseUrl) { - throw new Error('registerBackgroundTaskHandlers requires the background task dependencies.'); - } - - backgroundTaskQueue.setTaskHandler('media-sync', function (task) { - return runMediaSyncTask(task && task.payload ? task.payload : task); - }); - - backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) { - const payload = task && task.payload ? task.payload : {}; - const sourceType = String(payload.sourceType || '').trim(); - const sourceId = Number(payload.sourceId || 0); - - if (sourceType === 'api-source') { - const apiSource = await common.fetchApiSourceById(pool, sourceId); - if (!apiSource) { - throw new Error('API source not found.'); - } - return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null); - } - - if (sourceType === 'rss-feed') { - const rssFeed = await common.fetchRssFeedById(pool, sourceId); - if (!rssFeed) { - throw new Error('RSS feed not found.'); - } - return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null); - } - - throw new Error('Unsupported data source refresh task.'); - }); - - backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) { - const payload = task && task.payload ? task.payload : {}; - const slideId = Number(payload.slideId || 0); - if (!Number.isFinite(slideId) || slideId <= 0) { - throw new Error('Slide id is required.'); - } - - return captureSlideThumbnail({ - pool: pool, - common: common, - mediaDir: mediaDir, - baseUrl: playerInternalBaseUrl, - slideId: slideId, - previousThumbnailPath: payload.previousThumbnailPath || null - }); - }); - - backgroundTaskQueue.setTaskHandler('template-slide-thumbnail-refresh', async function (task) { - const payload = task && task.payload ? task.payload : {}; - const templateId = Number(payload.templateId || 0); - if (!Number.isFinite(templateId) || templateId <= 0) { - throw new Error('Template id is required.'); - } - - const [slides] = await pool.query( - 'SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC', - [templateId] - ); - - for (const slide of slides || []) { - const slideId = Number(slide && slide.id || 0); - if (!Number.isFinite(slideId) || slideId <= 0) { - continue; - } - - await captureSlideThumbnail({ - pool: pool, - common: common, - mediaDir: mediaDir, - baseUrl: playerInternalBaseUrl, - slideId: slideId, - previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null - }); - } - - return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 }; - }); -} - -module.exports = { registerBackgroundTaskHandlers }; \ No newline at end of file diff --git a/src/web/lib/background-task-queue.js b/src/web/lib/background-task-queue.js deleted file mode 100644 index 2cda656..0000000 --- a/src/web/lib/background-task-queue.js +++ /dev/null @@ -1,774 +0,0 @@ -function normalizeText(value) { - return String(value || '').trim(); -} - -function toIsoDate(value) { - if (!value) { - return ''; - } - - const date = value instanceof Date ? value : new Date(value); - return Number.isNaN(date.getTime()) ? '' : date.toISOString(); -} - -function normalizeIntervalMs(value, unit) { - const numericValue = Math.max(1, Number(value) || 0); - const normalizedUnit = String(unit || 'minutes').trim().toLowerCase(); - if (normalizedUnit === 'seconds') { - return numericValue * 1000; - } - return numericValue * 60 * 1000; -} - -function createBackgroundTaskQueue(options) { - const pool = options && options.pool; - const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1); - const taskHandlers = new Map(); - const tasksById = new Map(); - const recurringJobsByKey = new Map(); - const taskIdToRecurringKey = new Map(); - const pendingIds = []; - let nextTaskId = 1; - let activeCount = 0; - let drainScheduled = false; - let initializationPromise = null; - - function createTaskCompletionController() { - let resolveCompletion = null; - let rejectCompletion = null; - const completionPromise = new Promise(function (resolve, reject) { - resolveCompletion = resolve; - rejectCompletion = reject; - }); - - return { - promise: completionPromise, - resolve: resolveCompletion, - reject: rejectCompletion - }; - } - - function parseJsonValue(value, fallback) { - if (value === null || value === undefined || value === '') { - return fallback; - } - - if (typeof value === 'object') { - return value; - } - - try { - return JSON.parse(String(value)); - } catch (_error) { - return fallback; - } - } - - function stringifyJsonValue(value) { - if (value === undefined || value === null) { - return null; - } - - return JSON.stringify(value); - } - - function buildSnapshot(task) { - return { - id: task.id, - key: task.key, - taskType: task.taskType || '', - title: task.title, - category: task.category, - status: task.status, - createdAt: task.createdAt, - startedAt: task.startedAt, - finishedAt: task.finishedAt, - errorMessage: task.errorMessage, - attempts: task.attempts || 0, - metadata: task.metadata, - payload: task.payload || null - }; - } - - function getTaskById(taskId) { - const numericTaskId = Number(taskId); - if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) { - return null; - } - - return tasksById.get(numericTaskId) || null; - } - - function buildTaskFromRow(row) { - const task = { - id: Number(row.id), - key: String(row.task_key || '').trim(), - taskType: String(row.task_type || '').trim(), - title: String(row.title || 'Background task').trim() || 'Background task', - category: String(row.category || 'general').trim() || 'general', - status: String(row.status || 'queued').trim() || 'queued', - createdAt: row.created_at ? new Date(row.created_at).toISOString() : '', - startedAt: row.started_at ? new Date(row.started_at).toISOString() : '', - finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '', - errorMessage: String(row.error_message || ''), - attempts: Math.max(0, Number(row.attempts) || 0), - metadata: parseJsonValue(row.metadata_json, {}), - payload: parseJsonValue(row.payload_json, null), - completionPromise: null, - resolveCompletion: null, - rejectCompletion: null, - persisted: true, - run: typeof row.run === 'function' ? row.run : function () { - return Promise.resolve(); - } - }; - - const completionController = createTaskCompletionController(); - task.completionPromise = completionController.promise; - task.resolveCompletion = completionController.resolve; - task.rejectCompletion = completionController.reject; - return task; - } - - function buildTaskRecord(task) { - return { - task_key: task.key || null, - task_type: task.taskType || 'general', - title: task.title, - category: task.category || 'general', - status: task.status, - payload_json: stringifyJsonValue(task.payload), - metadata_json: stringifyJsonValue(task.metadata), - attempts: Math.max(0, Number(task.attempts) || 0), - created_at: task.createdAt ? new Date(task.createdAt) : new Date(), - started_at: task.startedAt ? new Date(task.startedAt) : null, - finished_at: task.finishedAt ? new Date(task.finishedAt) : null, - error_message: task.errorMessage || null - }; - } - - async function persistTaskInsert(task) { - if (!pool || !task.taskType) { - return task; - } - - const record = buildTaskRecord(task); - const [result] = await pool.query( - `INSERT INTO o_background_tasks ( - task_key, - task_type, - title, - category, - status, - payload_json, - metadata_json, - attempts, - created_at, - started_at, - finished_at, - error_message - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - record.task_key, - record.task_type, - record.title, - record.category, - record.status, - record.payload_json, - record.metadata_json, - record.attempts, - record.created_at, - record.started_at, - record.finished_at, - record.error_message - ] - ); - - task.id = Number(result.insertId); - task.persisted = true; - return task; - } - - async function persistTaskUpdate(task) { - if (!pool || !task.persisted) { - return; - } - - const record = buildTaskRecord(task); - await pool.query( - `UPDATE o_background_tasks - SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ? - WHERE id = ?`, - [ - record.task_key, - record.task_type, - record.title, - record.category, - record.status, - record.payload_json, - record.metadata_json, - record.attempts, - record.started_at, - record.finished_at, - record.error_message, - task.id - ] - ); - } - - async function persistTaskDelete(taskId) { - if (!pool) { - return; - } - - await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [taskId]); - } - - async function initialize() { - if (initializationPromise) { - return initializationPromise; - } - - initializationPromise = (async function () { - if (!pool) { - return; - } - - const [rows] = await pool.query( - 'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM o_background_tasks ORDER BY id ASC' - ); - - let highestTaskId = 0; - for (const row of rows || []) { - const task = buildTaskFromRow(row); - if (!Number.isInteger(task.id) || task.id <= 0) { - continue; - } - - highestTaskId = Math.max(highestTaskId, task.id); - tasksById.set(task.id, task); - - if (task.status === 'running') { - task.status = 'queued'; - task.startedAt = ''; - task.finishedAt = ''; - task.errorMessage = ''; - await pool.query( - 'UPDATE o_background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?', - ['queued', task.id] - ); - } - - if (task.status === 'queued' || task.status === 'running') { - pendingIds.push(task.id); - } - } - - nextTaskId = Math.max(nextTaskId, highestTaskId + 1); - if (pendingIds.length) { - scheduleDrain(); - } - })(); - - return initializationPromise; - } - - function scheduleDrain() { - if (drainScheduled) { - return; - } - - drainScheduled = true; - setTimeout(function () { - drainScheduled = false; - processQueue(); - }, 0); - } - - function clearRecurringTimer(job) { - if (job && job.timerId) { - clearTimeout(job.timerId); - job.timerId = null; - } - } - - function setTaskHandler(taskType, handler) { - const normalizedTaskType = normalizeText(taskType); - if (!normalizedTaskType || typeof handler !== 'function') { - return false; - } - - taskHandlers.set(normalizedTaskType, handler); - return true; - } - - function scheduleRecurringRun(job, delayMs) { - if (!job || job.enabled === false) { - return; - } - - clearRecurringTimer(job); - const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0); - job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay)); - job.timerId = setTimeout(function () { - job.timerId = null; - triggerRecurringJob(job.key); - }, safeDelay); - } - - function triggerRecurringJob(recurringKey) { - const job = recurringJobsByKey.get(recurringKey); - if (!job || job.enabled === false) { - return; - } - - if (job.activeTaskId && tasksById.has(job.activeTaskId)) { - scheduleRecurringRun(job, job.intervalMs); - return; - } - - job.activeTaskId = -1; - enqueueTask({ - key: `${job.key}:${Date.now()}`, - title: job.title, - category: job.category, - taskType: job.taskType || '', - metadata: Object.assign({}, job.metadata || {}, { - recurringKey: job.key, - recurringTitle: job.title - }), - payload: Object.assign({}, job.payload || {}, { - recurringKey: job.key - }), - run: job.run, - persist: Boolean(job.taskType) - }).then(function (task) { - if (task && Number.isInteger(task.id)) { - job.activeTaskId = task.id; - taskIdToRecurringKey.set(task.id, job.key); - } else { - job.activeTaskId = null; - } - }).catch(function (error) { - job.activeTaskId = null; - console.error(error); - }); - - scheduleRecurringRun(job, job.intervalMs); - } - - function runRecurringTask(recurringKey) { - const normalizedKey = normalizeText(recurringKey); - if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) { - return false; - } - - triggerRecurringJob(normalizedKey); - return true; - } - - function syncRecurringTaskState(task, status, errorMessage) { - const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey); - if (!recurringKey) { - return; - } - - const job = recurringJobsByKey.get(recurringKey); - if (!job) { - taskIdToRecurringKey.delete(task.id); - return; - } - - job.activeTaskId = null; - job.lastRunAt = toIsoDate(new Date()); - job.lastStatus = status; - job.lastError = errorMessage ? String(errorMessage) : ''; - taskIdToRecurringKey.delete(task.id); - } - - async function processQueue() { - while (activeCount < maxConcurrent) { - const nextTaskId = pendingIds.shift(); - if (!nextTaskId) { - break; - } - - const task = tasksById.get(nextTaskId); - if (!task || task.status !== 'queued') { - continue; - } - - activeCount += 1; - task.status = 'running'; - task.startedAt = toIsoDate(new Date()); - task.errorMessage = ''; - task.attempts = Math.max(0, Number(task.attempts) || 0) + 1; - - try { - await persistTaskUpdate(task); - } catch (error) { - task.status = 'failed'; - task.errorMessage = String(error && error.message ? error.message : 'Unable to update task state.'); - task.finishedAt = toIsoDate(new Date()); - if (typeof task.rejectCompletion === 'function') { - const completionError = new Error(task.errorMessage); - completionError.task = buildSnapshot(task); - task.rejectCompletion(completionError); - } - activeCount = Math.max(0, activeCount - 1); - scheduleDrain(); - continue; - } - - Promise.resolve() - .then(function () { - if (task.taskType) { - const handler = taskHandlers.get(task.taskType); - if (!handler) { - throw new Error('No handler registered for task type ' + task.taskType + '.'); - } - - return handler({ - id: task.id, - key: task.key, - title: task.title, - category: task.category, - taskType: task.taskType, - payload: task.payload, - metadata: task.metadata, - attempts: task.attempts - }); - } - - return task.run({ - id: task.id, - key: task.key, - title: task.title, - category: task.category, - metadata: task.metadata - }); - }) - .then(function () { - task.status = 'completed'; - task.finishedAt = toIsoDate(new Date()); - if (typeof task.resolveCompletion === 'function') { - task.resolveCompletion(buildSnapshot(task)); - } - syncRecurringTaskState(task, task.status, ''); - return persistTaskUpdate(task); - }) - .catch(function (error) { - task.status = 'failed'; - task.errorMessage = String(error && error.message ? error.message : 'Background task failed.'); - task.finishedAt = toIsoDate(new Date()); - if (typeof task.rejectCompletion === 'function') { - const completionError = new Error(task.errorMessage); - completionError.task = buildSnapshot(task); - task.rejectCompletion(completionError); - } - syncRecurringTaskState(task, task.status, task.errorMessage); - return persistTaskUpdate(task); - }) - .finally(function () { - activeCount = Math.max(0, activeCount - 1); - scheduleDrain(); - }); - } - } - - async function enqueueTask(definition) { - const normalizedKey = normalizeText(definition && definition.key); - const normalizedTitle = normalizeText(definition && definition.title) || 'Background task'; - const normalizedTaskType = normalizeText(definition && definition.taskType); - const shouldPersist = Boolean((definition && definition.persist) || (pool && normalizedTaskType)); - const existingTask = normalizedKey - ? Array.from(tasksById.values()).find(function (task) { - return task.key === normalizedKey && task.status === 'queued' && (!normalizedTaskType || task.taskType === normalizedTaskType); - }) - : null; - - if (existingTask) { - existingTask.title = normalizedTitle; - existingTask.category = normalizeText(definition && definition.category) || existingTask.category || 'general'; - existingTask.metadata = definition && definition.metadata ? definition.metadata : {}; - existingTask.taskType = normalizedTaskType || existingTask.taskType || ''; - existingTask.payload = definition && definition.payload !== undefined ? definition.payload : existingTask.payload; - existingTask.run = typeof definition.run === 'function' ? definition.run : existingTask.run; - existingTask.createdAt = toIsoDate(new Date()); - existingTask.errorMessage = ''; - existingTask.persisted = existingTask.persisted || shouldPersist; - await persistTaskUpdate(existingTask); - return buildSnapshot(existingTask); - } - - const task = { - id: shouldPersist ? 0 : nextTaskId, - key: normalizedKey, - taskType: normalizedTaskType, - title: normalizedTitle, - category: normalizeText(definition && definition.category) || 'general', - status: 'queued', - createdAt: toIsoDate(new Date()), - startedAt: '', - finishedAt: '', - errorMessage: '', - attempts: 0, - metadata: definition && definition.metadata ? definition.metadata : {}, - payload: definition && definition.payload !== undefined ? definition.payload : null, - persisted: shouldPersist, - run: typeof definition.run === 'function' ? definition.run : function () { - return Promise.resolve(); - }, - completionPromise: null, - resolveCompletion: null, - rejectCompletion: null - }; - - const completionController = createTaskCompletionController(); - task.completionPromise = completionController.promise; - task.resolveCompletion = completionController.resolve; - task.rejectCompletion = completionController.reject; - - if (shouldPersist) { - await persistTaskInsert(task); - } else { - nextTaskId += 1; - } - - tasksById.set(task.id, task); - pendingIds.push(task.id); - scheduleDrain(); - return buildSnapshot(task); - } - - function registerRecurringTask(definition) { - const normalizedKey = normalizeText(definition && definition.key); - if (!normalizedKey) { - throw new Error('Recurring tasks require a key.'); - } - - const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0); - if (!Number.isFinite(intervalMs) || intervalMs < 1000) { - throw new Error('Recurring tasks require a valid interval.'); - } - - const job = recurringJobsByKey.get(normalizedKey) || { - key: normalizedKey, - activeTaskId: null, - lastRunAt: '', - lastStatus: '', - lastError: '', - nextRunAt: '', - timerId: null, - enabled: true - }; - - clearRecurringTimer(job); - job.title = normalizeText(definition && definition.title) || 'Background task'; - job.category = normalizeText(definition && definition.category) || 'general'; - job.intervalMs = intervalMs; - job.metadata = definition && definition.metadata ? definition.metadata : {}; - job.run = typeof definition.run === 'function' ? definition.run : function () { - return Promise.resolve(); - }; - job.enabled = definition && definition.enabled === false ? false : true; - recurringJobsByKey.set(normalizedKey, job); - - if (job.enabled) { - scheduleRecurringRun(job, intervalMs); - } - - return buildRecurringSnapshot(job); - } - - function removeRecurringTask(recurringKey) { - const normalizedKey = normalizeText(recurringKey); - const job = recurringJobsByKey.get(normalizedKey); - if (!job) { - return false; - } - - clearRecurringTimer(job); - recurringJobsByKey.delete(normalizedKey); - return true; - } - - function buildRecurringSnapshot(job) { - return { - key: job.key, - title: job.title, - category: job.category, - intervalMs: job.intervalMs, - enabled: job.enabled !== false, - activeTaskId: job.activeTaskId || null, - createdAt: job.createdAt || '', - nextRunAt: job.nextRunAt || '', - lastRunAt: job.lastRunAt || '', - lastStatus: job.lastStatus || '', - lastError: job.lastError || '', - metadata: job.metadata || {} - }; - } - - function getTaskSortTime(task) { - const finishedTime = task && task.finishedAt ? Date.parse(task.finishedAt) : NaN; - if (Number.isFinite(finishedTime)) { - return finishedTime; - } - - const createdTime = task && task.createdAt ? Date.parse(task.createdAt) : NaN; - if (Number.isFinite(createdTime)) { - return createdTime; - } - - return 0; - } - - function listTasks() { - return Array.from(tasksById.values()) - .slice() - .sort(function (left, right) { - const leftStartedTime = left && left.startedAt ? Date.parse(left.startedAt) : NaN; - const rightStartedTime = right && right.startedAt ? Date.parse(right.startedAt) : NaN; - if (Number.isFinite(leftStartedTime) && Number.isFinite(rightStartedTime) && leftStartedTime !== rightStartedTime) { - return rightStartedTime - leftStartedTime; - } - - if (Number.isFinite(leftStartedTime) !== Number.isFinite(rightStartedTime)) { - return Number.isFinite(leftStartedTime) ? -1 : 1; - } - - const leftCreatedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN; - const rightCreatedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN; - if (Number.isFinite(leftCreatedTime) && Number.isFinite(rightCreatedTime) && leftCreatedTime !== rightCreatedTime) { - return rightCreatedTime - leftCreatedTime; - } - - if (Number.isFinite(leftCreatedTime) !== Number.isFinite(rightCreatedTime)) { - return Number.isFinite(leftCreatedTime) ? -1 : 1; - } - - return right.id - left.id; - }) - .map(buildSnapshot); - } - - function listRecurringTasks() { - return Array.from(recurringJobsByKey.values()) - .slice() - .sort(function (left, right) { - return left.key.localeCompare(right.key); - }) - .map(buildRecurringSnapshot); - } - - function clearFinishedTasks() { - let removedCount = 0; - Array.from(tasksById.values()).forEach(function (task) { - if (task.status === 'running' || task.status === 'queued') { - return; - } - tasksById.delete(task.id); - removedCount += 1; - persistTaskDelete(task.id).catch(function (error) { - console.warn('Unable to delete finished task from persistence:', error); - }); - }); - return removedCount; - } - - function cancelTask(taskId) { - const task = getTaskById(taskId); - if (!task || task.status !== 'queued') { - return false; - } - - task.status = 'canceled'; - task.finishedAt = toIsoDate(new Date()); - const pendingIndex = pendingIds.indexOf(task.id); - if (pendingIndex >= 0) { - pendingIds.splice(pendingIndex, 1); - } - - if (typeof task.rejectCompletion === 'function') { - const cancellationError = new Error('Task canceled.'); - cancellationError.task = buildSnapshot(task); - task.rejectCompletion(cancellationError); - } - - persistTaskUpdate(task).catch(function (error) { - console.warn('Unable to persist canceled task:', error); - }); - - return true; - } - - function retryTask(taskId) { - const task = getTaskById(taskId); - if (!task || task.status !== 'failed') { - return null; - } - - return enqueueTask({ - key: task.key, - title: task.title, - category: task.category, - metadata: task.metadata, - run: task.run - }); - } - - function getSummary() { - const counts = { - queued: 0, - running: 0, - completed: 0, - failed: 0, - canceled: 0 - }; - - listTasks().forEach(function (task) { - if (Object.prototype.hasOwnProperty.call(counts, task.status)) { - counts[task.status] += 1; - } - }); - - return { - activeCount: activeCount, - counts: counts, - scheduledCount: recurringJobsByKey.size, - total: listTasks().length - }; - } - - return { - enqueueTask: enqueueTask, - enqueueTaskAndWait: function (definition) { - return enqueueTask(definition).then(function (snapshot) { - const task = snapshot && snapshot.id ? getTaskById(snapshot.id) : null; - if (!task || !task.completionPromise) { - return snapshot; - } - - return task.completionPromise; - }); - }, - initialize: initialize, - setTaskHandler: setTaskHandler, - registerRecurringTask: registerRecurringTask, - removeRecurringTask: removeRecurringTask, - runRecurringTask: runRecurringTask, - listTasks: listTasks, - listRecurringTasks: listRecurringTasks, - getTaskById: getTaskById, - getSummary: getSummary, - cancelTask: cancelTask, - retryTask: retryTask, - clearFinishedTasks: clearFinishedTasks - }; -} - -module.exports = { - createBackgroundTaskQueue: createBackgroundTaskQueue, - normalizeIntervalMs: normalizeIntervalMs -}; \ No newline at end of file diff --git a/src/web/lib/background-task-scheduling.js b/src/web/lib/background-task-scheduling.js deleted file mode 100644 index d923d55..0000000 --- a/src/web/lib/background-task-scheduling.js +++ /dev/null @@ -1,146 +0,0 @@ -const { normalizeIntervalMs } = require('./background-task-queue'); - -function registerBackgroundTaskScheduling(options) { - const pool = options && options.pool; - const common = options && options.common; - const backgroundTaskQueue = options && options.backgroundTaskQueue; - const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory; - const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles; - const refreshApiSource = options && options.refreshApiSource; - const refreshRssFeed = options && options.refreshRssFeed; - const mediaDir = String(options && options.mediaDir || '').trim(); - const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250)); - - if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !mediaDir) { - throw new Error('registerBackgroundTaskScheduling requires the background task dependencies.'); - } - - async function syncRecurringRefreshes() { - const apiSourcesData = await common.fetchApiSourcesData(pool); - (apiSourcesData.apiSources || []).forEach(function (apiSource) { - backgroundTaskQueue.registerRecurringTask({ - key: 'api-source-refresh:' + Number(apiSource.id), - title: 'API source refresh', - category: 'data-source', - intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit), - metadata: { - sourceType: 'api-source', - sourceId: Number(apiSource.id), - sourceName: apiSource.name - }, - run: function () { - return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null); - } - }); - }); - - const rssFeedsData = await common.fetchRssFeedsData(pool); - (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { - backgroundTaskQueue.registerRecurringTask({ - key: 'rss-feed-refresh:' + Number(rssFeed.id), - title: 'RSS feed refresh', - category: 'data-source', - intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit), - metadata: { - sourceType: 'rss-feed', - sourceId: Number(rssFeed.id), - sourceName: rssFeed.name - }, - run: function () { - return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); - } - }); - }); - } - - async function registerRecurringMaintenanceTasks() { - async function runUnusedUploadSweep() { - const uploadPaths = await collectUploadPathsFromDirectory(mediaDir); - if (!uploadPaths.length) { - return; - } - - await removeUnusedUploadFiles(pool, mediaDir, uploadPaths); - } - - backgroundTaskQueue.registerRecurringTask({ - key: 'unused-upload-sweep', - title: 'Unused upload sweep', - category: 'media-sync', - intervalMs: 24 * 60 * 60 * 1000, - metadata: { - mediaDir: mediaDir - }, - run: runUnusedUploadSweep - }); - } - - async function scheduleInitialDataSourceRefreshes() { - const apiSourcesData = await common.fetchApiSourcesData(pool); - const rssFeedsData = await common.fetchRssFeedsData(pool); - const startupSources = []; - - (apiSourcesData.apiSources || []).forEach(function (apiSource) { - startupSources.push({ - type: 'api-source', - id: Number(apiSource.id), - name: apiSource.name, - run: function () { - return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null); - } - }); - }); - - (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { - startupSources.push({ - type: 'rss-feed', - id: Number(rssFeed.id), - name: rssFeed.name, - run: function () { - return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); - } - }); - }); - - startupSources.forEach(function (source, index) { - const startupDelayMs = index * dataSourceStartupRefreshStaggerMs; - - setTimeout(function () { - backgroundTaskQueue.enqueueTask({ - key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(), - title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh', - category: 'data-source', - taskType: 'data-source-refresh', - metadata: { - sourceType: source.type, - sourceId: source.id, - sourceName: source.name, - startupRefresh: true - }, - payload: { - sourceType: source.type, - sourceId: source.id, - sourceName: source.name, - startupRefresh: true - } - }).catch(function (error) { - console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error); - }); - }, startupDelayMs); - }); - } - - async function initialize() { - await syncRecurringRefreshes(); - await registerRecurringMaintenanceTasks(); - scheduleInitialDataSourceRefreshes().catch(function (error) { - console.warn('Unable to schedule startup data source refreshes:', error); - }); - } - - return { - initialize: initialize - }; -} - -module.exports = { registerBackgroundTaskScheduling }; \ No newline at end of file diff --git a/src/web/lib/background-task-setup.js b/src/web/lib/background-task-setup.js deleted file mode 100644 index 4bada3c..0000000 --- a/src/web/lib/background-task-setup.js +++ /dev/null @@ -1,32 +0,0 @@ -const { registerBackgroundTaskHandlers } = require('./background-task-handlers'); -const { registerBackgroundTaskScheduling } = require('./background-task-scheduling'); - -function createBackgroundTaskSetup(options) { - const pool = options && options.pool; - const common = options && options.common; - const backgroundTaskQueue = options && options.backgroundTaskQueue; - const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory; - const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles; - const refreshApiSource = options && options.refreshApiSource; - const refreshRssFeed = options && options.refreshRssFeed; - const uploadsDir = String(options && options.uploadsDir || '').trim(); - const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250)); - - if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) { - throw new Error('createBackgroundTaskSetup requires the background task dependencies.'); - } - - async function initialize() { - registerBackgroundTaskHandlers(options); - const backgroundTaskScheduling = registerBackgroundTaskScheduling(options); - if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') { - await backgroundTaskScheduling.initialize(); - } - } - - return { - initialize: initialize - }; -} - -module.exports = { createBackgroundTaskSetup }; \ No newline at end of file diff --git a/src/web/lib/background-tasks/index.js b/src/web/lib/background-tasks/index.js new file mode 100644 index 0000000..2b57cd6 --- /dev/null +++ b/src/web/lib/background-tasks/index.js @@ -0,0 +1,99 @@ +// Shared loader for task folders under background-tasks. + +const fs = require('fs'); +const path = require('path'); + +function getTaskExport(taskModule) { + return typeof taskModule === 'function' + ? taskModule + : taskModule && typeof taskModule === 'object' + ? Object.values(taskModule).find(function (value) { + return typeof value === 'function'; + }) + : null; +} + +function loadTaskModules(directory, options) { + const taskFiles = fs.readdirSync(directory) + .filter(function (fileName) { + return fileName.toLowerCase().endsWith('.js') && fileName !== 'index.js'; + }) + .sort(function (left, right) { + return left.localeCompare(right); + }); + + return Promise.all(taskFiles.map(function (fileName) { + const taskModule = require(path.join(directory, fileName)); + const exported = getTaskExport(taskModule); + + if (typeof exported === 'function') { + return exported(options); + } + return null; + })); +} + +// Register ad hoc task handlers that execute queued background work. +function registerAdhocTasks(options) { + const backgroundTaskDirectory = path.join(__dirname, 'tasks-adhoc'); + if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !options.captureSlideThumbnail || !options.uploadSyncService || !String(options.mediaDir || '').trim()) { + throw new Error('registerAdhocTasks requires the background task dependencies.'); + } + + loadTaskModules(backgroundTaskDirectory, options); +} + +// Register one-time startup work that should run during boot only. +function registerStartupTasks(options) { + const backgroundTaskDirectory = path.join(__dirname, 'tasks-startup'); + + if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !String(options.mediaDir || '').trim()) { + throw new Error('registerStartupTasks requires the startup task dependencies.'); + } + + async function initialize() { + await loadTaskModules(backgroundTaskDirectory, options); + } + + return { + initialize: initialize + }; +} + +// Register recurring maintenance and data-source refresh tasks. +function registerScheduledTasks(options) { + const backgroundTaskDirectory = path.join(__dirname, 'tasks-scheduled'); + + if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !options.uploadSyncService || !String(options.mediaDir || '').trim()) { + throw new Error('registerScheduledTasks requires the background task dependencies.'); + } + + async function initialize() { + await loadTaskModules(backgroundTaskDirectory, options); + } + + return { + initialize: initialize + }; +} + +async function initializeBackgroundTasks(options) { + registerAdhocTasks(options); + + const backgroundTaskStartup = registerStartupTasks(options); + if (backgroundTaskStartup && typeof backgroundTaskStartup.initialize === 'function') { + await backgroundTaskStartup.initialize(); + } + + const backgroundTaskScheduling = registerScheduledTasks(options); + if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') { + await backgroundTaskScheduling.initialize(); + } +} + +module.exports = { + registerAdhocTasks: registerAdhocTasks, + registerStartupTasks: registerStartupTasks, + registerScheduledTasks: registerScheduledTasks, + initializeBackgroundTasks: initializeBackgroundTasks +}; \ No newline at end of file diff --git a/src/web/lib/background-tasks/queue.js b/src/web/lib/background-tasks/queue.js new file mode 100644 index 0000000..851722a --- /dev/null +++ b/src/web/lib/background-tasks/queue.js @@ -0,0 +1,719 @@ +// Database-backed background task queue. + +function normalizeText(value) { + return String(value || '').trim(); +} + +function toIsoDate(value) { + if (!value) { + return ''; + } + + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? '' : date.toISOString(); +} + +function normalizeIntervalMs(value, unit) { + const numericValue = Math.max(1, Number(value) || 0); + const normalizedUnit = String(unit || 'minutes').trim().toLowerCase(); + if (normalizedUnit === 'seconds') { + return numericValue * 1000; + } + return numericValue * 60 * 1000; +} + +function createBackgroundTaskQueue(options) { + const pool = options && options.pool; + const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1); + const taskHandlers = new Map(); + const recurringJobsByKey = new Map(); + let activeCount = 0; + let drainScheduled = false; + let initializationPromise = null; + + if (!pool) { + throw new Error('createBackgroundTaskQueue requires a database pool.'); + } + + function buildSnapshot(task) { + return { + id: Number(task && task.id) || 0, + key: String(task && task.key || '').trim(), + taskType: String(task && task.taskType || '').trim(), + title: String(task && task.title || 'Background task').trim() || 'Background task', + category: String(task && task.category || 'general').trim() || 'general', + status: String(task && task.status || 'queued').trim() || 'queued', + createdAt: String(task && task.createdAt || ''), + startedAt: String(task && task.startedAt || ''), + finishedAt: String(task && task.finishedAt || ''), + errorMessage: String(task && task.errorMessage || ''), + attempts: Math.max(0, Number(task && task.attempts) || 0), + metadata: task && task.metadata ? task.metadata : {}, + payload: task && task.payload !== undefined ? task.payload : null + }; + } + + function parseJsonValue(value, fallback) { + if (value === null || value === undefined || value === '') { + return fallback; + } + + if (typeof value === 'object') { + return value; + } + + try { + return JSON.parse(String(value)); + } catch (_error) { + return fallback; + } + } + + function stringifyJsonValue(value) { + if (value === undefined || value === null) { + return null; + } + + return JSON.stringify(value); + } + + function hydrateTaskRow(row) { + if (!row) { + return null; + } + + return { + id: Number(row.id), + key: String(row.task_key || '').trim(), + taskType: String(row.task_type || '').trim(), + title: String(row.title || 'Background task').trim() || 'Background task', + category: String(row.category || 'general').trim() || 'general', + status: String(row.status || 'queued').trim() || 'queued', + createdAt: row.created_at ? new Date(row.created_at).toISOString() : '', + startedAt: row.started_at ? new Date(row.started_at).toISOString() : '', + finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '', + errorMessage: String(row.error_message || ''), + attempts: Math.max(0, Number(row.attempts) || 0), + metadata: parseJsonValue(row.metadata_json, {}), + payload: parseJsonValue(row.payload_json, null) + }; + } + + function buildTaskRecord(task) { + return { + task_key: task.key || null, + task_type: task.taskType || null, + title: task.title, + category: task.category || 'general', + status: task.status, + payload_json: stringifyJsonValue(task.payload), + metadata_json: stringifyJsonValue(task.metadata), + attempts: Math.max(0, Number(task.attempts) || 0), + created_at: task.createdAt ? new Date(task.createdAt) : new Date(), + started_at: task.startedAt ? new Date(task.startedAt) : null, + finished_at: task.finishedAt ? new Date(task.finishedAt) : null, + error_message: task.errorMessage || null + }; + } + + async function persistTaskInsert(task) { + const normalizedTaskType = normalizeText(task && task.taskType); + if (!normalizedTaskType) { + throw new Error('Background tasks require a task type.'); + } + + const record = buildTaskRecord(Object.assign({}, task, { + taskType: normalizedTaskType + })); + const [result] = await pool.query( + `INSERT INTO o_background_tasks ( + task_key, + task_type, + title, + category, + status, + payload_json, + metadata_json, + attempts, + created_at, + started_at, + finished_at, + error_message + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + record.task_key, + record.task_type, + record.title, + record.category, + record.status, + record.payload_json, + record.metadata_json, + record.attempts, + record.created_at, + record.started_at, + record.finished_at, + record.error_message + ] + ); + + task.id = Number(result.insertId); + return task; + } + + async function persistTaskUpdate(task) { + if (!task || !Number.isInteger(Number(task.id)) || Number(task.id) <= 0) { + return; + } + + const record = buildTaskRecord(task); + await pool.query( + `UPDATE o_background_tasks + SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ? + WHERE id = ?`, + [ + record.task_key, + record.task_type, + record.title, + record.category, + record.status, + record.payload_json, + record.metadata_json, + record.attempts, + record.started_at, + record.finished_at, + record.error_message, + Number(task.id) + ] + ); + } + + async function persistTaskDelete(taskId) { + const numericTaskId = Number(taskId); + if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) { + return; + } + + await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [numericTaskId]); + } + + async function fetchTaskById(taskId) { + const numericTaskId = Number(taskId); + if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) { + return null; + } + + const [rows] = await pool.query( + `SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message + FROM o_background_tasks + WHERE id = ? + LIMIT 1`, + [numericTaskId] + ); + + return hydrateTaskRow(rows && rows[0]) || null; + } + + async function fetchQueuedTaskCount() { + const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM o_background_tasks WHERE status = 'queued'`); + return Number(rows && rows[0] && rows[0].count) || 0; + } + + async function claimNextTask() { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const [rows] = await connection.query( + `SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message + FROM o_background_tasks + WHERE status = 'queued' + ORDER BY created_at ASC, id ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED` + ); + + const row = rows && rows[0] ? rows[0] : null; + if (!row) { + await connection.commit(); + return null; + } + + const nextAttempts = Math.max(0, Number(row.attempts) || 0) + 1; + await connection.query( + `UPDATE o_background_tasks + SET status = 'running', started_at = NOW(), attempts = ?, error_message = NULL + WHERE id = ?`, + [nextAttempts, row.id] + ); + await connection.commit(); + + return hydrateTaskRow(Object.assign({}, row, { + status: 'running', + started_at: new Date(), + attempts: nextAttempts, + error_message: null + })); + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } + throw error; + } finally { + connection.release(); + } + } + + async function syncRecurringTaskState(task, status, errorMessage) { + const recurringKey = String(task && task.metadata && task.metadata.recurringKey || '').trim(); + if (!recurringKey) { + return; + } + + const job = recurringJobsByKey.get(recurringKey); + if (!job) { + return; + } + + job.lastRunAt = toIsoDate(new Date()); + job.lastStatus = String(status || ''); + job.lastError = errorMessage ? String(errorMessage) : ''; + } + + function setTaskHandler(taskType, handler) { + const normalizedTaskType = normalizeText(taskType); + if (!normalizedTaskType || typeof handler !== 'function') { + return false; + } + + taskHandlers.set(normalizedTaskType, handler); + return true; + } + + setTaskHandler('recurring-run', async function (task) { + const recurringKey = String(task && task.metadata && task.metadata.recurringKey || '').trim(); + if (!recurringKey) { + throw new Error('Recurring task key is required.'); + } + + const job = recurringJobsByKey.get(recurringKey); + if (!job || typeof job.run !== 'function') { + throw new Error('Recurring task not found.'); + } + + return job.run(task); + }); + + function scheduleDrain() { + if (drainScheduled) { + return; + } + + drainScheduled = true; + setTimeout(function () { + drainScheduled = false; + processQueue().catch(function (error) { + console.error(error); + }); + }, 0); + } + + async function initialize() { + if (initializationPromise) { + return initializationPromise; + } + + initializationPromise = (async function () { + await pool.query( + `UPDATE o_background_tasks + SET status = 'queued', started_at = NULL, finished_at = NULL, error_message = NULL + WHERE status = 'running'` + ); + + if (await fetchQueuedTaskCount() > 0) { + scheduleDrain(); + } + })(); + + return initializationPromise; + } + + async function processQueue() { + while (activeCount < maxConcurrent) { + const task = await claimNextTask(); + if (!task) { + break; + } + + activeCount += 1; + + (async function () { + try { + const handler = taskHandlers.get(task.taskType); + if (!handler) { + throw new Error('No handler registered for task type ' + task.taskType + '.'); + } + + await handler(task); + task.status = 'completed'; + task.finishedAt = toIsoDate(new Date()); + task.errorMessage = ''; + await persistTaskUpdate(task); + await syncRecurringTaskState(task, task.status, ''); + } catch (error) { + task.status = 'failed'; + task.errorMessage = String(error && error.message ? error.message : 'Background task failed.'); + task.finishedAt = toIsoDate(new Date()); + try { + await persistTaskUpdate(task); + } catch (persistError) { + console.error(persistError); + } + await syncRecurringTaskState(task, task.status, task.errorMessage); + } finally { + activeCount = Math.max(0, activeCount - 1); + scheduleDrain(); + } + })().catch(function (error) { + console.error(error); + }); + } + } + + async function enqueueTask(definition) { + const normalizedKey = normalizeText(definition && definition.key); + const normalizedTitle = normalizeText(definition && definition.title) || 'Background task'; + const normalizedTaskType = normalizeText(definition && definition.taskType); + if (!normalizedTaskType) { + throw new Error('Background tasks require a task type.'); + } + + const task = { + id: 0, + key: normalizedKey, + taskType: normalizedTaskType, + title: normalizedTitle, + category: normalizeText(definition && definition.category) || 'general', + status: 'queued', + createdAt: toIsoDate(new Date()), + startedAt: '', + finishedAt: '', + errorMessage: '', + attempts: 0, + metadata: definition && definition.metadata ? definition.metadata : {}, + payload: definition && definition.payload !== undefined ? definition.payload : null + }; + + await persistTaskInsert(task); + scheduleDrain(); + return buildSnapshot(task); + } + + async function enqueueTaskAndWait(definition) { + const snapshot = await enqueueTask(definition); + const taskId = snapshot && snapshot.id ? snapshot.id : null; + if (!taskId) { + return snapshot; + } + + while (true) { + const currentTask = await fetchTaskById(taskId); + if (!currentTask) { + return snapshot; + } + + if (currentTask.status !== 'queued' && currentTask.status !== 'running') { + return buildSnapshot(currentTask); + } + + await new Promise(function (resolve) { + setTimeout(resolve, 250); + }); + } + } + + function clearRecurringTimer(job) { + if (job && job.timerId) { + clearTimeout(job.timerId); + job.timerId = null; + } + } + + function scheduleRecurringRun(job, delayMs) { + if (!job || job.enabled === false) { + return; + } + + clearRecurringTimer(job); + const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0); + job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay)); + job.timerId = setTimeout(function () { + job.timerId = null; + triggerRecurringJob(job.key).catch(function (error) { + console.error(error); + }); + }, safeDelay); + } + + async function triggerRecurringJob(recurringKey) { + const job = recurringJobsByKey.get(recurringKey); + if (!job || job.enabled === false) { + return false; + } + + await enqueueTask({ + key: `${job.key}:${Date.now()}`, + title: job.title, + category: job.category, + taskType: 'recurring-run', + metadata: Object.assign({}, job.metadata || {}, { + recurringKey: job.key, + recurringTitle: job.title + }), + payload: Object.assign({}, job.payload || {}, { + recurringKey: job.key + }) + }); + + scheduleRecurringRun(job, job.intervalMs); + return true; + } + + async function runRecurringTask(recurringKey) { + const normalizedKey = normalizeText(recurringKey); + if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) { + return false; + } + + return triggerRecurringJob(normalizedKey); + } + + function registerRecurringTask(definition) { + const normalizedKey = normalizeText(definition && definition.key); + if (!normalizedKey) { + throw new Error('Recurring tasks require a key.'); + } + + const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0); + if (!Number.isFinite(intervalMs) || intervalMs < 1000) { + throw new Error('Recurring tasks require a valid interval.'); + } + + const job = recurringJobsByKey.get(normalizedKey) || { + key: normalizedKey, + lastRunAt: '', + lastStatus: '', + lastError: '', + nextRunAt: '', + timerId: null, + enabled: true + }; + + clearRecurringTimer(job); + job.title = normalizeText(definition && definition.title) || 'Background task'; + job.category = normalizeText(definition && definition.category) || 'general'; + job.intervalMs = intervalMs; + job.metadata = definition && definition.metadata ? definition.metadata : {}; + job.payload = definition && definition.payload ? definition.payload : null; + job.run = typeof definition.run === 'function' ? definition.run : function () { + return Promise.resolve(); + }; + job.enabled = definition && definition.enabled === false ? false : true; + recurringJobsByKey.set(normalizedKey, job); + + if (job.enabled) { + scheduleRecurringRun(job, intervalMs); + } + + return buildRecurringSnapshot(job); + } + + function removeRecurringTask(recurringKey) { + const normalizedKey = normalizeText(recurringKey); + const job = recurringJobsByKey.get(normalizedKey); + if (!job) { + return false; + } + + clearRecurringTimer(job); + recurringJobsByKey.delete(normalizedKey); + return true; + } + + function buildRecurringSnapshot(job, state) { + const latestState = state || null; + const activeTaskId = latestState && (latestState.status === 'queued' || latestState.status === 'running') + ? latestState.id + : null; + const lastRunAt = latestState + ? latestState.finishedAt || latestState.startedAt || latestState.createdAt || '' + : job.lastRunAt || ''; + + return { + key: job.key, + title: job.title, + category: job.category, + intervalMs: job.intervalMs, + enabled: job.enabled !== false, + activeTaskId: activeTaskId, + createdAt: latestState ? latestState.createdAt || '' : job.createdAt || '', + nextRunAt: job.nextRunAt || '', + lastRunAt: lastRunAt || '', + lastStatus: latestState ? latestState.status || '' : job.lastStatus || '', + lastError: latestState ? latestState.errorMessage || '' : job.lastError || '', + metadata: job.metadata || {} + }; + } + + async function fetchRecurringTaskStates() { + const [rows] = await pool.query( + `SELECT id, status, created_at, started_at, finished_at, error_message, metadata_json + FROM o_background_tasks + WHERE JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.recurringKey')) IS NOT NULL + ORDER BY created_at DESC, id DESC` + ); + + const statesByKey = new Map(); + for (const row of rows || []) { + const metadata = parseJsonValue(row.metadata_json, {}); + const recurringKey = normalizeText(metadata && metadata.recurringKey); + if (!recurringKey || statesByKey.has(recurringKey)) { + continue; + } + + statesByKey.set(recurringKey, { + id: Number(row.id), + status: String(row.status || ''), + createdAt: row.created_at ? new Date(row.created_at).toISOString() : '', + startedAt: row.started_at ? new Date(row.started_at).toISOString() : '', + finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '', + errorMessage: String(row.error_message || '') + }); + } + + return statesByKey; + } + + async function listTasks() { + const [rows] = await pool.query( + `SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message + FROM o_background_tasks + ORDER BY + CASE status + WHEN 'running' THEN 0 + WHEN 'queued' THEN 1 + WHEN 'failed' THEN 2 + WHEN 'completed' THEN 3 + WHEN 'canceled' THEN 4 + ELSE 5 + END, + COALESCE(started_at, created_at) DESC, + id DESC` + ); + + return (rows || []).map(hydrateTaskRow); + } + + async function listRecurringTasks() { + const statesByKey = await fetchRecurringTaskStates(); + return Array.from(recurringJobsByKey.values()) + .slice() + .sort(function (left, right) { + return left.key.localeCompare(right.key); + }) + .map(function (job) { + return buildRecurringSnapshot(job, statesByKey.get(job.key) || null); + }); + } + + async function clearFinishedTasks() { + const [result] = await pool.query( + `DELETE FROM o_background_tasks + WHERE status IN ('completed', 'failed', 'canceled')` + ); + return Number(result && result.affectedRows) || 0; + } + + async function cancelTask(taskId) { + const numericTaskId = Number(taskId); + if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) { + return false; + } + + const [result] = await pool.query( + `UPDATE o_background_tasks + SET status = 'canceled', + finished_at = NOW(), + error_message = 'Task canceled.' + WHERE id = ? + AND status = 'queued'`, + [numericTaskId] + ); + return Number(result && result.affectedRows) > 0; + } + + async function retryTask(taskId) { + const task = await fetchTaskById(taskId); + if (!task || task.status !== 'failed') { + return null; + } + + return enqueueTask({ + key: task.key, + title: task.title, + category: task.category, + taskType: task.taskType, + metadata: task.metadata, + payload: task.payload + }); + } + + async function getSummary() { + const [rows] = await pool.query( + `SELECT + SUM(status = 'queued') AS queued, + SUM(status = 'running') AS running, + SUM(status = 'completed') AS completed, + SUM(status = 'failed') AS failed, + SUM(status = 'canceled') AS canceled, + COUNT(*) AS total + FROM o_background_tasks` + ); + const row = rows && rows[0] ? rows[0] : {}; + + return { + activeCount: activeCount, + counts: { + queued: Number(row.queued) || 0, + running: Number(row.running) || 0, + completed: Number(row.completed) || 0, + failed: Number(row.failed) || 0, + canceled: Number(row.canceled) || 0 + }, + scheduledCount: recurringJobsByKey.size, + total: Number(row.total) || 0 + }; + } + + return { + enqueueTask: enqueueTask, + enqueueTaskAndWait: enqueueTaskAndWait, + initialize: initialize, + setTaskHandler: setTaskHandler, + registerRecurringTask: registerRecurringTask, + removeRecurringTask: removeRecurringTask, + runRecurringTask: runRecurringTask, + listTasks: listTasks, + listRecurringTasks: listRecurringTasks, + getTaskById: fetchTaskById, + getSummary: getSummary, + cancelTask: cancelTask, + retryTask: retryTask, + clearFinishedTasks: clearFinishedTasks + }; +} + +module.exports = { + createBackgroundTaskQueue: createBackgroundTaskQueue, + normalizeIntervalMs: normalizeIntervalMs +}; \ No newline at end of file diff --git a/src/web/lib/background-tasks/tasks-adhoc/data-source-refresh.js b/src/web/lib/background-tasks/tasks-adhoc/data-source-refresh.js new file mode 100644 index 0000000..6fba4d7 --- /dev/null +++ b/src/web/lib/background-tasks/tasks-adhoc/data-source-refresh.js @@ -0,0 +1,42 @@ +const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh'); + +const TASK = { + taskType: 'data-source-refresh' +}; + +function registerDataSourceRefreshTask(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const pool = options && options.pool; + const common = options && options.common; + + if (!backgroundTaskQueue || !pool || !common) { + throw new Error('registerDataSourceRefreshTask requires the data source refresh dependencies.'); + } + + backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) { + const payload = task && task.payload ? task.payload : {}; + const sourceType = String(payload.sourceType || '').trim(); + const sourceId = Number(payload.sourceId || 0); + + // Resolve the source record at execution time so stale queued tasks fail cleanly. + if (sourceType === 'api-source') { + const apiSource = await common.fetchApiSourceById(pool, sourceId); + if (!apiSource) { + throw new Error('API source not found.'); + } + return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null); + } + + if (sourceType === 'rss-feed') { + const rssFeed = await common.fetchRssFeedById(pool, sourceId); + if (!rssFeed) { + throw new Error('RSS feed not found.'); + } + return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null); + } + + throw new Error('Unsupported data source refresh task.'); + }); +} + +module.exports = { registerDataSourceRefreshTask }; \ No newline at end of file diff --git a/src/web/lib/background-tasks/tasks-adhoc/font-sync.js b/src/web/lib/background-tasks/tasks-adhoc/font-sync.js new file mode 100644 index 0000000..7d4191c --- /dev/null +++ b/src/web/lib/background-tasks/tasks-adhoc/font-sync.js @@ -0,0 +1,39 @@ +function registerFontSyncTask(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const uploadSyncService = options && options.uploadSyncService; + + if (!backgroundTaskQueue || !uploadSyncService) { + throw new Error('registerFontSyncTask requires the font sync dependencies.'); + } + + backgroundTaskQueue.setTaskHandler('font-sync', async function (task) { + const payload = task && task.payload ? task.payload : task || {}; + const uploadDir = String(payload.uploadDir || '').trim(); + const operations = Array.isArray(payload.operations) + ? payload.operations + : Array.isArray(payload.uploadPaths) + ? payload.uploadPaths.map(function (uploadPath) { + return { type: String(payload.action || 'put').trim().toLowerCase(), uploadPath: uploadPath }; + }) + : []; + + if (!uploadDir || !operations.length) { + return; + } + + for (let i = 0; i < operations.length; i += 1) { + const operation = operations[i] || {}; + const uploadPath = String(operation.uploadPath || '').trim(); + if (!uploadPath) { + continue; + } + if (String(operation.type || '').trim().toLowerCase() === 'delete') { + await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir); + } else { + await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir); + } + } + }); +} + +module.exports = { registerFontSyncTask }; \ No newline at end of file diff --git a/src/web/lib/background-tasks/tasks-adhoc/media-sync.js b/src/web/lib/background-tasks/tasks-adhoc/media-sync.js new file mode 100644 index 0000000..85a7409 --- /dev/null +++ b/src/web/lib/background-tasks/tasks-adhoc/media-sync.js @@ -0,0 +1,20 @@ +const TASK = { + taskType: 'media-sync' +}; + +function registerMediaSyncTask(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const uploadSyncService = options && options.uploadSyncService; + const runMediaSyncTask = uploadSyncService && uploadSyncService.runMediaSyncTask; + + if (!backgroundTaskQueue || typeof runMediaSyncTask !== 'function') { + throw new Error('registerMediaSyncTask requires the media sync dependencies.'); + } + + // Media sync is just a pass-through to the shared media sync runner. + backgroundTaskQueue.setTaskHandler(TASK.taskType, function (task) { + return runMediaSyncTask(task && task.payload ? task.payload : task); + }); +} + +module.exports = { registerMediaSyncTask }; \ No newline at end of file diff --git a/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-slide.js b/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-slide.js new file mode 100644 index 0000000..ddab50a --- /dev/null +++ b/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-slide.js @@ -0,0 +1,51 @@ +const TASK = { + taskType: 'slide-thumbnail-refresh' +}; + +async function fetchPlayerInternalBaseUrl(pool) { + const [rows] = await pool.query( + `SELECT internal_base_url + FROM d_players + WHERE device_id = '1' + LIMIT 1` + ); + + return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null; +} + +function registerSlideThumbnailRefreshTask(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const captureSlideThumbnail = options && options.captureSlideThumbnail; + const pool = options && options.pool; + const common = options && options.common; + const mediaDir = String(options && options.mediaDir || '').trim(); + + if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) { + throw new Error('registerSlideThumbnailRefreshTask requires the slide thumbnail dependencies.'); + } + + // Refresh the slide thumbnail for one slide id. + backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) { + const payload = task && task.payload ? task.payload : {}; + const slideId = Number(payload.slideId || 0); + if (!Number.isFinite(slideId) || slideId <= 0) { + throw new Error('Slide id is required.'); + } + + const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool); + if (!playerInternalBaseUrl) { + throw new Error('Player internal base URL is required.'); + } + + return captureSlideThumbnail({ + pool: pool, + common: common, + mediaDir: mediaDir, + baseUrl: playerInternalBaseUrl, + slideId: slideId, + previousThumbnailPath: payload.previousThumbnailPath || null + }); + }); +} + +module.exports = { registerSlideThumbnailRefreshTask }; \ No newline at end of file diff --git a/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-template.js b/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-template.js new file mode 100644 index 0000000..42d8053 --- /dev/null +++ b/src/web/lib/background-tasks/tasks-adhoc/thumbnail-refresh-template.js @@ -0,0 +1,65 @@ +const TASK = { + taskType: 'template-slide-thumbnail-refresh' +}; + +async function fetchPlayerInternalBaseUrl(pool) { + const [rows] = await pool.query( + `SELECT internal_base_url + FROM d_players + WHERE device_id = '1' + LIMIT 1` + ); + + return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null; +} + +function registerTemplateSlideThumbnailRefreshTask(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const captureSlideThumbnail = options && options.captureSlideThumbnail; + const pool = options && options.pool; + const common = options && options.common; + const mediaDir = String(options && options.mediaDir || '').trim(); + + if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) { + throw new Error('registerTemplateSlideThumbnailRefreshTask requires the template thumbnail dependencies.'); + } + + // Walk every slide in the template and regenerate its thumbnail. + backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) { + const payload = task && task.payload ? task.payload : {}; + const templateId = Number(payload.templateId || 0); + if (!Number.isFinite(templateId) || templateId <= 0) { + throw new Error('Template id is required.'); + } + + const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool); + if (!playerInternalBaseUrl) { + throw new Error('Player internal base URL is required.'); + } + + const [slides] = await pool.query( + 'SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC', + [templateId] + ); + + for (const slide of slides || []) { + const slideId = Number(slide && slide.id || 0); + if (!Number.isFinite(slideId) || slideId <= 0) { + continue; + } + + await captureSlideThumbnail({ + pool: pool, + common: common, + mediaDir: mediaDir, + baseUrl: playerInternalBaseUrl, + slideId: slideId, + previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null + }); + } + + return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 }; + }); +} + +module.exports = { registerTemplateSlideThumbnailRefreshTask }; \ No newline at end of file diff --git a/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js b/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js new file mode 100644 index 0000000..372f659 --- /dev/null +++ b/src/web/lib/background-tasks/tasks-scheduled/data-source-refresh.js @@ -0,0 +1,139 @@ +const TASK = { + key: 'recurring-data-source-refreshes', + title: 'Recurring data source refreshes', + category: 'data-source', + trigger: 'scheduled recurring task definitions from the database', + purpose: 'keep API sources and RSS feeds refreshed on their configured intervals.', + taskType: 'data-source-refresh', + intervalMs: null +}; + +const { normalizeIntervalMs } = require('../queue'); +const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh'); + +function registerRecurringDataSourceRefreshes(options) { + const pool = options && options.pool; + const common = options && options.common; + const backgroundTaskQueue = options && options.backgroundTaskQueue; + + if (!pool || !common || !backgroundTaskQueue) { + throw new Error('registerRecurringDataSourceRefreshes requires the recurring refresh dependencies.'); + } + + return (async function () { + const apiSourcesData = await common.fetchApiSourcesData(pool); + (apiSourcesData.apiSources || []).forEach(function (apiSource) { + backgroundTaskQueue.registerRecurringTask({ + key: 'api-source-refresh:' + Number(apiSource.id), + title: 'API source refresh', + category: TASK.category, + intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit), + metadata: { + sourceType: 'api-source', + sourceId: Number(apiSource.id), + sourceName: apiSource.name + }, + run: function () { + return refreshApiSource(pool, common, apiSource, null); + } + }); + }); + + const rssFeedsData = await common.fetchRssFeedsData(pool); + (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { + backgroundTaskQueue.registerRecurringTask({ + key: 'rss-feed-refresh:' + Number(rssFeed.id), + title: 'RSS feed refresh', + category: TASK.category, + intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit), + metadata: { + sourceType: 'rss-feed', + sourceId: Number(rssFeed.id), + sourceName: rssFeed.name + }, + run: function () { + return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); + } + }); + }); + })(); +} + +function createDataSourceTaskService(options) { + const pool = options && options.pool; + const common = options && options.common; + const backgroundTaskQueue = options && options.backgroundTaskQueue; + + if (!pool || !common || !backgroundTaskQueue) { + throw new Error('createDataSourceTaskService requires the data source task dependencies.'); + } + + function formatRecurringKey(sourceType, id) { + return sourceType + '-refresh:' + Number(id); + } + + function buildRecurringTitle(sourceType) { + return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh'; + } + + function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) { + backgroundTaskQueue.registerRecurringTask({ + key: formatRecurringKey(sourceType, id), + title: buildRecurringTitle(sourceType), + category: 'data-source', + intervalMs: normalizeIntervalMs(intervalValue, intervalUnit), + metadata: { + sourceType: sourceType, + sourceId: Number(id), + sourceName: name + }, + run: run + }); + } + + function removeRecurringRefresh(sourceType, id) { + backgroundTaskQueue.removeRecurringTask(formatRecurringKey(sourceType, id)); + } + + async function getTaskStatusById(taskId) { + if (!backgroundTaskQueue || typeof backgroundTaskQueue.getTaskById !== 'function') { + return null; + } + + const task = await backgroundTaskQueue.getTaskById(taskId); + if (!task) { + return null; + } + + return { + id: task.id, + key: task.key, + status: task.status, + finishedAt: task.finishedAt || '', + errorMessage: task.errorMessage || '' + }; + } + + async function refreshApiSourceInBackground(apiSourceId, actorId) { + return refreshApiSource(pool, common, apiSourceId, actorId); + } + + async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) { + return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId); + } + + return { + formatRecurringKey: formatRecurringKey, + buildRecurringTitle: buildRecurringTitle, + registerRecurringRefresh: registerRecurringRefresh, + removeRecurringRefresh: removeRecurringRefresh, + getTaskStatusById: getTaskStatusById, + refreshApiSourceInBackground: refreshApiSourceInBackground, + refreshRssFeedInBackground: refreshRssFeedInBackground + }; +} + +module.exports = { + registerRecurringDataSourceRefreshes: registerRecurringDataSourceRefreshes, + createDataSourceTaskService: createDataSourceTaskService +}; \ No newline at end of file diff --git a/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js b/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js new file mode 100644 index 0000000..95f787c --- /dev/null +++ b/src/web/lib/background-tasks/tasks-scheduled/font-sweep.js @@ -0,0 +1,68 @@ +const TASK = { + key: 'font-sweep', + title: 'Font sweep', + category: 'cleanup', + trigger: 'recurring scheduled task, daily', + purpose: 'reconcile managed fonts on the player and remove stale font files.', + taskType: 'recurring-run', + intervalMs: 24 * 60 * 60 * 1000 +}; + +const { + collectFontLibraryDirectoryUploadPaths, + collectFontLibrarySyncOperations +} = require('../../media/font-library'); + +function registerFontSweepTask(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const uploadSyncService = options && options.uploadSyncService; + const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer; + const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer; + const mediaDir = String(options && options.mediaDir || '').trim(); + + if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) { + throw new Error('registerFontSweepTask requires the font sweep dependencies.'); + } + + backgroundTaskQueue.registerRecurringTask({ + key: TASK.key, + title: TASK.title, + category: TASK.category, + intervalMs: TASK.intervalMs, + metadata: { + mediaDir: mediaDir + }, + run: async function () { + const desiredOperations = collectFontLibrarySyncOperations(mediaDir); + const desiredUploadPaths = new Set(desiredOperations.map(function (operation) { + return operation && operation.uploadPath ? operation.uploadPath : ''; + }).filter(Boolean)); + const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir); + + for (let i = 0; i < desiredOperations.length; i += 1) { + const operation = desiredOperations[i] || {}; + const uploadPath = String(operation.uploadPath || '').trim(); + if (!uploadPath) { + continue; + } + + if (String(operation.type || '').trim().toLowerCase() === 'delete') { + await removeUploadFileFromPlayer(uploadPath, mediaDir); + } else { + await pushUploadFileToPlayer(uploadPath, mediaDir); + } + } + + for (let i = 0; i < currentUploadPaths.length; i += 1) { + const uploadPath = String(currentUploadPaths[i] || '').trim(); + if (!uploadPath || desiredUploadPaths.has(uploadPath)) { + continue; + } + + await removeUploadFileFromPlayer(uploadPath, mediaDir); + } + } + }); +} + +module.exports = { registerFontSweepTask }; \ No newline at end of file diff --git a/src/web/lib/background-tasks/tasks-scheduled/unused-upload-sweep.js b/src/web/lib/background-tasks/tasks-scheduled/unused-upload-sweep.js new file mode 100644 index 0000000..3ae5823 --- /dev/null +++ b/src/web/lib/background-tasks/tasks-scheduled/unused-upload-sweep.js @@ -0,0 +1,42 @@ +const TASK = { + key: 'unused-upload-sweep', + title: 'Unused upload sweep', + category: 'cleanup', + trigger: 'recurring scheduled task, daily', + purpose: 'remove uploaded media files that are no longer referenced.', + taskType: 'recurring-run', + intervalMs: 24 * 60 * 60 * 1000 +}; + +function registerUnusedUploadSweepTask(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const uploadSyncService = options && options.uploadSyncService; + const collectUploadPathsFromDirectory = uploadSyncService && uploadSyncService.collectUploadPathsFromDirectory; + const removeUnusedUploadFiles = uploadSyncService && uploadSyncService.removeUnusedUploadFiles; + const pool = options && options.pool; + const mediaDir = String(options && options.mediaDir || '').trim(); + + if (!backgroundTaskQueue || typeof collectUploadPathsFromDirectory !== 'function' || typeof removeUnusedUploadFiles !== 'function' || !pool || !mediaDir) { + throw new Error('registerUnusedUploadSweepTask requires the unused upload sweep dependencies.'); + } + + backgroundTaskQueue.registerRecurringTask({ + key: TASK.key, + title: TASK.title, + category: TASK.category, + intervalMs: TASK.intervalMs, + metadata: { + mediaDir: mediaDir + }, + run: async function () { + const uploadPaths = await collectUploadPathsFromDirectory(mediaDir); + if (!uploadPaths.length) { + return; + } + + await removeUnusedUploadFiles(pool, mediaDir, uploadPaths); + } + }); +} + +module.exports = { registerUnusedUploadSweepTask }; \ No newline at end of file diff --git a/src/web/lib/background-tasks/tasks-startup/data-source-refresh.js b/src/web/lib/background-tasks/tasks-startup/data-source-refresh.js new file mode 100644 index 0000000..3bcc82d --- /dev/null +++ b/src/web/lib/background-tasks/tasks-startup/data-source-refresh.js @@ -0,0 +1,76 @@ +const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh'); + +const TASK = { + key: 'startup-data-source-refresh', + category: 'data-source', +}; + +function scheduleStartupDataSourceRefreshes(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const pool = options && options.pool; + const common = options && options.common; + const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250)); + + if (!backgroundTaskQueue || !pool || !common) { + throw new Error('scheduleStartupDataSourceRefreshes requires the startup refresh dependencies.'); + } + + const staggerMs = Math.max(100, Number(dataSourceStartupRefreshStaggerMs || 250)); + + function buildStartupSource(type, id, name, run) { + return { + type: type, + id: Number(id), + name: name, + run: run + }; + } + + return (async function () { + const apiSourcesData = await common.fetchApiSourcesData(pool); + const rssFeedsData = await common.fetchRssFeedsData(pool); + const startupSources = []; + + (apiSourcesData.apiSources || []).forEach(function (apiSource) { + startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () { + return refreshApiSource(pool, common, apiSource, null); + })); + }); + + (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { + startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () { + return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); + })); + }); + + // Stagger startup refreshes to avoid a burst against the DB/player. + startupSources.forEach(function (source, index) { + const startupDelayMs = index * staggerMs; + + setTimeout(function () { + backgroundTaskQueue.enqueueTask({ + key: TASK.key + ':' + source.type + ':' + source.id + ':' + Date.now(), + title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh', + category: TASK.category, + taskType: 'data-source-refresh', + metadata: { + sourceType: source.type, + sourceId: source.id, + sourceName: source.name, + startupRefresh: true + }, + payload: { + sourceType: source.type, + sourceId: source.id, + sourceName: source.name, + startupRefresh: true + } + }).catch(function (error) { + console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error); + }); + }, startupDelayMs); + }); + })(); +} + +module.exports = { scheduleStartupDataSourceRefreshes }; \ No newline at end of file diff --git a/src/web/lib/background-tasks/tasks-startup/font-sync.js b/src/web/lib/background-tasks/tasks-startup/font-sync.js new file mode 100644 index 0000000..1fe7d7e --- /dev/null +++ b/src/web/lib/background-tasks/tasks-startup/font-sync.js @@ -0,0 +1,32 @@ +const { collectFontLibrarySyncOperations } = require('../../media/font-library'); + +const TASK = { + key: 'initial-font-sync', + category: 'fonts' +}; + +function registerInitialFontSyncTask(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const mediaDir = String(options && options.mediaDir || '').trim(); + + if (!backgroundTaskQueue || !mediaDir) { + throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.'); + } + + return backgroundTaskQueue.enqueueTask({ + key: TASK.key, + title: 'Initial font sync', + category: TASK.category, + taskType: 'font-sync', + payload: { + mode: 'initial', + uploadDir: mediaDir, + operations: collectFontLibrarySyncOperations(mediaDir) + }, + persist: true + }).catch(function (error) { + console.warn('Unable to queue initial font sync:', error); + }); +} + +module.exports = { registerInitialFontSyncTask }; \ No newline at end of file diff --git a/src/web/lib/background-tasks/tasks-startup/media-sync.js b/src/web/lib/background-tasks/tasks-startup/media-sync.js new file mode 100644 index 0000000..5eb69ea --- /dev/null +++ b/src/web/lib/background-tasks/tasks-startup/media-sync.js @@ -0,0 +1,29 @@ +const TASK = { + key: 'initial-media-sync', + category: 'media-sync', +}; + +function registerInitialMediaSyncTask(options) { + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const mediaDir = String(options && options.mediaDir || '').trim(); + + if (!backgroundTaskQueue || !mediaDir) { + throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.'); + } + + return backgroundTaskQueue.enqueueTask({ + key: TASK.key, + title: 'Initial media sync', + category: TASK.category, + taskType: 'media-sync', + payload: { + mode: 'initial', + uploadDir: mediaDir + }, + persist: true + }).catch(function (error) { + console.warn('Unable to queue initial media sync:', error); + }); +} + +module.exports = { registerInitialMediaSyncTask }; \ No newline at end of file diff --git a/src/web/lib/config.js b/src/web/lib/config.js new file mode 100644 index 0000000..d921c63 --- /dev/null +++ b/src/web/lib/config.js @@ -0,0 +1,30 @@ +const path = require('path'); + +function createWebConfig() { + const mediaDir = path.join(__dirname, '..', '..', '..', 'media'); + const uploadsDir = path.join(mediaDir, 'uploads'); + const thumbnailsDir = path.join(mediaDir, 'thumbnails'); + const assetDir = path.join(__dirname, '..', 'public'); + const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, ''); + const playerPublicBaseUrl = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:8081').replace(/\/$/, ''); + const sessionCookieName = 'digital_signage_session'; + const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14); + const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000; + const port = Number(process.env.WEB_PORT || 8080); + const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250)); + + return { + port: port, + mediaDir: mediaDir, + uploadsDir: uploadsDir, + thumbnailsDir: thumbnailsDir, + assetDir: assetDir, + playerInternalBaseUrl: playerInternalBaseUrl, + playerPublicBaseUrl: playerPublicBaseUrl, + sessionCookieName: sessionCookieName, + sessionMaxAgeMs: sessionMaxAgeMs, + dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs + }; +} + +module.exports = { createWebConfig }; \ No newline at end of file diff --git a/src/web/lib/dashboard-state.js b/src/web/lib/dashboard-state.js index 368e56e..400f6e8 100644 --- a/src/web/lib/dashboard-state.js +++ b/src/web/lib/dashboard-state.js @@ -1,21 +1,24 @@ +// Helpers for normalizing dashboard state and date values. + const { WebSocket } = require('ws'); function normalizeClientName(value) { return String(value || '').trim(); } -function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug) { +function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) { return (screens || []).map(function (screen) { const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; return Object.assign({}, screen, { client_name: onboardingNameBySlug[screen.slug] || null, player_connection_count: connectionState.count || 0, - player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : [] + player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : [], + player_url: String(playerUrlsBySlug && playerUrlsBySlug[screen.slug] || '').trim() || null }); }); } -function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate) { +function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate) { return (screens || []).flatMap(function (screen) { const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; return (connectionState.connections || []).map(function (connection) { @@ -27,7 +30,7 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa playlist_name: screen.playlist_name || null, connectedAtLabel: formatDashboardDate(connection.connectedAt), lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt), - player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}` + player_url: screen.player_url || String(connection.page || '').trim() || null }); }); }); @@ -51,7 +54,6 @@ function createDashboardStateService(options) { const playerSnapshotCache = options && options.playerSnapshotCache; const playerSnapshotSockets = options && options.playerSnapshotSockets; const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription; - const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, ''); const formatDashboardDate = options && options.formatDashboardDate; if (!pool || !common || !playerSnapshotCache || !playerSnapshotSockets || typeof ensurePlayerSnapshotSubscription !== 'function' || typeof formatDashboardDate !== 'function') { @@ -61,6 +63,9 @@ function createDashboardStateService(options) { async function buildDashboardState() { const data = await common.fetchAdminData(pool); const screensData = data.screens || []; + const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function' + ? await common.fetchScreenPlayerUrls(pool) + : {}; screensData.forEach(function (screen) { ensurePlayerSnapshotSubscription(screen.slug); }); @@ -94,14 +99,14 @@ function createDashboardStateService(options) { } }); - const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug) + const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) .map(function (screen) { return Object.assign({}, screen, { - player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}` + player_url: screen.player_url || null }); }) .sort(compareScreenNames); - const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate); + const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate); const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) { return socket && socket.readyState === WebSocket.OPEN; }); diff --git a/src/web/lib/data-source-refresh.js b/src/web/lib/data-source-refresh.js index 77aa7ff..ad8ad22 100644 --- a/src/web/lib/data-source-refresh.js +++ b/src/web/lib/data-source-refresh.js @@ -1,11 +1,20 @@ -async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) { +async function refreshApiSource(pool, common, apiSourceOrId, actorId) { const connection = await pool.getConnection(); try { + const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object' + ? apiSourceOrId + : typeof common.fetchApiSourceById === 'function' + ? await common.fetchApiSourceById(pool, Number(apiSourceOrId)) + : null; + if (!apiSource) { + throw new Error('API source not found.'); + } + let responseDetails = null; let pullError = ''; try { - responseDetails = await common.fetchApiSourceResponse(apiUrl); + responseDetails = await common.fetchApiSourceResponse(apiSource); } catch (error) { pullError = String(error && error.message ? error.message : 'Unable to load API response.'); } @@ -13,7 +22,7 @@ async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) { await connection.beginTransaction(); await connection.query( 'UPDATE i_api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?', - [new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId] + [new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id] ); await connection.commit(); } catch (error) { diff --git a/src/web/lib/helpers.js b/src/web/lib/helpers.js index e6925fa..9075334 100644 --- a/src/web/lib/helpers.js +++ b/src/web/lib/helpers.js @@ -1,3 +1,5 @@ +// Shared helpers for dashboard date formatting, uploads, and query parsing. + const dashboardDateFormatter = new Intl.DateTimeFormat('en-US', { month: 'short', day: '2-digit', diff --git a/src/web/lib/list-query.js b/src/web/lib/list-query.js index b639c39..e0e52c1 100644 --- a/src/web/lib/list-query.js +++ b/src/web/lib/list-query.js @@ -1,8 +1,22 @@ +// Shared helpers for parsing and sorting web list query state. + function parsePageNumber(value) { const pageNumber = Math.floor(Number(value) || 1); return Math.max(1, pageNumber); } +function getSearchQuery(req) { + return String(req && req.query && req.query.search || '').trim(); +} + +function getSortQuery(req) { + return String(req && req.query && req.query.sort || '').trim(); +} + +function getSortDirectionQuery(req) { + return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; +} + function normalizeSortDirection(value) { return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; } @@ -81,7 +95,10 @@ function createSearchMatcher(searchTerm, fields) { module.exports = { parsePageNumber: parsePageNumber, + getSearchQuery: getSearchQuery, + getSortQuery: getSortQuery, normalizeSortDirection: normalizeSortDirection, + getSortDirectionQuery: getSortDirectionQuery, getComparableSortValue: getComparableSortValue, compareSortValues: compareSortValues, sortRows: sortRows, diff --git a/src/web/lib/notify-player-screens.js b/src/web/lib/notify-player-screens.js new file mode 100644 index 0000000..7c408b2 --- /dev/null +++ b/src/web/lib/notify-player-screens.js @@ -0,0 +1,25 @@ +function createNotifyPlayerScreens(forwardPlayerCommand) { + if (typeof forwardPlayerCommand !== 'function') { + throw new Error('createNotifyPlayerScreens requires a player command sender.'); + } + + return function notifyPlayerScreens(slugs, commandOrPayload) { + const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) { + return String(slug || '').trim(); + }).filter(Boolean))); + + if (!uniqueSlugs.length) { + return Promise.resolve(0); + } + + return Promise.allSettled(uniqueSlugs.map(function (slug) { + return forwardPlayerCommand(slug, commandOrPayload || 'refresh'); + })).then(function (results) { + return results.filter(function (result) { + return result.status === 'fulfilled'; + }).length; + }); + }; +} + +module.exports = { createNotifyPlayerScreens }; \ No newline at end of file diff --git a/src/web/lib/pagination.js b/src/web/lib/pagination.js index 49033e9..8b4fce3 100644 --- a/src/web/lib/pagination.js +++ b/src/web/lib/pagination.js @@ -1,3 +1,5 @@ +// Shared helpers for building paginated list response state. + function normalizePageNumber(value) { const pageNumber = Math.floor(Number(value) || 1); return Math.max(1, pageNumber); @@ -89,6 +91,15 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz const firstQuery = Object.assign({}, queryState, { [normalizedPageParam]: 1 }); const lastQuery = Object.assign({}, queryState, { [normalizedPageParam]: totalPages }); + let normalizedItemLabel = String(itemLabel || 'items'); + const total = Number(totalItems) || 0; + + if (total === 1 && normalizedItemLabel.endsWith('s')) { + normalizedItemLabel = normalizedItemLabel.slice(0, -1); + } else if (total > 1 && !normalizedItemLabel.endsWith('s')) { + normalizedItemLabel += 's'; + } + return { currentPage: safeCurrentPage, totalPages: totalPages, @@ -107,7 +118,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz pages: pages, pageSize: normalizedPageSize, pageParam: normalizedPageParam, - itemLabel: String(itemLabel || 'items'), + itemLabel: String(normalizedItemLabel || 'item'), ariaLabel: String(ariaLabel || 'Pagination') }; } diff --git a/src/web/lib/player-actions.js b/src/web/lib/player-actions.js index 0b6e4ea..2e913a6 100644 --- a/src/web/lib/player-actions.js +++ b/src/web/lib/player-actions.js @@ -1,13 +1,55 @@ -const { createRequestAuthHeaders } = require('../../request-auth'); +const { createRequestAuthHeaders } = require('#src/request-auth'); function createPlayerActionService(options) { - const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); + const pool = options && options.pool; + const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); const common = options && options.common; - if (!playerInternalBaseUrl || !common) { + if (!common) { throw new Error('createPlayerActionService requires the player action dependencies.'); } + let playerInternalBaseUrl = null; + let playerInternalBaseUrlPromise = null; + + async function getPlayerInternalBaseUrl() { + if (playerInternalBaseUrl) { + return playerInternalBaseUrl; + } + + if (playerInternalBaseUrlPromise) { + return playerInternalBaseUrlPromise; + } + + playerInternalBaseUrlPromise = (async function () { + if (!pool) { + return configuredPlayerInternalBaseUrl || null; + } + + try { + const [rows] = await pool.query( + `SELECT internal_base_url + FROM d_players + WHERE device_id = '1' + LIMIT 1` + ); + const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, ''); + return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null; + } catch (_error) { + return configuredPlayerInternalBaseUrl || null; + } + })().then(function (baseUrl) { + playerInternalBaseUrl = baseUrl || null; + playerInternalBaseUrlPromise = null; + return playerInternalBaseUrl; + }, function () { + playerInternalBaseUrlPromise = null; + return configuredPlayerInternalBaseUrl || null; + }); + + return playerInternalBaseUrlPromise; + } + async function forwardPlayerCommand(slug, commandOrPayload, connectionId) { const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null ? Object.assign({}, commandOrPayload) @@ -20,7 +62,12 @@ function createPlayerActionService(options) { pathname: `/api/screens/${encodeURIComponent(slug)}/commands`, body: payload }); - const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, { + const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); + if (!resolvedPlayerInternalBaseUrl) { + throw new Error('Unable to resolve the player internal base URL.'); + } + + const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -42,12 +89,50 @@ function createPlayerActionService(options) { }); } + async function forwardAnnouncementRefresh(slug) { + const authHeaders = createRequestAuthHeaders({ + method: 'POST', + pathname: `/api/screens/${encodeURIComponent(slug)}/announcements/refresh`, + body: { command: 'announcement-refresh' } + }); + const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); + if (!resolvedPlayerInternalBaseUrl) { + throw new Error('Unable to resolve the player internal base URL.'); + } + + const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/announcements/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...authHeaders + }, + body: JSON.stringify({ command: 'announcement-refresh' }) + }); + + if (!response.ok) { + const errorText = await response.text().catch(function () { return ''; }); + const error = new Error(errorText || `Unable to refresh announcements for player ${slug}.`); + error.statusCode = response.status; + throw error; + } + + return response.json().catch(function () { + return { ok: true }; + }); + } + async function getScreenConnections(slug) { const authHeaders = createRequestAuthHeaders({ method: 'GET', pathname: `/api/screens/${encodeURIComponent(slug)}/connections` }); - const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, { + const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); + if (!resolvedPlayerInternalBaseUrl) { + throw new Error('Unable to resolve the player internal base URL.'); + } + + const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, { method: 'GET', headers: { Accept: 'application/json', @@ -110,6 +195,7 @@ function createPlayerActionService(options) { return { forwardPlayerCommand: forwardPlayerCommand, + forwardAnnouncementRefresh: forwardAnnouncementRefresh, getScreenConnections: getScreenConnections, getScreenDeleteBlockMessage: getScreenDeleteBlockMessage, getSlideDeleteBlockMessage: getSlideDeleteBlockMessage, diff --git a/src/web/lib/region-scripts.js b/src/web/lib/region-scripts.js new file mode 100644 index 0000000..b3ff2a1 --- /dev/null +++ b/src/web/lib/region-scripts.js @@ -0,0 +1,36 @@ +const fs = require('fs'); +const path = require('path'); + +const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js'); +const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions'); +const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type'); +const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js']; + +function withAssetVersion(scriptPath, assetVersion) { + if (!assetVersion) { + return scriptPath; + } + + return scriptPath + (String(scriptPath).indexOf('?') === -1 ? '?v=' : '&v=') + encodeURIComponent(String(assetVersion)); +} + +function getRegionModuleScripts(assetVersion) { + if (!fs.existsSync(REGION_TYPE_DIR)) { + return []; + } + + return fs.readdirSync(REGION_TYPE_DIR, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.js')) + .map((entry) => withAssetVersion(`js/regions/type/${entry.name}`, assetVersion)) + .sort((left, right) => left.localeCompare(right)); +} + +function getRegionEditorScripts(assetVersion) { + return REGION_CORE_SCRIPTS.map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion)); +} + +module.exports = { + getRegionModuleScripts, + getRegionEditorScripts, + withAssetVersion +}; \ No newline at end of file diff --git a/src/web/lib/slide-thumbnails.js b/src/web/lib/slide-thumbnails.js deleted file mode 100644 index aea1296..0000000 --- a/src/web/lib/slide-thumbnails.js +++ /dev/null @@ -1,291 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const puppeteer = require('puppeteer-core'); -const chromiumModule = require('@sparticuz/chromium'); -const sharp = require('sharp'); -const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function' - ? chromiumModule - : chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function' - ? chromiumModule.default - : chromiumModule; -const { - escapeHtml, - mediaKind, - renderEditorJsContent, - sanitizeFontFamily, - sanitizeFontSize, - sanitizeTextColor -} = require('../../player/render-helpers'); -const { createRequestAuthHeaders } = require('../../request-auth'); - -const SYSTEM_CHROMIUM_PATHS = [ - process.env.PUPPETEER_EXECUTABLE_PATH, - process.env.CHROMIUM_PATH, - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/usr/local/bin/chromium', - '/snap/bin/chromium' -].filter(Boolean); -const PLAYER_VIEWPORT = { - width: 1920, - height: 1080, - deviceScaleFactor: 1 -}; -const THUMBNAIL_MAX_SIZE = { - width: 480, - height: 270 -}; - -function normalizeBaseUrl(baseUrl) { - return String(baseUrl || '').trim().replace(/\/$/, ''); -} - -function resolveAssetUrl(baseUrl, value) { - const raw = String(value || '').trim(); - if (!raw) { - return ''; - } - if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) { - return raw; - } - const normalizedBaseUrl = normalizeBaseUrl(baseUrl); - if (!normalizedBaseUrl) { - return raw; - } - if (raw.startsWith('/')) { - return normalizedBaseUrl + raw; - } - return normalizedBaseUrl + '/' + raw.replace(/^\/+/, ''); -} - -function getCanvasSize(slide) { - const template = slide && slide.template ? slide.template : null; - return { - width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)), - height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080)) - }; -} - -function getRegionContent(slide, region) { - const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {}; - return content && typeof content === 'object' ? content : { value: content }; -} - -function hasVisibleContent(html) { - return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim()); -} - -function buildTextRegionMarkup(region, regionContent) { - const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family); - const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size); - const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color); - const renderedBody = renderEditorJsContent(regionContent.value || ''); - if (!hasVisibleContent(renderedBody)) { - return ''; - } - - return 'Loading screen...'; -} - -function buildRegionInnerHtml(region, regionContent, baseUrl) { - const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase(); - const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : ''; - - if (regionType === 'image') { - const src = resolveAssetUrl(baseUrl, rawValue); - return src - ? '' + renderedBody + '' - : ''; - } - - if (regionType === 'video') { - const src = resolveAssetUrl(baseUrl, rawValue); - return src - ? '' - : ''; - } - - if (regionType === 'webpage') { - const src = resolveAssetUrl(baseUrl, rawValue); - return src - ? '' - : ''; - } - - if (regionType === 'html') { - const html = String(rawValue || '').trim(); - return html - ? '' - : ''; - } - - if (regionType === 'rtmp') { - const label = String(rawValue || '').trim() || 'RTMP source'; - return '
' + escapeHtml(label) + ''; - } - - return buildTextRegionMarkup(region, regionContent); -} - -async function launchBrowser() { - let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) { - return fs.existsSync(candidate); - }) || ''; - const usingSystemChromium = Boolean(executablePath); - - if (!executablePath && chromium && typeof chromium.executablePath === 'function') { - executablePath = await chromium.executablePath(); - } - - if (!executablePath || !fs.existsSync(executablePath)) { - throw new Error('Chromium executable was not found.'); - } - - if (usingSystemChromium) { - return puppeteer.launch({ - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-gpu' - ], - defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 }, - executablePath: executablePath, - headless: true - }); - } - - return puppeteer.launch({ - args: puppeteer.defaultArgs({ - args: chromium && chromium.args ? chromium.args : [], - headless: 'shell' - }), - defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null, - executablePath: executablePath, - headless: 'shell' - }); -} - -async function captureSlideThumbnail(options) { - const pool = options && options.pool; - const common = options && options.common; - const mediaDir = String(options && options.mediaDir || '').trim(); - const baseUrl = normalizeBaseUrl(options && options.baseUrl); - const slideId = Number(options && options.slideId || 0); - const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim(); - - if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) { - throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.'); - } - - const slide = await common.fetchSlideById(pool, slideId); - if (!slide) { - throw new Error('Slide not found.'); - } - - const canvasSize = getCanvasSize(slide); - const thumbnailDir = path.join(mediaDir, 'thumbnails'); - const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png'; - const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, '')); - const fullSizePath = filePath.replace(/\.png$/i, '.full.png'); - const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png'); - const thumbnailPath = thumbnailRelativePath; - - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - - async function waitForThumbnailRender(page) { - await page.waitForFunction(function () { - return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas')); - }, { timeout: 30000 }); - - await page.waitForFunction(function () { - var canvas = document.querySelector('.slide-canvas'); - if (!canvas) { - return false; - } - - var images = Array.prototype.slice.call(canvas.querySelectorAll('img')); - return images.every(function (image) { - return image.complete && typeof image.naturalWidth === 'number'; - }); - }, { timeout: 30000 }); - - await page.evaluate(async function () { - if (document.fonts && document.fonts.ready) { - try { - await document.fonts.ready; - } catch (_error) { - // Ignore font readiness failures and fall back to the rendered frame. - } - } - }); - - await page.evaluate(function () { - return new Promise(function (resolve) { - window.requestAnimationFrame(function () { - window.requestAnimationFrame(resolve); - }); - }); - }); - } - - const browser = await launchBrowser(); - try { - const page = await browser.newPage(); - try { - const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview'; - const previewUrl = baseUrl + previewPath; - await page.setExtraHTTPHeaders(createRequestAuthHeaders({ - method: 'GET', - pathname: previewPath - })); - await page.setViewport(PLAYER_VIEWPORT); - await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); - await waitForThumbnailRender(page); - const canvas = await page.$('.slide-canvas'); - if (!canvas) { - throw new Error('Player render did not produce a slide canvas.'); - } - await canvas.screenshot({ path: fullSizePath }); - } finally { - await page.close().catch(function () { - return null; - }); - } - } finally { - await browser.close().catch(function () { - return null; - }); - } - - await sharp(fullSizePath) - .resize({ - width: THUMBNAIL_MAX_SIZE.width, - height: THUMBNAIL_MAX_SIZE.height, - fit: 'inside', - withoutEnlargement: true - }) - .png() - .toFile(thumbnailTempPath); - - await fs.promises.rm(filePath, { force: true }); - await fs.promises.rename(thumbnailTempPath, filePath); - await fs.promises.unlink(fullSizePath).catch(function (error) { - if (!error || error.code !== 'ENOENT') { - throw error; - } - }); - - await pool.query('UPDATE c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]); - return { - slideId: slide.id, - thumbnailPath: thumbnailPath, - filePath: filePath, - fullSizePath: fullSizePath, - mediaKind: mediaKind('') - }; -} - -module.exports = { - captureSlideThumbnail: captureSlideThumbnail -}; \ No newline at end of file diff --git a/src/web/lib/upload-sync.js b/src/web/lib/upload-sync.js deleted file mode 100644 index 077c9cf..0000000 --- a/src/web/lib/upload-sync.js +++ /dev/null @@ -1,721 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); -const multer = require('multer'); -const { createRequestAuthHeaders } = require('../../request-auth'); - -function normalizeUploadRoot(uploadDir) { - return path.resolve(String(uploadDir || '').trim()); -} - -function createUploadSyncService(options) { - const pool = options && options.pool; - const common = options && options.common; - const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); - const playerSnapshotCache = options && options.playerSnapshotCache; - const notifyPlayerScreens = options && options.notifyPlayerScreens; - const backgroundTaskQueue = options && options.backgroundTaskQueue; - const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024; - - let playerUploadSyncMode = null; - let playerUploadSyncModePromise = null; - const pendingPlayerUploadSyncs = new Map(); - let pendingPlayerUploadSyncFlushTimer = null; - let pendingPlayerUploadSyncFlushInFlight = null; - const pendingPlaylistUploadSyncs = new Map(); - let pendingPlaylistUploadSyncFlushTimer = null; - let pendingPlaylistUploadSyncFlushInFlight = null; - - if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') { - throw new Error('createUploadSyncService requires the upload dependencies.'); - } - - function createUploadMiddleware(uploadDir) { - const storage = multer.diskStorage({ - destination: function (_req, _file, cb) { - cb(null, uploadDir); - }, - filename: function (_req, file, cb) { - const safeExt = path.extname(file.originalname || '').toLowerCase(); - const stamp = `${Date.now()}-${crypto.randomUUID()}`; - cb(null, `${stamp}${safeExt}`); - } - }); - return multer({ - storage: storage, - limits: { - fileSize: MAX_UPLOAD_BYTES - } - }); - } - - function normalizeUploadReference(uploadPath) { - const value = String(uploadPath || '').trim(); - if (!value || !value.startsWith('/media/')) { - return null; - } - return value; - } - - function getUploadRelativePath(uploadPath) { - const value = normalizeUploadReference(uploadPath); - if (!value) { - return null; - } - return value.replace(/^\/media\//, ''); - } - - function resolveUploadFilePath(uploadDir, uploadPath) { - const relativePath = getUploadRelativePath(uploadPath); - if (!relativePath) { - return null; - } - - const normalizedUploadDir = normalizeUploadRoot(uploadDir); - if (!normalizedUploadDir) { - return null; - } - - const mediaRoot = path.basename(normalizedUploadDir) === 'uploads' - ? path.dirname(normalizedUploadDir) - : normalizedUploadDir; - - if (relativePath.startsWith('uploads/')) { - return path.join(mediaRoot, relativePath); - } - - return path.join(mediaRoot, relativePath); - } - - function collectUploadReferencesFromValue(value, refs) { - if (!value) { - return refs; - } - const stack = [value]; - while (stack.length) { - const current = stack.pop(); - if (Array.isArray(current)) { - current.forEach(function (item) { - stack.push(item); - }); - continue; - } - if (current && typeof current === 'object') { - Object.keys(current).forEach(function (key) { - stack.push(current[key]); - }); - continue; - } - if (typeof current === 'string') { - const reference = normalizeUploadReference(current); - if (reference) { - refs.add(reference); - } - } - } - return refs; - } - - function collectUploadReferencesFromSlide(slide) { - const refs = new Set(); - if (!slide) { - return refs; - } - collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs); - return refs; - } - - function collectUploadReferencesFromTemplate(template) { - const refs = new Set(); - if (!template) { - return refs; - } - collectUploadReferencesFromValue(template.background_image_path, refs); - return refs; - } - - function collectUploadReferencesFromPayload(payload) { - const refs = new Set(); - if (!payload) { - return refs; - } - collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs); - collectUploadReferencesFromValue(payload.backgroundImagePath, refs); - return refs; - } - - async function countUploadReferences(pool, uploadPath) { - const [slideRows] = await pool.query( - `SELECT COUNT(*) AS ref_count - FROM c_slides - WHERE JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`, - [uploadPath] - ); - const [thumbnailRows] = await pool.query( - 'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?', - [uploadPath] - ); - const [templateRows] = await pool.query( - 'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?', - [uploadPath] - ); - return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0); - } - - async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) { - const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean))); - for (let i = 0; i < uniquePaths.length; i += 1) { - const uploadPath = uniquePaths[i]; - const referenceCount = await countUploadReferences(pool, uploadPath); - if (referenceCount > 0) { - continue; - } - - const filePath = resolveUploadFilePath(uploadDir, uploadPath); - try { - await fs.promises.unlink(filePath); - } catch (error) { - if (error && error.code !== 'ENOENT') { - console.warn('Unable to remove unused upload file:', filePath, error); - } - } - queuePlayerUploadSync({ - type: 'delete', - uploadPath: uploadPath, - uploadDir: uploadDir - }); - } - } - - async function collectUploadPathsFromDirectory(uploadDir) { - const normalizedUploadDir = normalizeUploadRoot(uploadDir); - if (!normalizedUploadDir) { - return []; - } - - const uploadPaths = []; - - async function walkDirectory(currentDir, relativeDir) { - let entries = []; - try { - entries = await fs.promises.readdir(currentDir, { withFileTypes: true }); - } catch (error) { - if (error && error.code !== 'ENOENT') { - console.warn('Unable to read upload directory:', currentDir, error); - } - return; - } - - for (const entry of entries) { - const entryName = String(entry && entry.name || '').trim(); - if (!entryName || entryName === '.' || entryName === '..') { - continue; - } - - const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName; - const nextAbsolutePath = path.join(currentDir, entryName); - - if (entry.isDirectory && entry.isDirectory()) { - await walkDirectory(nextAbsolutePath, nextRelativePath); - continue; - } - - if (entry.isFile && !entry.isFile()) { - continue; - } - - uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/')); - } - } - - await walkDirectory(normalizedUploadDir, ''); - return uploadPaths; - } - - async function getPlayerUploadSyncMode(localUploadDir) { - if (playerUploadSyncMode) { - return playerUploadSyncMode; - } - if (playerUploadSyncModePromise) { - return playerUploadSyncModePromise; - } - - playerUploadSyncModePromise = (async function () { - try { - const authHeaders = createRequestAuthHeaders({ - method: 'GET', - pathname: '/api/media/config' - }); - const response = await fetch(`${playerInternalBaseUrl}/api/media/config`, { - headers: { - Accept: 'application/json', - ...authHeaders - } - }); - if (!response.ok) { - return null; - } - const data = await response.json(); - const playerUploadDir = data && (data.uploadDir || data.mediaDir) ? normalizeUploadRoot(data.uploadDir || data.mediaDir) : null; - if (!playerUploadDir) { - return null; - } - return playerUploadDir === normalizeUploadRoot(localUploadDir) ? 'shared' : 'different'; - } catch (_error) { - return null; - } - })().then(function (mode) { - if (mode) { - playerUploadSyncMode = mode; - } - playerUploadSyncModePromise = null; - return mode; - }, function () { - playerUploadSyncModePromise = null; - return null; - }); - - return playerUploadSyncModePromise; - } - - async function shouldMirrorUploads(localUploadDir) { - return Boolean(localUploadDir); - } - - function queuePlayerUploadSync(operation) { - if (!operation || !operation.uploadPath) { - return; - } - - pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), { - type: operation.type === 'delete' ? 'delete' : 'put', - uploadPath: normalizeUploadReference(operation.uploadPath), - uploadDir: operation.uploadDir || null - }); - - schedulePendingPlayerUploadSyncFlush(); - } - - function schedulePendingPlayerUploadSyncFlush() { - if (pendingPlayerUploadSyncFlushTimer) { - return; - } - - pendingPlayerUploadSyncFlushTimer = setTimeout(function () { - pendingPlayerUploadSyncFlushTimer = null; - flushPendingPlayerUploadSyncs().catch(function (error) { - console.warn('Unable to flush pending upload syncs:', error); - }); - }, 5000); - } - - async function pushUploadFileToPlayer(uploadPath, localUploadDir) { - if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) { - return false; - } - - const relativePath = getUploadRelativePath(uploadPath); - const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath); - if (!relativePath || !sourcePath) { - return false; - } - let fileBuffer = null; - try { - fileBuffer = await fs.promises.readFile(sourcePath); - } catch (error) { - if (!error || error.code !== 'ENOENT') { - console.warn('Unable to read upload for player sync:', sourcePath, error); - } - return; - } - - try { - const authHeaders = createRequestAuthHeaders({ - method: 'PUT', - pathname: `/api/media/${encodeURIComponent(relativePath)}`, - body: fileBuffer - }); - const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/octet-stream', - ...authHeaders - }, - body: fileBuffer - }); - if (!response.ok) { - console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText); - return false; - } - return true; - } catch (error) { - console.warn('Unable to sync upload to player:', relativePath, error); - return false; - } - } - - async function removeUploadFileFromPlayer(uploadPath, localUploadDir) { - if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) { - return false; - } - - const relativePath = getUploadRelativePath(uploadPath); - if (!relativePath) { - return false; - } - try { - const authHeaders = createRequestAuthHeaders({ - method: 'DELETE', - pathname: `/api/media/${encodeURIComponent(relativePath)}` - }); - const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { - method: 'DELETE', - headers: { - Accept: 'application/json', - ...authHeaders - } - }); - if (!response.ok && response.status !== 404) { - console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText); - return false; - } - return true; - } catch (error) { - console.warn('Unable to remove upload from player:', relativePath, error); - return false; - } - } - - async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) { - if (!(await shouldMirrorUploads(localUploadDir))) { - return; - } - - const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean))); - for (let i = 0; i < uniqueRefs.length; i += 1) { - const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir); - if (!success) { - queuePlayerUploadSync({ - type: 'put', - uploadPath: uniqueRefs[i], - uploadDir: localUploadDir - }); - } - } - } - - async function syncExistingUploadsToPlayer(pool, localUploadDir) { - return queueMediaSyncTask('media-sync:initial', 'Initial media sync', { - mode: 'initial', - uploadDir: localUploadDir - }); - } - - function getVisibleCurrentSlideIds() { - const visibleSlideIds = new Set(); - playerSnapshotCache.forEach(function (snapshot) { - const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : []; - connections.forEach(function (connection) { - const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object' - ? connection.currentSlide - : null; - const slideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null - ? String(currentSlide.id).trim() - : ''; - if (slideId) { - visibleSlideIds.add(slideId); - } - }); - }); - return visibleSlideIds; - } - - function isScreenRefreshBlocked(screenSlug, blockedSlideIds, screenSlideCounts) { - const slideIds = Array.isArray(blockedSlideIds) - ? blockedSlideIds.map(function (value) { - return String(value || '').trim(); - }).filter(Boolean) - : []; - if (!slideIds.length) { - return false; - } - const normalizedScreenSlug = String(screenSlug || '').trim(); - const slideCount = screenSlideCounts && Object.prototype.hasOwnProperty.call(screenSlideCounts, normalizedScreenSlug) - ? Number(screenSlideCounts[normalizedScreenSlug]) - : null; - if (Number.isFinite(slideCount) && slideCount <= 1) { - return false; - } - const snapshot = playerSnapshotCache.get(String(screenSlug || '').trim()); - const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : []; - return connections.some(function (connection) { - const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object' - ? connection.currentSlide - : null; - const currentSlideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null - ? String(currentSlide.id).trim() - : ''; - return currentSlideId && slideIds.includes(currentSlideId); - }); - } - - function splitRefreshScreenSlugsByVisibility(screenSlugs, blockedSlideIds, screenSlideCounts) { - const ready = []; - const blocked = []; - Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).forEach(function (screenSlug) { - const normalizedScreenSlug = String(screenSlug || '').trim(); - if (!normalizedScreenSlug) { - return; - } - if (isScreenRefreshBlocked(normalizedScreenSlug, blockedSlideIds, screenSlideCounts)) { - blocked.push(normalizedScreenSlug); - } else { - ready.push(normalizedScreenSlug); - } - }); - return { ready: ready, blocked: blocked }; - } - - function normalizePlaylistUploadSyncOperation(options) { - return { - key: String(options && options.key ? options.key : '').trim(), - pool: options && options.pool ? options.pool : null, - localUploadDir: options && options.localUploadDir ? options.localUploadDir : null, - previousUploadRefs: Array.from(new Set(options && options.previousUploadRefs ? options.previousUploadRefs : [])), - nextUploadRefs: Array.from(new Set(options && options.nextUploadRefs ? options.nextUploadRefs : [])), - blockedSlideIds: Array.from(new Set(options && options.blockedSlideIds ? options.blockedSlideIds : [])).map(function (value) { - return String(value || '').trim(); - }).filter(Boolean), - refreshScreenSlugs: Array.from(new Set(options && options.refreshScreenSlugs ? options.refreshScreenSlugs : [])).map(function (value) { - return String(value || '').trim(); - }).filter(Boolean) - , - screenSlideCounts: options && options.screenSlideCounts && typeof options.screenSlideCounts === 'object' - ? options.screenSlideCounts - : {} - }; - } - - function queuePlaylistUploadSync(operation) { - if (!operation || !operation.key) { - return; - } - pendingPlaylistUploadSyncs.set(operation.key, normalizePlaylistUploadSyncOperation(operation)); - schedulePendingPlaylistUploadSyncFlush(); - } - - function schedulePendingPlaylistUploadSyncFlush() { - if (pendingPlaylistUploadSyncFlushTimer) { - return; - } - - pendingPlaylistUploadSyncFlushTimer = setTimeout(function () { - pendingPlaylistUploadSyncFlushTimer = null; - flushPendingPlaylistUploadSyncs().catch(function (error) { - console.warn('Unable to flush pending playlist upload syncs:', error); - }); - }, 5000); - } - - async function syncPlaylistUploadsOnChange(options) { - const operation = normalizePlaylistUploadSyncOperation(options); - if (!operation.key) { - return; - } - - return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', { - mode: 'playlist', - operation: operation - }); - } - - async function flushPendingPlaylistUploadSyncs() { - if (pendingPlaylistUploadSyncFlushInFlight) { - return pendingPlaylistUploadSyncFlushInFlight; - } - - if (!pendingPlaylistUploadSyncs.size) { - return null; - } - - pendingPlaylistUploadSyncFlushInFlight = (async function () { - const pendingEntries = Array.from(pendingPlaylistUploadSyncs.values()); - for (let i = 0; i < pendingEntries.length; i += 1) { - const operation = pendingEntries[i]; - const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts); - if (!refreshTargets.ready.length) { - continue; - } - - if (refreshTargets.ready.length) { - await notifyPlayerScreens(refreshTargets.ready, 'refresh'); - } - - pendingPlaylistUploadSyncs.delete(operation.key); - } - })().finally(function () { - pendingPlaylistUploadSyncFlushInFlight = null; - if (pendingPlaylistUploadSyncs.size) { - schedulePendingPlaylistUploadSyncFlush(); - } - }); - - return pendingPlaylistUploadSyncFlushInFlight; - } - - async function flushPendingPlayerUploadSyncs() { - if (pendingPlayerUploadSyncFlushInFlight) { - return pendingPlayerUploadSyncFlushInFlight; - } - - if (!pendingPlayerUploadSyncs.size) { - return null; - } - - pendingPlayerUploadSyncFlushInFlight = (async function () { - const pendingEntries = Array.from(pendingPlayerUploadSyncs.values()); - for (let i = 0; i < pendingEntries.length; i += 1) { - const operation = pendingEntries[i]; - let success = false; - if (operation.type === 'delete') { - success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir); - } else { - success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir); - } - if (success) { - pendingPlayerUploadSyncs.delete(operation.uploadPath); - } - } - })().finally(function () { - pendingPlayerUploadSyncFlushInFlight = null; - if (pendingPlayerUploadSyncs.size) { - schedulePendingPlayerUploadSyncFlush(); - } - }); - - return pendingPlayerUploadSyncFlushInFlight; - } - - async function runMediaSyncTask(payload) { - const taskPayload = payload || {}; - const mode = String(taskPayload.mode || '').trim(); - - if (mode === 'initial') { - const uploadDir = String(taskPayload.uploadDir || '').trim(); - if (!(await shouldMirrorUploads(uploadDir))) { - return; - } - - const data = await common.fetchAdminData(pool); - const uploadRefs = new Set(); - (data.slides || []).forEach(function (slide) { - collectUploadReferencesFromSlide(slide).forEach(function (reference) { - uploadRefs.add(reference); - }); - }); - (data.templates || []).forEach(function (template) { - collectUploadReferencesFromTemplate(template).forEach(function (reference) { - uploadRefs.add(reference); - }); - }); - Array.from(uploadRefs).forEach(function (uploadPath) { - queuePlayerUploadSync({ - type: 'put', - uploadPath: uploadPath, - uploadDir: uploadDir - }); - }); - await flushPendingPlayerUploadSyncs(); - return; - } - - if (mode === 'playlist') { - const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload); - - if (operation.nextUploadRefs.length) { - await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir); - } - - if (operation.previousUploadRefs.length) { - const nextUploadRefSet = new Set(operation.nextUploadRefs); - await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) { - return !nextUploadRefSet.has(reference); - })); - } - - if (operation.refreshScreenSlugs.length) { - const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts); - if (refreshTargets.ready.length) { - await notifyPlayerScreens(refreshTargets.ready, 'refresh'); - } - refreshTargets.blocked.forEach(function (screenSlug) { - queuePlaylistUploadSync({ - key: operation.key + ':refresh:' + screenSlug, - blockedSlideIds: operation.blockedSlideIds, - refreshScreenSlugs: [screenSlug] - }); - }); - } - return; - } - - throw new Error('Unknown media sync task mode.'); - } - - async function queueMediaSyncTask(taskKey, title, payload) { - const safePayload = Object.assign({}, payload || {}); - delete safePayload.pool; - if (safePayload.operation && typeof safePayload.operation === 'object') { - safePayload.operation = Object.assign({}, safePayload.operation); - delete safePayload.operation.pool; - } - - const definition = { - key: taskKey, - title: title, - category: 'media-sync', - taskType: 'media-sync', - payload: safePayload, - persist: true - }; - - if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTaskAndWait === 'function') { - return backgroundTaskQueue.enqueueTaskAndWait(definition); - } - - return runMediaSyncTask(safePayload); - } - - return { - createUploadMiddleware: createUploadMiddleware, - normalizeUploadReference: normalizeUploadReference, - collectUploadReferencesFromValue: collectUploadReferencesFromValue, - collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, - collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, - collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, - countUploadReferences: countUploadReferences, - removeUnusedUploadFiles: removeUnusedUploadFiles, - collectUploadPathsFromDirectory: collectUploadPathsFromDirectory, - getPlayerUploadSyncMode: getPlayerUploadSyncMode, - shouldMirrorUploads: shouldMirrorUploads, - queuePlayerUploadSync: queuePlayerUploadSync, - schedulePendingPlayerUploadSyncFlush: schedulePendingPlayerUploadSyncFlush, - pushUploadFileToPlayer: pushUploadFileToPlayer, - removeUploadFileFromPlayer: removeUploadFileFromPlayer, - syncUploadRefsToPlayer: syncUploadRefsToPlayer, - syncExistingUploadsToPlayer: syncExistingUploadsToPlayer, - getVisibleCurrentSlideIds: getVisibleCurrentSlideIds, - isScreenRefreshBlocked: isScreenRefreshBlocked, - splitRefreshScreenSlugsByVisibility: splitRefreshScreenSlugsByVisibility, - normalizePlaylistUploadSyncOperation: normalizePlaylistUploadSyncOperation, - queuePlaylistUploadSync: queuePlaylistUploadSync, - schedulePendingPlaylistUploadSyncFlush: schedulePendingPlaylistUploadSyncFlush, - syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, - flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs, - flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs, - runMediaSyncTask: runMediaSyncTask, - queueMediaSyncTask: queueMediaSyncTask - }; -} - -module.exports = { createUploadSyncService }; \ No newline at end of file diff --git a/src/web/lib/web-startup.js b/src/web/lib/web-startup.js new file mode 100644 index 0000000..9650b64 --- /dev/null +++ b/src/web/lib/web-startup.js @@ -0,0 +1,34 @@ +async function initializeWebServer(options) { + const common = options && options.common; + const pool = options && options.pool; + const mediaDir = String(options && options.mediaDir || '').trim(); + const backgroundTaskQueue = options && options.backgroundTaskQueue; + const webBootstrap = options && options.webBootstrap; + const loadCurrentUser = options && options.loadCurrentUser; + const initializeBackgroundTasks = options && options.initializeBackgroundTasks; + const captureSlideThumbnail = options && options.captureSlideThumbnail; + const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250)); + const server = options && options.server; + + if (!common || !pool || !mediaDir || !backgroundTaskQueue || !webBootstrap || typeof loadCurrentUser !== 'function' || typeof initializeBackgroundTasks !== 'function' || typeof captureSlideThumbnail !== 'function' || !server) { + throw new Error('initializeWebServer requires the web startup dependencies.'); + } + + await common.ensureSchema(pool, { mediaDir: mediaDir }); + await common.bootstrapDatabase(pool); + await backgroundTaskQueue.initialize(); + + await initializeBackgroundTasks({ + pool: pool, + common: common, + backgroundTaskQueue: backgroundTaskQueue, + uploadSyncService: webBootstrap.uploadSyncService, + captureSlideThumbnail: captureSlideThumbnail, + mediaDir: mediaDir, + dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs + }); + + webBootstrap.installDashboardWebsocket(server, loadCurrentUser); +} + +module.exports = { initializeWebServer }; \ No newline at end of file diff --git a/src/web/middleware.js b/src/web/middleware.js new file mode 100644 index 0000000..eff0b12 --- /dev/null +++ b/src/web/middleware.js @@ -0,0 +1,38 @@ +// Shared web middleware for auth, media, and upload handling. + +const express = require('express'); +const fs = require('fs'); + +module.exports = function registerMiddleware(app, deps) { + const pool = deps.pool; + const loadCurrentUser = deps.loadCurrentUser; + const requireAuth = deps.requireAuth; + const mediaDir = deps.MEDIA_DIR; + const uploadsDir = deps.UPLOADS_DIR; + const thumbnailsDir = deps.THUMBNAILS_DIR; + const assetDir = deps.ASSET_DIR; + + app.use(express.urlencoded({ extended: true })); + app.use(express.json()); + app.use('/assets', express.static(assetDir)); + app.use('/media', express.static(mediaDir)); + fs.mkdirSync(uploadsDir, { recursive: true }); + fs.mkdirSync(thumbnailsDir, { recursive: true }); + + app.use(async function (req, _res, next) { + try { + req.currentUser = await loadCurrentUser(pool, req); + next(); + } catch (error) { + next(error); + } + }); + + app.use(function (req, res, next) { + if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) { + return next(); + } + + return requireAuth(req, res, next); + }); +}; \ No newline at end of file diff --git a/src/web/pages/error.js b/src/web/pages/error.js index 5e747d4..14ded86 100644 --- a/src/web/pages/error.js +++ b/src/web/pages/error.js @@ -1,3 +1,5 @@ +// Error page renderer for the web UI. + const { renderView } = require('../view'); function getErrorCopy(statusCode, message) { diff --git a/src/web/pages/index.js b/src/web/pages/index.js index 3a4180f..cd01570 100644 --- a/src/web/pages/index.js +++ b/src/web/pages/index.js @@ -1,3 +1,5 @@ +// Page route-to-view mapping for the web UI. + const path = require('path'); function routePath(...segments) { @@ -25,6 +27,9 @@ module.exports = { renderScreensPage: require(routePath('signage', 'screens', 'list')), renderScreenFormPage: require(routePath('signage', 'screens', 'add')), renderScreenEditPage: require(routePath('signage', 'screens', 'edit')), + renderAnnouncementsPage: require(routePath('signage', 'announcements', 'list')), + renderAnnouncementFormPage: require(routePath('signage', 'announcements', 'add')), + renderAnnouncementEditPage: require(routePath('signage', 'announcements', 'edit')), renderSlidesPage: require(routePath('signage', 'slides', 'list')), renderSlideFormPage: require(routePath('signage', 'slides', 'form')), renderTemplatesPage: require(routePath('signage', 'templates', 'list')), @@ -33,6 +38,7 @@ module.exports = { renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')), renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')), renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')), + renderFontsPage: require(routePath('settings', 'fonts', 'list')), renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage, renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage, renderErrorPage: require('./error'), diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 45b8c9c..f114386 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -180,6 +180,141 @@ max-width: 14rem; } +.announcement-icon-picker { + position: relative; +} + +.announcement-icon-picker__toggle { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + min-height: calc(1.5em + 0.75rem + 2px); + padding: 0.375rem 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.375rem; + background: var(--bs-body-bg); + color: var(--bs-body-color); + text-align: left; +} + +.announcement-icon-picker__toggle:focus-visible { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25); +} + +.announcement-icon-picker__toggle-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.5rem; + background: rgba(var(--bs-primary-rgb), 0.1); + color: var(--bs-primary); + font-size: 1rem; + flex: 0 0 auto; +} + +.announcement-icon-picker__toggle-label { + flex: 1 1 auto; + min-width: 0; + font-weight: 600; +} + +.announcement-icon-picker__caret { + flex: 0 0 auto; + color: var(--bs-secondary-color); + font-size: 0.8rem; +} + +.announcement-icon-picker__menu { + position: absolute; + z-index: 20; + top: calc(100% + 0.5rem); + bottom: auto; + left: 0; + width: min(100%, 28rem); + max-width: calc(100vw - 1rem); + padding: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 1rem; + background: var(--bs-body-bg); + box-shadow: 0 1rem 2rem rgba(15, 23, 42, 0.18); +} + +.announcement-icon-picker__menu.is-open-above { + top: auto; + bottom: calc(100% + 0.5rem); +} + +.announcement-icon-picker__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; + font-size: 0.875rem; + font-weight: 700; +} + +.announcement-icon-picker__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(2.75rem, 1fr)); + gap: 0.5rem; +} + +.announcement-icon-picker__option { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: var(--bs-body-bg); + color: var(--bs-body-color); + transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease; +} + +.announcement-icon-picker__option:hover, +.announcement-icon-picker__option:focus-visible { + border-color: rgba(var(--bs-primary-rgb), 0.7); + background: rgba(var(--bs-primary-rgb), 0.08); + box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15); + transform: translateY(-1px); +} + +.announcement-icon-picker__option.is-selected { + border-color: var(--bs-primary); + background: rgba(var(--bs-primary-rgb), 0.12); + color: var(--bs-primary); + box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12); +} + +.announcement-icon-picker__option i { + font-size: 1.05rem; + line-height: 1; +} + +@media (max-width: 576px) { + .announcement-icon-picker__menu { + width: calc(100vw - 2rem); + right: 0; + left: auto; + } + + .announcement-icon-picker__grid { + grid-template-columns: repeat(auto-fill, minmax(2.5rem, 1fr)); + } +} + +.fonts-table-responsive { + border-bottom-left-radius: var(--bs-card-border-radius); + border-bottom-right-radius: var(--bs-card-border-radius); + overflow: hidden; +} + .background-tasks-task-tools { flex-wrap: nowrap; } @@ -194,6 +329,69 @@ flex: 0 0 auto; } +.background-tasks-page { + overflow: hidden; +} + +.table-pagination-page .app-main { + padding-bottom: 0; +} + +.table-pagination-page .app-content { + height: 100%; + overflow: hidden; +} + +.table-pagination-page .app-content .container-fluid { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + box-sizing: border-box; + padding-top: 0; + padding-bottom: 0; +} + +.background-tasks-page .app-content .container-fluid { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + box-sizing: border-box; + padding-bottom: 1rem; +} + +.background-tasks-page .app-main { + height: 100%; + padding-bottom: 0; +} + +.background-tasks-page .app-content { + height: 100%; + overflow: hidden; +} + +[data-table-pagination-card] { + display: flex; + flex: 0 1 auto; + flex-direction: column; + min-height: 0; + max-height: var(--table-pagination-card-max-height, var(--background-tasks-task-card-max-height, calc(100dvh - 12rem))); + overflow: hidden; +} + +[data-table-pagination-card] > .card-body.table-responsive { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +[data-table-pagination-card] > .card-footer { + margin-top: auto; + background: var(--bs-body-bg); + border-top: 1px solid var(--bs-border-color); +} + .admin-form-card { box-shadow: none; } @@ -202,6 +400,20 @@ background: var(--bs-tertiary-bg); } +.api-source-section-heading { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 1rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid var(--bs-border-color); + color: var(--bs-primary); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .dashboard-hero { overflow: hidden; background: linear-gradient(135deg, rgba(13, 110, 253, 0.08), rgba(32, 201, 151, 0.08)); @@ -468,6 +680,11 @@ white-space: nowrap; } +.chip[data-time-date-placeholder-chip] { + cursor: pointer; + user-select: none; +} + .empty { color: var(--bs-secondary-color); font-style: italic; @@ -532,281 +749,179 @@ td[data-label="Slides"] { margin-top: 0.25rem; } -.slide-form-stack .ck.ck-editor, -.template-field-card .ck.ck-editor { - --ck-custom-background: var(--bs-body-bg); - --ck-custom-foreground: var(--bs-body-color); - --ck-custom-border: var(--bs-border-color); - --ck-color-base-foreground: var(--bs-tertiary-bg); - --ck-color-base-background: var(--bs-body-bg); - --ck-color-base-border: var(--bs-border-color); - --ck-color-base-text: var(--bs-body-color); - --ck-color-base-action: var(--bs-primary); - --ck-color-base-focus: var(--bs-primary); - --ck-color-base-active: var(--bs-primary); - --ck-color-base-active-focus: var(--bs-primary); - --ck-color-base-error: var(--bs-danger); - --ck-color-focus-border: rgba(var(--bs-primary-rgb), 1); - --ck-color-focus-outer-shadow: rgba(var(--bs-primary-rgb), 0.18); - --ck-color-focus-disabled-shadow: rgba(var(--bs-secondary-rgb), 0.14); - --ck-color-focus-error-shadow: rgba(var(--bs-danger-rgb), 0.18); - --ck-color-shadow-drop: rgba(0, 0, 0, 0.16); - --ck-color-shadow-drop-active: rgba(0, 0, 0, 0.22); - --ck-color-shadow-inner: rgba(0, 0, 0, 0.08); - --ck-color-button-default-background: transparent; - --ck-color-button-default-hover-background: var(--bs-secondary-bg); - --ck-color-button-default-active-background: var(--bs-secondary-bg); - --ck-color-button-default-disabled-background: transparent; - --ck-color-button-on-background: rgba(var(--bs-primary-rgb), 0.12); - --ck-color-button-on-hover-background: rgba(var(--bs-primary-rgb), 0.16); - --ck-color-button-on-active-background: rgba(var(--bs-primary-rgb), 0.16); - --ck-color-button-on-disabled-background: var(--bs-secondary-bg); - --ck-color-button-on-color: var(--bs-primary); - --ck-color-button-action-background: var(--bs-primary); - --ck-color-button-action-hover-background: var(--bs-primary); - --ck-color-button-action-active-background: var(--bs-primary); - --ck-color-button-action-disabled-background: var(--bs-primary); - --ck-color-button-action-text: var(--bs-body-bg); - --ck-color-switch-button-off-background: var(--bs-secondary-color); - --ck-color-switch-button-off-hover-background: var(--bs-secondary-color); - --ck-color-switch-button-on-background: var(--bs-primary); - --ck-color-switch-button-on-hover-background: var(--bs-primary); - --ck-color-switch-button-inner-background: var(--bs-body-bg); - --ck-color-switch-button-inner-shadow: rgba(0, 0, 0, 0.08); - --ck-color-dropdown-panel-background: var(--bs-body-bg); - --ck-color-dropdown-panel-border: var(--bs-border-color); - --ck-color-dialog-background: var(--bs-body-bg); - --ck-color-dialog-form-header-border: var(--bs-border-color); - --ck-color-list-button-on-background: rgba(var(--bs-primary-rgb), 0.12); - --ck-color-list-button-on-text: var(--bs-primary); - --ck-color-input-background: var(--bs-body-bg); - --ck-color-input-border: var(--bs-border-color); - --ck-color-input-text: var(--bs-body-color); - --ck-color-input-disabled-background: var(--bs-secondary-bg); - --ck-color-input-disabled-border: var(--bs-border-color); - --ck-color-input-disabled-text: var(--bs-secondary-color); - --ck-color-toolbar-background: var(--bs-tertiary-bg); - --ck-color-toolbar-border: var(--bs-border-color); - --ck-color-toolbar-box-shadow: none; - --ck-color-image-caption-background: var(--bs-secondary-bg); - --ck-color-image-caption-text: var(--bs-secondary-color); - --ck-color-resizer: var(--bs-primary); - --ck-color-resizer-tooltip-background: var(--bs-dark); - --ck-color-resizer-tooltip-text: var(--bs-light); - border-radius: var(--bs-border-radius); - overflow: hidden; - background: var(--bs-body-bg); - border: 1px solid var(--bs-border-color); +.slide-form-stack .editor-holder, +.template-field-card .editor-holder { + position: relative; } -.slide-form-stack .ck.ck-editor__top .ck-sticky-panel__content, -.template-field-card .ck.ck-editor__top .ck-sticky-panel__content { +.slide-form-stack .editor-holder textarea, +.template-field-card .editor-holder textarea { + width: 100%; + min-height: 10rem; +} + +.slide-form-stack .tox, +.template-field-card .tox { + border: 1px solid var(--bs-border-color); + overflow: visible !important; + background: var(--bs-body-bg); +} + +.slide-form-stack .tox.tox-tinymce, +.template-field-card .tox.tox-tinymce { + border-radius: 0 !important; +} + +.slide-form-stack .tox.tox-edit-focus, +.template-field-card .tox.tox-edit-focus { + border-color: rgba(var(--bs-primary-rgb), 0.22) !important; + box-shadow: 0 0 0 0.05rem rgba(var(--bs-primary-rgb), 0.05) !important; +} + +.slide-form-stack .tox .tox-edit-area::before, +.template-field-card .tox .tox-edit-area::before { + border: 1px solid rgba(var(--bs-primary-rgb), 0.08) !important; +} + +.slide-form-stack .tox.tox-edit-focus .tox-edit-area::before, +.template-field-card .tox.tox-edit-focus .tox-edit-area::before { + opacity: 0.35 !important; +} + +.slide-form-stack .tox .tox-editor-header, +.template-field-card .tox .tox-editor-header { border-bottom: 1px solid var(--bs-border-color); } -.slide-form-stack .ck.ck-toolbar, -.template-field-card .ck.ck-toolbar { +.slide-form-stack .tox .tox-toolbar, +.template-field-card .tox .tox-toolbar { background: var(--bs-tertiary-bg); border: 0; - border-radius: 0; } -.slide-form-stack .ck.ck-toolbar .ck-toolbar__items, -.template-field-card .ck.ck-toolbar .ck-toolbar__items { - gap: 0.15rem; +.slide-form-stack .tox .tox-toolbar__group, +.template-field-card .tox .tox-toolbar__group { + border-color: var(--bs-border-color); } -.slide-form-stack .ck.ck-toolbar .ck-button, -.slide-form-stack .ck.ck-toolbar .ck-dropdown__button, -.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action, -.template-field-card .ck.ck-toolbar .ck-button, -.template-field-card .ck.ck-toolbar .ck-dropdown__button, -.template-field-card .ck.ck-toolbar .ck-splitbutton__action { - border-radius: 0.5rem; +.slide-form-stack .tox .tox-tbtn, +.template-field-card .tox .tox-tbtn { color: var(--bs-body-color); } -.slide-form-stack .ck.ck-toolbar .ck-button:hover, -.slide-form-stack .ck.ck-toolbar .ck-dropdown__button:hover, -.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action:hover, -.template-field-card .ck.ck-toolbar .ck-button:hover, -.template-field-card .ck.ck-toolbar .ck-dropdown__button:hover, -.template-field-card .ck.ck-toolbar .ck-splitbutton__action:hover { +.slide-form-stack .tox .tox-tbtn:hover, +.template-field-card .tox .tox-tbtn:hover { background: var(--bs-secondary-bg); } -.slide-form-stack .ck.ck-toolbar .ck-button.ck-on, -.slide-form-stack .ck.ck-toolbar .ck-dropdown__button.ck-on, -.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action.ck-on, -.template-field-card .ck.ck-toolbar .ck-button.ck-on, -.template-field-card .ck.ck-toolbar .ck-dropdown__button.ck-on, -.template-field-card .ck.ck-toolbar .ck-splitbutton__action.ck-on { +.slide-form-stack .tox .tox-tbtn--enabled, +.template-field-card .tox .tox-tbtn--enabled { background: rgba(var(--bs-primary-rgb), 0.12); color: var(--bs-primary); } -.slide-form-stack .ck.ck-toolbar .ck-button.ck-disabled, -.slide-form-stack .ck.ck-toolbar .ck-dropdown__button.ck-disabled, -.slide-form-stack .ck.ck-toolbar .ck-splitbutton__action.ck-disabled, -.template-field-card .ck.ck-toolbar .ck-button.ck-disabled, -.template-field-card .ck.ck-toolbar .ck-dropdown__button.ck-disabled, -.template-field-card .ck.ck-toolbar .ck-splitbutton__action.ck-disabled { +.slide-form-stack .tox .tox-tbtn--disabled, +.template-field-card .tox .tox-tbtn--disabled { color: var(--bs-secondary-color); } -.slide-form-stack .ck.ck-toolbar .ck-font-size-input, -.template-field-card .ck.ck-toolbar .ck-font-size-input { - flex: 0 0 5% !important; - width: 5% !important; - max-width: 5% !important; - min-width: 0 !important; -} - -.slide-form-stack .ck.ck-dropdown__panel, -.slide-form-stack .ck.ck-list__panel, -.slide-form-stack .ck.ck-balloon-panel, -.template-field-card .ck.ck-dropdown__panel, -.template-field-card .ck.ck-list__panel, -.template-field-card .ck.ck-balloon-panel { - border-color: var(--bs-border-color); - box-shadow: var(--bs-box-shadow); -} - -.slide-form-stack .ck.ck-list__item .ck-button, -.template-field-card .ck.ck-list__item .ck-button { +.slide-form-stack .tox .tox-edit-area__iframe, +.template-field-card .tox .tox-edit-area__iframe { + background: var(--bs-body-bg); color: var(--bs-body-color); } -.slide-form-stack .ck.ck-list__item .ck-button:hover, -.template-field-card .ck.ck-list__item .ck-button:hover { +.slide-form-stack .tox .tox-edit-area, +.template-field-card .tox .tox-edit-area { + min-height: 10rem; +} + +.slide-form-stack .tox .tox-edit-area__iframe, +.template-field-card .tox .tox-edit-area__iframe { + min-height: 10rem; +} + +.slide-form-stack .tox .tox-statusbar, +.template-field-card .tox .tox-statusbar { + border-top: 1px solid var(--bs-border-color); + background: var(--bs-tertiary-bg); +} + +.slide-form-stack .tox .tox-menu, +.template-field-card .tox .tox-menu, +.slide-form-stack .tox .tox-collection, +.template-field-card .tox .tox-collection { + background: var(--bs-body-bg); + border-color: var(--bs-border-color); + color: var(--bs-body-color); +} + +.slide-form-stack .tox .tox-collection__item, +.template-field-card .tox .tox-collection__item { + color: var(--bs-body-color); +} + +.slide-form-stack .tox .tox-collection__item:hover, +.template-field-card .tox .tox-collection__item:hover { background: var(--bs-secondary-bg); } -.slide-form-stack .ck.ck-dropdown__panel, -.slide-form-stack .ck.ck-list__panel, -.slide-form-stack .ck.ck-balloon-panel, -.slide-form-stack .ck.ck-toolbar .ck-dropdown__panel, -.template-field-card .ck.ck-dropdown__panel, -.template-field-card .ck.ck-list__panel, -.template-field-card .ck.ck-balloon-panel, -.template-field-card .ck.ck-toolbar .ck-dropdown__panel { +.slide-form-stack .tox .tox-input, +.template-field-card .tox .tox-input { background: var(--bs-body-bg); -} - -.slide-form-stack .ck.ck-input-text, -.slide-form-stack .ck.ck-list__item-text, -.slide-form-stack .ck.ck-list__item-label, -.slide-form-stack .ck.ck-dropdown__panel .ck-button__label, -.template-field-card .ck.ck-input-text, -.template-field-card .ck.ck-list__item-text, -.template-field-card .ck.ck-list__item-label, -.template-field-card .ck.ck-dropdown__panel .ck-button__label { + border-color: var(--bs-border-color); color: var(--bs-body-color); } -.slide-form-stack .ck.ck-input-text, -.template-field-card .ck.ck-input-text { - background: var(--bs-body-bg); - border-color: var(--bs-border-color); -} - -.slide-form-stack .ck.ck-input-text:focus, -.template-field-card .ck.ck-input-text:focus { +.slide-form-stack .tox .tox-input:focus, +.template-field-card .tox .tox-input:focus { border-color: var(--bs-primary); box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.18); } -.slide-form-stack .ck.ck-editor__main > .ck-editor__editable, -.slide-form-stack .ck.ck-editor__editable_inline, -.template-field-card .ck.ck-editor__main > .ck-editor__editable, -.template-field-card .ck.ck-editor__editable_inline { - background: var(--bs-body-bg); - color: var(--bs-body-color); - border: 0; +.slide-form-stack .tox .tox-edit-area__iframe, +.template-field-card .tox .tox-edit-area__iframe { + font-family: inherit; } -.slide-form-stack .ck.ck-editor__main > .ck-editor__editable.ck-focused, -.slide-form-stack .ck.ck-editor__editable_inline.ck-focused, -.template-field-card .ck.ck-editor__main > .ck-editor__editable.ck-focused, -.template-field-card .ck.ck-editor__editable_inline.ck-focused { - box-shadow: inset 0 0 0 1px var(--bs-primary), 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.18); -} - -.slide-form-stack .ck-editor__editable_inline, -.template-field-card .ck-editor__editable_inline { - min-height: 10rem; -} - -.slide-form-stack .ck-content, -.template-field-card .ck-content { +.slide-form-stack .tox-content, +.template-field-card .tox-content { font-family: inherit; line-height: 1.55; } -.slide-form-stack .ck-content h1, -.slide-form-stack .ck-content h2, -.slide-form-stack .ck-content h3, -.slide-form-stack .ck-content h4, -.slide-form-stack .ck-content h5, -.slide-form-stack .ck-content h6, -.template-field-card .ck-content h1, -.template-field-card .ck-content h2, -.template-field-card .ck-content h3, -.template-field-card .ck-content h4, -.template-field-card .ck-content h5, -.template-field-card .ck-content h6 { +.slide-form-stack .tox-content h1, +.slide-form-stack .tox-content h2, +.slide-form-stack .tox-content h3, +.slide-form-stack .tox-content h4, +.slide-form-stack .tox-content h5, +.slide-form-stack .tox-content h6, +.template-field-card .tox-content h1, +.template-field-card .tox-content h2, +.template-field-card .tox-content h3, +.template-field-card .tox-content h4, +.template-field-card .tox-content h5, +.template-field-card .tox-content h6 { color: var(--bs-body-color); } -.slide-form-stack .ck-content p, -.template-field-card .ck-content p { +.slide-form-stack .tox-content p, +.template-field-card .tox-content p { margin-bottom: 0.75rem; } -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel, -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__panel, -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list, -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-balloon-panel { +html[data-bs-theme='dark'] .slide-form-stack .tox, +html[data-bs-theme='dark'] .template-field-card .tox, +html[data-bs-theme='dark'] .slide-form-stack .tox .tox-menu, +html[data-bs-theme='dark'] .template-field-card .tox .tox-menu, +html[data-bs-theme='dark'] .slide-form-stack .tox .tox-collection, +html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { background: var(--bs-body-bg) !important; background-color: var(--bs-body-bg) !important; border-color: var(--bs-border-color) !important; color: var(--bs-body-color) !important; } -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__item .ck-button, -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button, -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__item .ck-button__label, -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button__label, -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-button__label, -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-list__item-text, -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-list__item-label, -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid, -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile { - color: var(--bs-body-color) !important; -} - -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button { - background: transparent !important; -} - -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-list-item-button { - background: transparent !important; - background-color: transparent !important; -} - -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-list-item-button:hover { - background: var(--bs-secondary-bg) !important; - background-color: var(--bs-secondary-bg) !important; -} - -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button:hover { - background: var(--bs-secondary-bg) !important; -} - -html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile { - border-color: var(--bs-border-color) !important; -} - .slide-preview-dimensions-chip { display: inline-flex; @@ -1137,7 +1252,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile { .template-designer-layout { display: grid; - grid-template-columns: minmax(0, 1fr) 20rem; + grid-template-columns: minmax(0, 1fr) 25rem; gap: 1rem; align-items: start; } @@ -1435,7 +1550,8 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile { } .template-field-card { - overflow: hidden; + position: relative; + overflow: visible; } .template-field-card .card-header, @@ -1444,11 +1560,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile { border-bottom: 1px solid var(--bs-border-color-translucent); } -.template-field-card .card-body, -.region-item .card-body { - background: var(--bs-body-bg); -} - .template-field-card label, .region-item label { margin-bottom: 0; @@ -1465,11 +1576,105 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile { width: 100%; } -.template-field-card .ckeditor-holder { +.template-field-card .editor-holder { min-width: 0; } -.template-field-card.is-expanded .ck-editor__editable_inline { +.announcement-color-preview { + display: flex; + align-items: center; + justify-content: center; + min-width: 5.5rem; + height: calc(2.375rem + 2px); + box-sizing: border-box; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); +} + +.announcement-icon-preview { + display: flex; + align-items: center; + justify-content: center; + min-width: 5.5rem; + height: calc(2.375rem + 2px); + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + background: var(--bs-tertiary-bg); + font-size: 1.35rem; +} + +.announcement-list-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + border-radius: 999px; + flex: 0 0 auto; + font-size: 1.1rem; +} + +.api-region-placeholder-section { + display: grid; + gap: 0.5rem; + padding-top: 0.25rem; +} + +.api-region-placeholder-title { + color: var(--bs-primary); + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.api-region-sample-accordion { + padding: 0.85rem 1rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + background: var(--bs-secondary-bg); +} + +.api-region-sample-accordion > summary { + cursor: pointer; + list-style: none; + color: var(--bs-primary); + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.api-region-sample-accordion > summary::-webkit-details-marker { + display: none; +} + +.api-region-sample-body { + display: grid; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.api-region-sample-meta { + color: var(--bs-secondary-color); + font-size: 0.78rem; +} + +.api-region-sample-preview { + max-height: 12rem; + overflow: auto; + margin: 0; + padding: 0.75rem; + border-radius: 0.65rem; + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color-translucent); + white-space: pre-wrap; + word-break: break-word; + font-size: 0.78rem; + line-height: 1.45; +} + +.template-field-card.is-expanded .tox-edit-area__iframe { min-height: 32rem; } diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js index 61d80c0..ff8f984 100644 --- a/src/web/public/js/admin/admin-page.js +++ b/src/web/public/js/admin/admin-page.js @@ -80,6 +80,37 @@ } function initAsyncCommandForms() { + function getAnnouncementActionState(actionPath) { + var isPlay = /\/play$/.test(actionPath); + return { + nextActionPath: actionPath.replace(/\/(?:play|stop)$/, isPlay ? '/stop' : '/play'), + nextLabel: isPlay ? 'Stop' : 'Play', + nextIcon: isPlay ? 'bi-stop-fill' : 'bi-play-fill', + nextClassName: isPlay ? 'btn-outline-warning' : 'btn-outline-success', + nextConfirmMessage: isPlay + ? 'Stop this announcement on the selected screens now?' + : 'Send this announcement to the selected screens now?' + }; + } + + function updateAnnouncementActionButton(form, actionPath) { + if (!form) { + return false; + } + + var state = getAnnouncementActionState(actionPath); + var visibleButton = form.querySelector('button[type="submit"]') || (form.id ? document.querySelector('button[form="' + form.id + '"]') : null); + if (!visibleButton) { + return false; + } + + form.action = state.nextActionPath; + form.setAttribute('data-confirm-message', state.nextConfirmMessage); + visibleButton.className = visibleButton.className.replace(/btn-outline-(success|warning)/g, state.nextClassName); + visibleButton.innerHTML = '' + state.nextLabel; + return true; + } + document.addEventListener('submit', function (event) { var form = event.target; if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) { @@ -97,6 +128,14 @@ return; } + var actionPath = ''; + try { + actionPath = new URL(form.action, window.location.href).pathname; + } catch (_error) { + actionPath = String(form.action || ''); + } + var shouldReloadAfterSuccess = /^\/announcements\/\d+\/(?:play|stop)$/.test(actionPath); + event.preventDefault(); form.dataset.busy = 'true'; @@ -116,6 +155,11 @@ }, body: body.toString(), credentials: 'same-origin' + }).then(function (response) { + if (shouldReloadAfterSuccess && response && response.ok) { + window.location.reload(); + return; + } }).finally(function () { delete form.dataset.busy; }); @@ -426,6 +470,52 @@ }); } + function renderJsonPrimitive(value) { + if (value === null) { + return 'null'; + } + if (value === true || value === false) { + return '' + String(value) + ''; + } + if (typeof value === 'number') { + return '' + escapeHtml(String(value)) + ''; + } + return '"' + escapeHtml(String(value)) + '"'; + } + + function renderJsonNode(key, value, isRoot) { + var isArray = Array.isArray(value); + var isObject = value && typeof value === 'object' && !isArray; + + if (!isArray && !isObject) { + var primitiveLabel = isRoot ? 'value' : String(key); + return '' + escapeHtml(primitiveLabel) + ':' + renderJsonPrimitive(value) + ''; + } + + var entries = isArray + ? value.map(function (entry, index) { + return renderJsonNode('[' + index + ']', entry, false); + }).join('') + : Object.keys(value).map(function (childKey) { + return renderJsonNode(childKey, value[childKey], false); + }).join(''); + + var summaryLabel = isRoot ? (isArray ? 'Array' : 'Object') : String(key); + var summaryMeta = isArray ? '[' + value.length + ']' : '{' + Object.keys(value).length + '}'; + return '' + + '' + + ''; + } + + function renderJsonTree(value) { + if (Array.isArray(value) || (value && typeof value === 'object')) { + return renderJsonNode('', value, true); + } + return renderJsonNode('value', value, true); + } + function initJsonTogglePanels(root) { var scope = root && root.querySelectorAll ? root : document; var panels = scope.querySelectorAll('[data-json-toggle-panel]'); @@ -436,8 +526,8 @@ } var card = panel.closest ? panel.closest('.card') : null; - var button = card ? card.querySelector('[data-json-toggle]') : null; - var label = button ? button.querySelector('[data-json-toggle-label]') : null; + var collapseButton = card ? card.querySelector('[data-json-toggle-collapse-all]') : null; + var expandButton = card ? card.querySelector('[data-json-toggle-expand-all]') : null; var sourceNode = panel.querySelector('[data-json-toggle-source]'); var rawJson = ''; try { @@ -463,32 +553,47 @@ return; } - var compactJson = JSON.stringify(parsedJson); var formattedJson = JSON.stringify(parsedJson, null, 2); - var isFormatted = true; + var formattedTree = renderJsonTree(parsedJson); - function syncButtonLabel() { - if (!button || !label) { - return; + function setAllSectionsExpanded(expanded) { + var details = output.querySelectorAll('details'); + Array.prototype.forEach.call(details, function (detail) { + detail.open = expanded; + }); + + if (collapseButton) { + collapseButton.disabled = !details.length || !expanded; + } + if (expandButton) { + expandButton.disabled = !details.length || expanded; } - label.textContent = isFormatted - ? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON') - : String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON'); - button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false'); } - function syncOutput() { - output.textContent = isFormatted ? formattedJson : compactJson; - syncButtonLabel(); + function syncButtons() { + if (collapseButton) { + collapseButton.setAttribute('aria-pressed', 'false'); + } + if (expandButton) { + expandButton.setAttribute('aria-pressed', 'true'); + } } - output.textContent = formattedJson; - syncButtonLabel(); + output.innerHTML = formattedTree; + syncButtons(); + setAllSectionsExpanded(true); - if (button) { - button.addEventListener('click', function () { - isFormatted = !isFormatted; - syncOutput(); + if (collapseButton) { + collapseButton.addEventListener('click', function () { + setAllSectionsExpanded(false); + syncButtons(); + }); + } + + if (expandButton) { + expandButton.addEventListener('click', function () { + setAllSectionsExpanded(true); + syncButtons(); }); } }); diff --git a/src/web/public/js/announcements/announcement-form.js b/src/web/public/js/announcements/announcement-form.js new file mode 100644 index 0000000..4cf6444 --- /dev/null +++ b/src/web/public/js/announcements/announcement-form.js @@ -0,0 +1,237 @@ +function updateAnnouncementDurationFields() { + var modeSelect = document.getElementById('announcement-duration-mode'); + var durationFields = document.querySelector('[data-announcement-duration-value-fields]'); + if (!modeSelect || !durationFields) { + return; + } + + var durationValueInput = document.getElementById('announcement-duration-value'); + var durationUnitSelect = document.getElementById('announcement-duration-unit'); + var isDurationMode = String(modeSelect.value || '').trim() === 'duration'; + + durationFields.hidden = !isDurationMode; + if (durationValueInput) { + durationValueInput.disabled = !isDurationMode; + } + if (durationUnitSelect) { + durationUnitSelect.disabled = !isDurationMode; + } +} + +function updateAnnouncementColorPreview() { + var colorSelect = document.getElementById('announcement-color'); + var preview = document.querySelector('[data-announcement-color-preview]'); + if (!colorSelect || !preview) { + return; + } + + var selectedOption = colorSelect.options[colorSelect.selectedIndex] || null; + var colorKey = String(colorSelect.value || '').trim().toLowerCase() || 'primary'; + var label = selectedOption ? String(selectedOption.textContent || selectedOption.label || colorKey).trim() : colorKey; + + preview.className = 'announcement-color-preview text-bg-' + colorKey; + preview.setAttribute('aria-label', label); + preview.setAttribute('title', label); + preview.textContent = ''; +} + +function updateAnnouncementIconPreview() { + var iconSelect = document.getElementById('announcement-icon'); + var preview = document.querySelector('[data-announcement-icon-preview]'); + if (!iconSelect || !preview) { + return; + } + + var selectedOption = iconSelect.options[iconSelect.selectedIndex] || null; + var iconKey = selectedOption ? String(selectedOption.value || '').trim().toLowerCase() : ''; + var label = selectedOption ? String(selectedOption.textContent || selectedOption.label || iconKey).trim() : iconKey; + + preview.className = 'announcement-icon-preview'; + preview.innerHTML = ''; + preview.setAttribute('aria-label', label); + preview.setAttribute('title', label); +} + +function positionAnnouncementIconPicker() { + var picker = document.querySelector('[data-announcement-icon-picker]'); + var toggle = document.querySelector('[data-announcement-icon-picker-toggle]'); + var shell = document.querySelector('[data-announcement-icon-picker-shell]'); + if (!picker || picker.hidden || !toggle || !shell) { + return; + } + + var padding = 8; + var toggleRect = toggle.getBoundingClientRect(); + var menuRect = picker.getBoundingClientRect(); + var viewportHeight = window.innerHeight || document.documentElement.clientHeight || toggleRect.bottom; + var placementAbove = false; + var spaceBelow = viewportHeight - toggleRect.bottom - padding; + var spaceAbove = toggleRect.top - padding; + + if (menuRect.height > spaceBelow && spaceAbove > spaceBelow) { + placementAbove = true; + } + + picker.classList.toggle('is-open-above', placementAbove); +} + +function closeAnnouncementIconPicker() { + var picker = document.querySelector('[data-announcement-icon-picker]'); + var toggle = document.querySelector('[data-announcement-icon-picker-toggle]'); + if (!picker) { + return; + } + + picker.hidden = true; + picker.classList.remove('is-open-above'); + if (toggle) { + toggle.setAttribute('aria-expanded', 'false'); + } +} + +function openAnnouncementIconPicker() { + var picker = document.querySelector('[data-announcement-icon-picker]'); + var toggle = document.querySelector('[data-announcement-icon-picker-toggle]'); + if (!picker) { + return; + } + + picker.hidden = false; + if (toggle) { + toggle.setAttribute('aria-expanded', 'true'); + } + + if (typeof window !== 'undefined' && window.requestAnimationFrame) { + window.requestAnimationFrame(positionAnnouncementIconPicker); + } else { + positionAnnouncementIconPicker(); + } +} + +function updateAnnouncementIconPickerSelection() { + var iconSelect = document.getElementById('announcement-icon'); + var previewButton = document.querySelector('[data-announcement-icon-picker-toggle]'); + var picker = document.querySelector('[data-announcement-icon-picker]'); + if (!iconSelect || !previewButton || !picker) { + return; + } + + var selectedOption = iconSelect.options[iconSelect.selectedIndex] || null; + var selectedValue = selectedOption ? String(selectedOption.value || '').trim().toLowerCase() : ''; + var label = selectedOption ? String(selectedOption.textContent || selectedOption.label || selectedValue).trim() : selectedValue; + var icon = previewButton.querySelector('[data-announcement-icon-picker-icon]'); + var text = previewButton.querySelector('[data-announcement-icon-picker-label]'); + + previewButton.setAttribute('aria-label', label); + previewButton.setAttribute('title', label); + if (icon) { + icon.className = 'bi bi-' + selectedValue; + } + if (text) { + text.textContent = label; + } + + picker.querySelectorAll('[data-announcement-icon-option]').forEach(function (button) { + var isSelected = String(button.getAttribute('data-icon-key') || '').trim().toLowerCase() === selectedValue; + button.classList.toggle('is-selected', isSelected); + button.setAttribute('aria-pressed', isSelected ? 'true' : 'false'); + }); +} + +function setAnnouncementIconValue(iconKey) { + var iconSelect = document.getElementById('announcement-icon'); + if (!iconSelect) { + return; + } + + var normalized = String(iconKey || '').trim().toLowerCase(); + if (!normalized) { + return; + } + + iconSelect.value = normalized; + updateAnnouncementIconPreview(); + updateAnnouncementIconPickerSelection(); +} + +function initAnnouncementForm() { + var modeSelect = document.getElementById('announcement-duration-mode'); + var colorSelect = document.getElementById('announcement-color'); + var iconSelect = document.getElementById('announcement-icon'); + var iconPickerToggle = document.querySelector('[data-announcement-icon-picker-toggle]'); + var iconPicker = document.querySelector('[data-announcement-icon-picker]'); + var iconPickerClose = document.querySelector('[data-announcement-icon-picker-close]'); + + if (modeSelect) { + modeSelect.addEventListener('change', updateAnnouncementDurationFields); + updateAnnouncementDurationFields(); + } + + if (colorSelect) { + colorSelect.addEventListener('change', updateAnnouncementColorPreview); + updateAnnouncementColorPreview(); + } + + if (iconSelect) { + iconSelect.addEventListener('change', updateAnnouncementIconPreview); + updateAnnouncementIconPreview(); + } + + if (iconPickerToggle) { + iconPickerToggle.addEventListener('click', function (event) { + event.preventDefault(); + var isExpanded = iconPicker && !iconPicker.hidden; + if (isExpanded) { + closeAnnouncementIconPicker(); + } else { + openAnnouncementIconPicker(); + } + }); + } + + if (iconPickerClose) { + iconPickerClose.addEventListener('click', function (event) { + event.preventDefault(); + closeAnnouncementIconPicker(); + }); + } + + if (iconPicker) { + iconPicker.addEventListener('click', function (event) { + var button = event.target && event.target.closest ? event.target.closest('[data-announcement-icon-option]') : null; + if (!button) { + return; + } + + event.preventDefault(); + setAnnouncementIconValue(button.getAttribute('data-icon-key')); + closeAnnouncementIconPicker(); + }); + } + + window.addEventListener('resize', positionAnnouncementIconPicker); + + document.addEventListener('click', function (event) { + var picker = document.querySelector('[data-announcement-icon-picker]'); + var toggle = document.querySelector('[data-announcement-icon-picker-toggle]'); + if (!picker || picker.hidden) { + return; + } + + if (picker.contains(event.target) || (toggle && toggle.contains(event.target))) { + return; + } + + closeAnnouncementIconPicker(); + }); + + updateAnnouncementIconPickerSelection(); +} + +if (typeof document !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initAnnouncementForm, { once: true }); + } else { + initAnnouncementForm(); + } +} \ No newline at end of file diff --git a/src/web/public/js/data-sources/api-source-form.js b/src/web/public/js/data-sources/api-source-form.js new file mode 100644 index 0000000..22a2a1a --- /dev/null +++ b/src/web/public/js/data-sources/api-source-form.js @@ -0,0 +1,32 @@ +// API source form visibility helpers. + +(function () { + var form = document.getElementById('api-source-form'); + if (!form) { + return; + } + + var methodSelect = form.querySelector('[data-api-source-auth-method]'); + var authDetailsSection = form.querySelector('[data-api-source-auth-details-section]'); + var panels = Array.prototype.slice.call(form.querySelectorAll('[data-api-source-auth-panel]')); + + function updatePanels() { + var method = String(methodSelect && methodSelect.value || 'none').trim(); + var hasAuth = method !== 'none'; + + if (authDetailsSection) { + authDetailsSection.hidden = !hasAuth; + } + + panels.forEach(function (panel) { + var panelMethod = String(panel.getAttribute('data-api-source-auth-panel') || '').trim(); + panel.hidden = !hasAuth || panelMethod !== method; + }); + } + + if (methodSelect) { + methodSelect.addEventListener('change', updatePanels); + } + + updatePanels(); +}()); \ No newline at end of file diff --git a/src/web/public/js/lib/modal.js b/src/web/public/js/lib/modal.js index e3ffb0a..ca3eac6 100644 --- a/src/web/public/js/lib/modal.js +++ b/src/web/public/js/lib/modal.js @@ -1,3 +1,5 @@ +// Bootstrap modal convenience wrapper for the browser scripts. + (function () { function getOrCreate(modalElement) { if (!modalElement || !window.bootstrap || !window.bootstrap.Modal) { diff --git a/src/web/public/js/rbac-permissions.js b/src/web/public/js/rbac-permissions.js index 10da083..3f6319e 100644 --- a/src/web/public/js/rbac-permissions.js +++ b/src/web/public/js/rbac-permissions.js @@ -1,3 +1,5 @@ +// RBAC permission matrix helpers for checkbox groups. + (function () { function getGroupCheckboxes(group) { return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]')); diff --git a/src/web/public/js/regions/region-types.js b/src/web/public/js/regions/region-types.js new file mode 100644 index 0000000..c8fdf7d --- /dev/null +++ b/src/web/public/js/regions/region-types.js @@ -0,0 +1,116 @@ +// Shared registry for region-type helpers used by the slide and template editors. + +(function () { + var root = window; + var registry = root.pulseRegionTypes && typeof root.pulseRegionTypes === 'object' ? root.pulseRegionTypes : {}; + + function normalizeType(type) { + return String(type || '').trim().toLowerCase(); + } + + function parseLockRatio(value) { + var raw = String(value || '').trim(); + if (!/^[0-9]+\s*:[0-9]+$/.test(raw)) { + return null; + } + + var parts = raw.replace(/\s+/g, '').split(':'); + var width = Number(parts[0]); + var height = Number(parts[1]); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + + return { label: parts[0] + ':' + parts[1], ratio: width / height }; + } + + function getFallbackDefaultSize(regionType, lockRatio) { + var ratio = parseLockRatio(lockRatio); + + if (ratio) { + if (ratio.ratio >= 1) { + var baseWidth = 300; + return { + width: Math.max(12, Math.round(baseWidth)), + height: Math.max(12, Math.round(baseWidth / ratio.ratio)) + }; + } + + var baseHeight = 240; + return { + width: Math.max(12, Math.round(baseHeight * ratio.ratio)), + height: Math.max(12, Math.round(baseHeight)) + }; + } + + return { + width: 300, + height: 120 + }; + } + + function register(type, definition) { + registry[normalizeType(type)] = definition || {}; + return registry[normalizeType(type)]; + } + + function get(type) { + return registry[normalizeType(type)] || null; + } + + function list() { + return Object.keys(registry).map(function (type) { + return { + type: type, + definition: registry[type] || {} + }; + }); + } + + function getDefaultRegionSize(regionType, lockRatio) { + var definition = get(regionType); + if (definition && typeof definition.getDefaultRegionSize === 'function') { + return definition.getDefaultRegionSize(lockRatio); + } + + return getFallbackDefaultSize(normalizeType(regionType), lockRatio); + } + + function getDefaultRegionStyle(regionType) { + var definition = get(regionType); + if (definition && typeof definition.getDefaultStyle === 'function') { + return definition.getDefaultStyle(); + } + + return null; + } + + function getRegionChipLabel(regionType) { + var definition = get(regionType); + if (definition && definition.label) { + return String(definition.label); + } + + var normalized = normalizeType(regionType); + if (!normalized) { + return 'Region'; + } + + return normalized + .split(/[_-]+/) + .filter(function (segment) { return segment; }) + .map(function (segment) { + return segment.charAt(0).toUpperCase() + segment.slice(1); + }) + .join(' ') || 'Region'; + } + + root.pulseRegionTypes = { + register: register, + get: get, + list: list, + getDefaultRegionSize: getDefaultRegionSize, + getDefaultRegionStyle: getDefaultRegionStyle, + getRegionChipLabel: getRegionChipLabel + }; +}()); \ No newline at end of file diff --git a/src/web/public/js/regions/region-utils.js b/src/web/public/js/regions/region-utils.js new file mode 100644 index 0000000..8d96800 --- /dev/null +++ b/src/web/public/js/regions/region-utils.js @@ -0,0 +1,772 @@ +// Shared browser utilities for region helper modules. + +(function () { + function escapeHtml(value) { + if (window.webUiHelpers && typeof window.webUiHelpers.escapeHtml === 'function') { + return window.webUiHelpers.escapeHtml(value); + } + + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function sanitizePreviewHtml(html) { + var output = String(html || ''); + output = output.replace(/' + escapeHtml(summaryLabel) + ':' + escapeHtml(summaryMeta) + '
' + + '' + entries + '' + + '