Compare commits

...
33 Commits
Author SHA1 Message Date
lzstealth 59730837ad Release 2.5.13 2026-08-05 22:30:03 +01:00
lzstealth 4c459e2745 Release v2.5.12 2026-08-05 22:24:08 +01:00
lzstealth b9dd4178c4 Apply remaining changes 2026-08-05 21:57:16 +01:00
lzstealth a6a5d71ef0 Release v2.5.11 2026-08-05 21:56:46 +01:00
lzstealth c55c3d2baf Release v2.5.10 2026-08-05 21:25:02 +01:00
lzstealth d3e059a878 Release v2.5.9 2026-08-05 21:05:43 +01:00
lzstealth 38d82d2ac6 Fix QR background gradient handling 2026-08-05 19:51:54 +01:00
lzstealth 9f831f04bc Release v2.5.6 2026-08-05 19:38:16 +01:00
lzstealth a8ef35b287 Fix dashboard test bootstrap 2026-08-03 21:21:54 +01:00
lzstealth a45552fed3 Release v2.5.4 2026-08-03 21:20:54 +01:00
lzstealth a5e8ecb21f Release v2.5.3 2026-08-03 20:04:43 +01:00
lzstealth 9d5029eff6 Release 2.5.2 2026-08-03 19:18:30 +01:00
lzstealth 37345f3949 Document template region schema 2026-08-03 17:30:19 +01:00
lzstealth ed8d51580e Add template animation editor 2026-08-03 17:27:42 +01:00
lzstealth e95928f31c Release v2.4.2 2026-08-03 12:45:27 +01:00
lzstealth 2b9cabdab2 Implement schedule WYSIWYG and UTC dates 2026-08-02 14:04:41 +01:00
lzstealth a9d1d45d78 Release v2.2.2 2026-08-01 22:32:28 +01:00
lzstealth d06da6fea2 Release v2.2.1 2026-08-01 21:53:52 +01:00
lzstealth 90db0f933d Fix ignored media source module 2026-08-01 21:52:50 +01:00
lzstealth d6417b667c Release v2.2.0 2026-08-01 21:41:44 +01:00
lzstealth c643d2fb07 Prepare v2.0.0 release 2026-07-28 22:20:40 +01:00
lzstealth de1469c29d Release 1.5.16 2026-07-26 22:03:36 +01:00
lzstealth 42279d95aa Release 1.5.15 2026-07-26 21:06:03 +01:00
lzstealth 9c5cabbd13 Release 1.5.14 2026-07-26 20:33:41 +01:00
lzstealth 815c808d73 Release 1.5.13 2026-07-26 20:26:01 +01:00
lzstealth 4afaeaafb1 Release v1.5.12 2026-07-26 16:22:28 +01:00
lzstealth ef9cdd2986 Merge remote-tracking branch 'Gitea/main' 2026-07-26 15:02:18 +01:00
lzstealth 2176fb9042 Release v1.5.11 2026-07-26 15:01:03 +01:00
lzstealth 6cb45d7839 Merge pull request 'Implement video region support' (#4) from feature/issue-2-video-region into main
Reviewed-on: LZStealth/pulse-signage#4
2026-07-26 14:59:14 +01:00
lzstealth eb1f33d82f Implement video region support 2026-07-26 14:54:50 +01:00
lzstealth 07ec17ad91 Release v1.5.10 2026-07-26 12:32:01 +01:00
lzstealth b5fbf79af7 Gitea Templates Update 2026-07-26 01:45:16 +01:00
lzstealth 4ca4da473f Gitea Templates 2026-07-26 01:35:57 +01:00
798 changed files with 171985 additions and 15856 deletions
+6 -9
View File
@@ -1,20 +1,17 @@
NODE_ENV=production PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
WEB_PORT=3000 PULSE_SIGNAGE_SHARED_SECRET=
PLAYER_PORT=3001
DB_HOST=mysql DB_HOST=mysql
DB_PORT=3306 DB_PORT=3306
DB_NAME=signage DB_NAME=pulse-signage
DB_USER=signage_user DB_USER=pulse-signage
DB_PASSWORD=signage_password DB_PASSWORD=signage_password
MYSQL_ROOT_PASSWORD=root_password MYSQL_ROOT_PASSWORD=root_password
PLAYER_INTERNAL_BASE_URL=http://player:3001 PLAYER_PUBLIC_BASE_URL=http://localhost:8081
PLAYER_PUBLIC_BASE_URL=http://localhost:3001 PLAYER_INTERNAL_BASE_URL=http://player:8081
PULSE_SIGNAGE_SHARED_SECRET=
SESSION_MAX_AGE_DAYS=14 SESSION_MAX_AGE_DAYS=14
DASHBOARD_REFRESH_INTERVAL_MS=2000
DEFAULT_ADMIN_USERNAME=admin DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_NAME=Admin DEFAULT_ADMIN_NAME=Admin
DEFAULT_ADMIN_PASSWORD=admin DEFAULT_ADMIN_PASSWORD=admin
+48
View File
@@ -0,0 +1,48 @@
name: Bug report
about: Report a problem with Pulse Signage
labels:
- bug
body:
- type: markdown
attributes:
value: |
Please provide enough detail to reproduce the problem.
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: List the actions that trigger the bug.
placeholder: |
1. Open the dashboard
2. Change the playlist on a screen
3. Save and wait for playback to refresh
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What you expected to happen.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What actually happened.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Include version, browser, player setup, and any relevant logs.
placeholder: |
Version:
Browser:
Player version:
Logs:
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://git.lzstealth.com/LZStealth/pulse-signage
about: Check the project repository and docs before opening an issue.
@@ -0,0 +1,37 @@
name: Feature request
about: Suggest an improvement or new capability
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Share the problem you're trying to solve and the outcome you'd like.
- type: textarea
id: problem
attributes:
label: Problem statement
description: What limitation or workflow gap are you trying to address?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe the change you would like to see.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any workarounds or other approaches you've considered.
- type: textarea
id: context
attributes:
label: Additional context
description: Mockups, screenshots, examples, or related links.
+14
View File
@@ -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.
+4
View File
@@ -1,9 +1,13 @@
node_modules/ node_modules/
media/ media/
!src/web/lib/media/
!src/web/lib/media/**
docker-compose.dev.yml docker-compose.dev.yml
/dev-demo-seed.js
.vscode/ .vscode/
.env .env
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.DS_Store .DS_Store
TODO.md
+417 -9
View File
@@ -2,9 +2,424 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## Unreleased ## 2.5.13 - 2026-08-05
- No unreleased changes recorded yet. ### Fixed
- Slide and template save forms now allow larger multipart text fields, so rich region content no longer trips Multer's default field-value limit.
## 2.5.12 - 2026-08-05
### Fixed
- The auth route test now loads the alias bootstrap before the login flow, so isolated test runs do not depend on prior module initialization.
- The slide editor now blocks pasted data images in TinyMCE so image content must come through the upload flow.
## 2.5.11 - 2026-08-05
### Fixed
- Single-slide playlists now re-render the active slide immediately when a refresh arrives, instead of waiting for a transition that never happens.
- Playlist create and update flows now redirect to the canonical `/playlists/:id/edit` path after saving.
- Slide preview popups now keep rich-text spacing and line height consistent with the editor preview.
## 2.5.10 - 2026-08-05
### Fixed
- Slide editor rich-text updates now keep the hidden field and preview state in sync after inline formatting actions.
## 2.5.9 - 2026-08-05
### Fixed
- Screen playlist refreshes now wait until the next slide transition before applying a pending update, so one-slide playlists do not swap immediately during a refresh.
- Playlist rows now show the mute control for any playable media region, including RTMP slides, instead of only video regions.
## 2.5.8 - 2026-08-05
### Fixed
- MariaDB migrations now retry alternate foreign-key constraint names when screen-to-player constraint creation reports a duplicate-key error.
## 2.5.7 - 2026-08-05
### Fixed
- QR template backgrounds now use a single solid color only, and the editor no longer exposes a background gradient mode.
## 2.5.6 - 2026-08-05
### Added
- Onboarding QR rendering now uses the vendored `qr-code-styling` library directly, with a wider quiet zone and extra frame padding so the code does not clip at the corners.
### Changed
- The shared QR helper now renders `qr-code-styling` in Node without the old `qrcode` or `qrcode-generator` packages.
- The onboarding QR endpoint now generates the styled QR at request time, and the player onboarding page requests it only after confirming the player is not already onboarded.
- Playlist QR preview backfill now uses the same styled QR helper as onboarding, so playlist snapshots and generated previews stay consistent.
- The onboarding QR layout now keeps the code inset within its frame so the corners have more breathing room.
## 2.5.5 - 2026-08-04
### Added
- A shared Dupe flow was added to most admin create pages and list views, including roles, users, playlists, slides, templates, canvas sizes, announcements, and supported data sources.
### Changed
- RBAC roles now include a Dupe action that opens a copy-on-create flow with the selected permissions already populated.
- Users now include a Dupe action that opens the add-user form with the copied name and roles, while leaving the username blank for a new login.
- User usernames are now editable from the admin users section, while the personal account password page remains unchanged.
- The users form now allows creating an account without assigning any roles.
- Save and New on the users form now returns to the blank create page instead of falling back to the list.
- Password updates now accept a slightly lighter policy of 10 characters with 3 of 4 character classes, and the account and user password forms now describe the updated requirement.
- The slide rich-text editor now groups underline, strikethrough, subscript, and superscript into a single split-button control, with the main button using the underline icon and the dropdown exposing the other text-format actions.
- Chip formatting in the slide rich-text editor now uses a reusable inline-format helper so the chip action continues to sync and mark the editor as edited when applied.
- RSS, API, and timetable placeholder help text now uses the same transform examples, spacing, and typography as the timetable version, with the hint shown after the visible placeholders and before any overflow disclosure.
### Fixed
- Cells and inputs now enforce route-specific character limits on the form itself and on the matching save paths, so overlong values no longer fail at submit time.
- Playlist rows now use a grip drag handle with separate up/down move controls, and the drag grip cursor now changes to grab and grabbing on hover and drag.
- Playlist duration inputs now respect a min-only limit correctly, so the shared limiter no longer coerces values down to 0 when no max attribute is present.
- Playlist row video and mute toggles now use the correct filled/outline Bootstrap button styles for their current state without relying on the `active` class.
- Slides table search now only matches slide titles and template names, and no longer searches within slide content.
- Banner marquee text now measures its real cycle width so top-banner and lower-third announcements loop continuously instead of resetting from a fixed start position.
- Slide editor template selection now stays editable on blank slides until the user makes a real content edit, so TinyMCE hydration no longer locks the canvas immediately.
- Slide editor region cards now share a common header shell, so the image and video chips stay aligned with the other region types on the Video Webpage and HTML RTMP pages.
- Firefox preview popup now centers the preview canvas in normal layout flow and scales from the center, so the popup preview lines up correctly in Chrome and Firefox.
- Canvas size edits now keep the width and height fields locked while the size is in use, and oversized canvas dimensions are rejected at 16384 instead of reaching the save path.
- Schedule rules now start collapsed by default, use the built-in card collapse behavior, and keep validation messages visible without re-highlighting empty paired inputs.
- Timetable group deletes now scan slide content for the actual `timetable_group_id` field, so groups that are still referenced by slides stay protected from deletion.
- Timetable add and edit pages now keep cancel confirmation tied to timetable entry edits, so changes inside the entries table are treated as unsaved form changes.
- Screen add and edit pages now keep the Player URL box in a stable two-column layout, so switching between the forms no longer causes the page to jump or shift.
- Screen add pages now show the secondary Save and Close / Save and New actions, and screen edit delete actions now prompt for confirmation before removing the screen.
- Admin user password resets now keep the selected user in view by returning to that user's edit page after saving.
- Account password saves now refresh the page after the success toast is queued so the confirmation message stays visible.
- Shared toast handling now preserves a pending success message across the redirect so refreshes do not drop the confirmation banner.
- Announcement create and edit pages now keep the save action buttons visible, and the edit-page delete action now prompts for confirmation before deleting.
- Active announcements are now blocked from deletion with a clearer edit-page message that explains why.
- Slide template create now shows the background image remove button and clear flag, matching the edit page behavior.
- Template create now exposes Save & Close and Save & New actions, and template edit delete actions now use the shared action-button helper with a confirmation prompt instead of a separate hidden form.
- The advanced animation modal now shows intro, outro, and loop durations as 1-200% fields, with 100% saving back as 1000ms.
- The advanced animation modal now hard-limits repeat counts to 999 so attention-seeker animations cannot be configured with unbounded loops.
- Template editor region boxes now rerender when the canvas or stage resizes, and region width/height changes now keep the lock ratio, stay within the canvas, and shift back on-canvas instead of spilling over.
- Background tasks and scheduled tasks now render timestamps in 24-hour format, and the background task queue keeps a confirmation prompt before clearing finished items.
## 2.5.4 - 2026-08-03
### Changed
- The API source bearer token field now uses a password input with a right-side toggle to show or hide the token.
### Fixed
- QR code regions now render at the correct size in the slide editor preview, and the QR chip header now matches the other region cards.
- The dashboard onboarding link now uses the player public base URL stored in the database instead of a `/screen/...` URL.
## 2.5.3 - 2026-08-03
### Changed
- The user edit page delete action now shows a confirmation prompt before removing an account.
### Fixed
- Schedule modal cancel now restores the original rule set instead of keeping edits made after the modal opened.
## 2.5.2 - 2026-08-03
### Changed
- Player page auth now writes a cookie for websocket reuse, and player and announcement sockets accept that cookie so they no longer depend on query-string tokens.
- RSS and API region placeholder panels now use shared field-aware chip rendering, with updated spacing and defaults across the slide editor.
- RTMP regions now default audio to enabled in the editor, and the slide rich-text editor no longer allows anchor tags.
### Fixed
- Player websocket auth now falls back to the `pulse_page_auth` cookie when the auth query parameter is unavailable.
## 2.5.1 - 2026-08-03
### Added
- Template regions now include a dedicated advanced animation modal for editing intro, outro, and Attention Seekers settings directly.
### Changed
- Template region animation settings now store as a single JSON object with `intro`, `outro`, and `loop` keys.
- The template editor advanced animation field now edits the JSON object directly instead of separate preset selects.
- Animate.css is now loaded from vendored assets so template previews and player rendering use the local copy.
### Fixed
- Existing databases now migrate legacy animation columns into `animation_json` and then drop the old columns.
## 2.5.0 - 2026-08-03
### Added
- Template regions now support intro, exit, and Attention Seekers animation presets in the editor, with in-canvas preview controls.
### Changed
- The template editor now locks editing while preview playback is running so animation changes are easier to inspect.
### Fixed
- Existing databases now receive the template region animation columns through the versioned migration path.
## 2.4.2 - 2026-08-02
### Added
- Screen-level controls are now shown on the dashboard for quicker access to screen actions.
- Connected clients can now be moved to another screen from the dashboard, and the player redirects to the new screen after the binding updates.
- Playlist slide config now includes a mute toggle for video and RTMP items, and the player honors the stored mute state during playback.
### Changed
- Playlist canvas lock now stores `canvas_id` on `c_playlists` and links directly to `c_canvas_sizes`.
- Playlist add and edit now keep the selected canvas size tied to the playlist form flow, so canvas changes stay consistent while editing.
- RBAC permission sections now follow the same order as the admin sidebar, with section spacing kept in 10-point increments.
- Admin add/edit pages now share form view-model helpers and common form shells across data sources, signage, RBAC, and user settings, reducing duplicated template markup.
- Playlist editing now goes through a shared form page helper with dedicated schedule-rule cards and drag sorting, keeping slide rows and rule state in sync.
- Data sources now use per-section route modules for API sources, RSS feeds, and timetables, while the shared controller only keeps the root `/data-sources` redirect.
- The consolidated admin shell also refreshed shared layout, toast, login, and error rendering to match the newer helper registration flow.
- Connected client row actions now use consistent icon-and-label buttons for pause and blackout across the server-rendered page and live dashboard updates.
### Fixed
- Canvas filtering for playlist slides now follows the selected canvas size id instead of a derived signature.
- Saving API sources and RSS feeds no longer throws an internal server error after the route split, because the shared data-source refresh task service is wired through the web bootstrap again.
## 2.4.1 - 2026-08-02
### Changed
- Playlist canvas size is now stored on `c_playlists.canvas_signature` and the migration backfills existing single-canvas playlists.
### Fixed
- Playlist canvas sizing no longer relies on startup bootstrap logic.
## 2.4.0 - 2026-08-02
### Added
- Playlist slide scheduling now supports multiple OR-ed rule blocks instead of a single date-or-time schedule.
### Changed
- Playlist slide schedule data now stores rule sets in `schedule_rules_json`, and the migration backfills legacy schedule columns before dropping them.
### Fixed
- Legacy `schedule_mode`, date, time, and day columns are removed from `c_playlist_slides` after the new rule schema is installed.
## 2.3.0 - 2026-08-02
### Added
- Shared add/edit form templates now cover API sources, RSS feeds, announcements, canvas sizes, screens, and templates, reducing duplicate page markup.
- Schedule data source CRUD was added for grouped events in the admin UI.
- The schedule region was added for rendering grouped events in slides and player playback.
- Schedule data now flows through `scheduleGroups` in the slide editor and player payloads, letting the schedule region show upcoming or current entries from a selected group.
### Changed
- Admin save actions were standardized around the shared `saveActionButtons` helper, including save, save-and-close, save-and-new, cancel, and delete controls.
- Admin routes and page renderers now route through the shared form/view helpers instead of separate add/edit templates for the refactored sections.
- Toast, error, login, and shared layout rendering were refreshed to line up with the consolidated admin shell and helper registration flow.
### Fixed
- Canvas size defaults now seed only once during bootstrap when `c_canvas_sizes` is empty, instead of running from the schema layout path on every startup.
- The schema bootstrap file is now limited to table layout definitions, with one-time seed data handled by the bootstrap layer.
## 2.2.2 - 2026-08-01
### Fixed
- The theme bootstrap now resolves saved `auto` to an actual dark or light value on first paint, so explicit dark mode no longer flashes white while the page loads.
- The shared app header is now sticky so the top navigation stays visible while scrolling.
## 2.2.1 - 2026-08-01
### Fixed
- Managed-font source files are now tracked correctly so packaged releases include the `src/web/lib/media` module instead of shipping a broken build.
## 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 pages 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
- Breaking database schema changes were introduced in this release; existing databases may require migration or recreation before upgrading.
### Added
- Broader player and admin release coverage across onboarding, playlist playback, background task handling, and media-region support.
### Changed
- The database bootstrap and migration flow were reorganized, with shared DB helpers split out to support the newer schema layout.
- Admin and signage views were updated around the newer list, playlist, and RBAC behavior, including playlist scheduling and screen/client actions.
- Player runtime, onboarding, and region rendering were tightened so HTML, image, RSS, RTMP, text, video, and webpage regions follow the newer playback flow.
- Background task, upload sync, and data-source refresh handling were refined across the web app and startup paths.
- Docker, environment, README, and websocket/api documentation were refreshed to match the current release structure.
### Fixed
- Client name checks, playlist editing, and slide/media synchronization paths were adjusted to better preserve existing references during admin and player updates.
## 1.5.16 - 2026-07-26
### Added
- Background task handling was split into dedicated handler and scheduling modules, with startup data-source refreshes and unused-upload cleanup wired through the shared setup flow.
### Changed
- The admin shell, shared theme, and toast presentation were refreshed to match the newer layout and notification styling.
- Playlist scheduling now handles video-duration toggles and already-assigned slides more clearly in the picker and editor UI.
- RBAC and settings views were updated to align with the revised admin layout and shared table behavior.
### Fixed
- Template and slide thumbnail refreshes now run through the background task queue so template-wide thumbnail regeneration stays consistent.
## 1.5.15 - 2026-07-26
### Changed
- Player slide transitions now start and pause video playback around the fade window so video regions stay aligned with the slide boundary.
- Dashboard action cards now use a three-column grid on wider layouts.
### Fixed
- Slide video duration probes now preserve sub-second precision instead of rounding to whole seconds.
## 1.5.14 - 2026-07-26
### Fixed
- Dashboard client action icons now render valid Bootstrap Icon classes without duplicating the `bi-` prefix.
## 1.5.13 - 2026-07-26
### Added
- Shared table helpers for pagination, search, and sorting across the admin and signage views.
### Changed
- Refreshed the shared admin shell and moved common UI behavior into shared layout and helper modules.
- Reworked dashboard, background tasks, RBAC, and signage list pages to use the newer shared table and state flow.
- Updated the admin and player branding assets and tightened the page scripts around the new layout structure.
## 1.5.12 - 2026-07-26
### Changed
- Shared web UI helpers are now loaded once in the admin shell and reused across the dashboard, admin, and template editor.
- Template lists now show slide counts, and the template editor tracks which regions are already in use.
### Fixed
- Template editing now blocks removal of regions that are still referenced by slides.
## 1.5.11 - 2026-07-26
### Added
- Video regions are now supported end to end, including player rendering, admin editing, playlist scheduling, and player sync.
### Changed
- The slide, template, and playlist flows were updated so video content can be created, configured, and scheduled alongside existing region types.
- Player rendering and related media handling now recognize the new video region implementation.
### Fixed
- Region and upload handling were adjusted so video media syncs cleanly through the player and web admin paths.
## 1.5.10 - 2026-07-26
### Added
- Scheduled background tasks now have their own settings page, separate permission gate, and manual run action alongside the main queue view.
- The slide editor now includes a refreshed preview/sidebar layout and a cropper-based image upload flow.
### Changed
- Background task and admin list views now use richer pagination and filtering so queue state, task sources, and recurring jobs stay easier to navigate.
- The admin shell and shared styling were refreshed to support the updated settings pages, slide editor layout, and task table presentation.
### Fixed
- Upload sync and slide thumbnail refresh handling were tightened so slide changes queue follow-up work more reliably.
## 1.5.9 - 2026-07-26 ## 1.5.9 - 2026-07-26
@@ -16,13 +431,6 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- Slide thumbnail refresh now uses the player-rendered preview again and signs the preview GET request in the same way the player verifies it.
- Background tasks settings page and client script were repaired after a syntax break so the web app can boot cleanly.
## 1.5.7 - 2026-07-25
### Fixed
- Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images. - Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images.
## 1.5.6 - 2026-07-25 ## 1.5.6 - 2026-07-25
+3
View File
@@ -9,6 +9,9 @@ RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src COPY src ./src
RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css
RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css
RUN mkdir -p /app/media RUN mkdir -p /app/media
EXPOSE 3000 EXPOSE 3000
+61 -72
View File
@@ -1,92 +1,81 @@
# Pulse Signage # Pulse Signage
Pulse Signage is a digital signage system that lets you manage screens, playlists, slides, templates, and uploaded media from one admin interface. Pulse Signage is a self-hosted digital signage platform built for teams that want clear, flexible control over what appears on every screen.
It runs as two connected services: It gives you one place to manage playlists, slides, templates, screen assignments, announcements, data-driven content, and live player control without handing that workflow to a third-party service.
- the web admin app for managing content and screens ## Why It Stands Out
- the player app that shows the current signage on each screen and listens for live updates
## What You Can Do - Keep signage under your own control.
- Build polished screen experiences with reusable templates and playlists.
- Push announcements, schedules, RSS feeds, API content, and other dynamic content to screens.
- See what is live, what is queued, and what needs attention from a single admin dashboard.
- Use the same system for everyday signage, timed messages, and more structured information displays.
- Sign in to the admin dashboard ## What It Handles
- Create and organize playlists and slides
- Design reusable templates and canvas sizes
- Register screens and assign playlists to them
- Manage roles and permissions for the admin web UI
- Upload images and other media for use in slides and templates
- View live screen connections and send player commands
Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately. - Screens and playlist assignments.
- Slides, templates, and canvas sizes.
- Announcements and screen-specific targeting.
- RSS feeds, API sources, and schedule-based content.
- Roles and permissions for admin access.
- Background tasks and scheduled refresh jobs.
- Player onboarding and live playback control.
## Documentation ## Simple Deployment
Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands. Pulse Signage ships with a Docker Compose stack for the published image:
Player websocket behavior lives in [docs/websocket.md](docs/websocket.md). It covers the player control socket, snapshot stream, and the command/message shapes the player accepts. - Published-image stack: `docker compose -f docker-compose.yml up -d`
## What You Need Use this stack when you want to run the tagged image instead of building from source.
- Docker and Docker Compose ## Environment File
- A MySQL database
## First-Time Setup The compose files read from a root `.env` file. Start by copying `.env.example` to `.env`, then adjust the values for your setup.
When the app starts for the first time, it creates the database tables it needs and adds a default admin account if no users exist yet. The variables are split by who uses them:
Shared across the stack:
- `PULSE_SIGNAGE_IMAGE`
- `PULSE_SIGNAGE_SHARED_SECRET`
Web container:
- `SESSION_MAX_AGE_DAYS`
- `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_NAME`
- `DEFAULT_ADMIN_PASSWORD`
- `PASSWORD_HASH_ITERATIONS`
Player container:
- `PLAYER_PUBLIC_BASE_URL`
- `PLAYER_INTERNAL_BASE_URL`
MySQL container:
- `DB_HOST`
- `DB_PORT`
- `DB_NAME`
- `DB_USER`
- `DB_PASSWORD`
- `MYSQL_ROOT_PASSWORD`
`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD` are shared in practice because both app containers talk to the same database service.
`PLAYER_INTERNAL_BASE_URL` is used by the web side for server-to-player calls, while `PLAYER_PUBLIC_BASE_URL` is used for browser-facing player links.
## First-Time Use
The first startup seeds a default admin account if the database is empty.
- Username: `admin` - Username: `admin`
- Password: `admin` - Password: `admin`
The first admin account is placed into the built-in `Administrators` role, which has full web-admin access through the CRUD permissions. ## In Practice
You can change the initial admin credentials with these optional environment variables: Pulse Signage is designed to feel focused and reliable rather than heavy or noisy. It works well when you want a clean internal signage system for offices, venues, dashboards, or any environment where screens need to stay up to date without a lot of manual effort.
- `DEFAULT_ADMIN_USERNAME` The player and admin pieces stay linked, so changes made in the dashboard can flow out to screens quickly and consistently.
- `DEFAULT_ADMIN_NAME`
- `DEFAULT_ADMIN_PASSWORD`
## Configuration
The app reads its settings from environment variables.
### Database Connection
- `DB_HOST` - MySQL host, default `127.0.0.1`
- `DB_PORT` - MySQL port, default `3306`
- `DB_NAME` - database name, default `signage`
- `DB_USER` - database user, default `signage_user`
- `DB_PASSWORD` - database password, default `signage_password`
### Web Admin App
- `WEB_PORT` - admin app port, default `3000`
- `PLAYER_INTERNAL_BASE_URL` - player address used by the server, default `http://player:3001`
- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links, default `http://localhost:3001`
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; page tokens auto-renew while the page stays active and request signatures must be fresh; leave unset to keep the auth checks disabled for compatibility
### Player App
- `PLAYER_PORT` - player port, default `3001`
## Docker Compose
The repository includes a `docker-compose.yml` file that starts three services:
- `web` - the admin app on port `3000`
- `player` - the signage player on port `3001`
- `mysql` - the database on port `3306`
By default, `web` and `player` use the published image from `git.lzstealth.com/lzstealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/lzstealth/pulse-signage:v1.0.0`.
The Compose file also defines a shared `media` volume for stored assets and a `mysql_data` volume for database persistence.
In Docker, media storage is controlled by the `media` volume mount at `/app/media`.
Outside Docker, the web app and player do not have to use the same upload location or even the same server, as long as each service can access its own configured media path.
## Important Notes
- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`.
- When running in Docker, that value should point to the Docker service name, not `localhost`.
- If `PULSE_SIGNAGE_SHARED_SECRET` is set, the web and player containers must use the same value, and any reverse proxy in front of the player must forward `/api/media/...` without rewriting the path.
- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes.
- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock.
+6 -16
View File
@@ -1,20 +1,15 @@
services: services:
web: web:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
container_name: signage-web
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "8080:8080"
environment: environment:
NODE_ENV: ${NODE_ENV:-production}
WEB_PORT: ${WEB_PORT:-3000}
DB_HOST: ${DB_HOST:-mysql} DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306} DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage} DB_NAME: ${DB_NAME:-signage}
DB_USER: ${DB_USER:-signage_user} DB_USER: ${DB_USER:-signage_user}
DB_PASSWORD: ${DB_PASSWORD:-signage_password} 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:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14} SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000} DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000}
@@ -24,7 +19,7 @@ services:
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000} PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
volumes: volumes:
- media:/app/media - media:/app/media
command: ["npm", "run", "start:web"] command: ["node", "src/web.js"]
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@@ -33,14 +28,12 @@ services:
player: player:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
container_name: signage-player
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3001:3001" - "8081:8081"
environment: environment:
NODE_ENV: ${NODE_ENV:-production} PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
PLAYER_PORT: ${PLAYER_PORT:-3001} PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081}
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql} DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306} DB_PORT: ${DB_PORT:-3306}
@@ -49,7 +42,7 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-signage_password} DB_PASSWORD: ${DB_PASSWORD:-signage_password}
volumes: volumes:
- media:/app/media - media:/app/media
command: ["npm", "run", "start:player"] command: ["node", "src/player.js"]
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@@ -58,10 +51,7 @@ services:
mysql: mysql:
image: mysql:8.4 image: mysql:8.4
container_name: signage-mysql
restart: unless-stopped restart: unless-stopped
ports:
- "3306:3306"
environment: environment:
MYSQL_DATABASE: ${DB_NAME:-signage} MYSQL_DATABASE: ${DB_NAME:-signage}
MYSQL_USER: ${DB_USER:-signage_user} MYSQL_USER: ${DB_USER:-signage_user}
+1 -1
View File
@@ -2,7 +2,7 @@
## Overview ## Overview
Player service base URL: `http://localhost:3001` Player service base URL: `http://localhost:8081`
This document covers the player HTTP surface only. The admin dashboard exposes its own routes for screen commands and onboarding management. This document covers the player HTTP surface only. The admin dashboard exposes its own routes for screen commands and onboarding management.
+286
View File
@@ -0,0 +1,286 @@
# Database Schema
This app creates and maintains its schema at startup through `src/db/index.js`.
The sections below summarize the current tables and their purpose.
Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_at` and `modified_at` when a table supports auditing.
## Admin
- `a_users` - admin user accounts and password hashes.
- `a_roles` - named role definitions.
- `a_permissions` - permission catalog seeded from the application constants.
- `a_role_permissions` - many-to-many mapping between roles and permissions.
- `a_user_roles` - many-to-many mapping between users and roles.
- `a_sessions` - persisted admin session tokens.
### `a_users`
- `id`, `name`, `username`, `password_hash`, `password_salt`, `password_iterations`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `username` is unique.
### `a_roles`
- `id`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `name` is unique.
### `a_permissions`
- `id`, `permission_key`, `name`, `section_name`, `description`, `created_at`, `modified_at`
- `permission_key` is unique.
### `a_role_permissions`
- `role_id`, `permission_id`, `created_at`, `modified_at`
- Foreign keys:
- `role_id` -> `a_roles.id`
- `permission_id` -> `a_permissions.id`
- Composite primary key: `(role_id, permission_id)`
### `a_user_roles`
- `user_id`, `role_id`, `created_at`, `modified_at`
- Foreign keys:
- `user_id` -> `a_users.id`
- `role_id` -> `a_roles.id`
- Composite primary key: `(user_id, role_id)`
### `a_sessions`
- `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`
## Content
- `c_canvas_sizes` - reusable canvas presets for templates.
- `c_playlists` - playlist definitions, playback options, and canvas assignment.
- `c_templates` - slide templates with canvas and background settings.
- `c_template_regions` - template region layout and metadata.
- `c_slides` - slide records with template binding, JSON content, and thumbnail path.
- `c_playlist_slides` - ordered playlist items and timing.
- `c_playlist_slide_schedule_rules` - rule rows attached to playlist slides.
### `c_canvas_sizes`
- `id`, `name`, `width`, `height`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `(width, height)` is unique.
### `c_playlists`
- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `canvas_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `canvas_id` -> `c_canvas_sizes.id` with `ON DELETE SET NULL`
### `c_templates`
- `id`, `name`, `canvas_size_id`, `background_image_path`, `background_color`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `canvas_size_id` -> `c_canvas_sizes.id` with `ON DELETE SET NULL`
### `c_template_regions`
- `id`, `template_id`, `region_key`, `region_type`, `label`, `font_family`, `lock_ratio`, `animation_json`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `template_id` -> `c_templates.id` with `ON DELETE CASCADE`
### `c_slides`
- `id`, `title`, `template_id`, `content_json`, `thumbnail_path`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `template_id` -> `c_templates.id` with `ON DELETE SET NULL`
### `c_playlist_slides`
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign keys:
- `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE`
- `slide_id` -> `c_slides.id` with `ON DELETE CASCADE`
### `c_playlist_slide_schedule_rules`
- `id`, `playlist_slide_id`, `position`, `start_datetime`, `end_datetime`, `start_time`, `end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `playlist_slide_id` -> `c_playlist_slides.id` with `ON DELETE CASCADE`
- Index:
- `(playlist_slide_id, position)`
- Each playlist slide can have zero or more schedule rules.
- Rules are evaluated with OR across rows and with AND across the fields inside a single rule.
## 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.
## Announcements
- `d_announcements` - announcement content and display metadata.
- `d_announcement_screens` - announcement-to-screen assignments.
### `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`
- `id`, `name`, `slug`, `playlist_id`, `player_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `slug` is unique.
- Foreign keys:
- `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL`
- `player_id` -> `d_players.id` with `ON DELETE RESTRICT`
- `player_id` is required and defaults to `'1'` for the current singleton-player model.
### `d_onboarding_devices`
- `device_id`, `client_name`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `device_id` is the primary key.
- Foreign key:
- `screen_id` -> `d_screens.id` with `ON DELETE SET NULL`
### `d_announcements`
- `id`, `message`, `short_label`, `announcement_type`, `color_key`, `icon_key`, `duration_seconds`, `expires_at`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `announcement_type` defaults to `lower-third`.
### `d_announcement_screens`
- `announcement_id`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign keys:
- `announcement_id` -> `d_announcements.id` with `ON DELETE CASCADE`
- `screen_id` -> `d_screens.id` with `ON DELETE CASCADE`
- Composite primary key: `(announcement_id, screen_id)`
## 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.
- `i_rss_feed_items` - cached RSS feed items.
- `i_api_sources` - API source definitions and last response snapshot.
- `i_schedule_groups` - grouped schedule definitions used by the schedule region.
- `i_schedule_entries` - dated entries that belong to a schedule group.
### `i_rss_feeds`
- `id`, `name`, `feed_url`, `update_interval_value`, `update_interval_unit`, `item_limit`, `created_at`, `created_by`, `modified_at`, `modified_by`
### `i_rss_feed_items`
- `id`, `rss_feed_id`, `position`, `item_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `rss_feed_id` -> `i_rss_feeds.id` with `ON DELETE CASCADE`
- Unique key:
- `(rss_feed_id, position)`
### `i_api_sources`
- `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`
### `i_schedule_groups`
- `id`, `name`, `short_description`, `created_at`, `created_by`, `modified_at`, `modified_by`
### `i_schedule_entries`
- `id`, `schedule_group_id`, `title`, `short_description`, `start_datetime`, `end_datetime`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `schedule_group_id` -> `i_schedule_groups.id` with `ON DELETE CASCADE`
- Index:
- `(schedule_group_id, start_datetime)`
## Operations
- `o_background_tasks` - queue and history for background jobs.
### `o_background_tasks`
- `id`, `task_key`, `task_type`, `title`, `category`, `status`, `payload_json`, `metadata_json`, `attempts`, `created_at`, `created_by`, `started_at`, `finished_at`, `error_message`
- Indexed by `status`, `task_key`, and `task_type`.
## Notes
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
- `src/db/bootstrap.js` seeds the canvas size defaults, default permissions, and the default administrator role.
- The migration module stays in place for future releases, but this version treats the current schema as the install baseline.
```mermaid
erDiagram
A_USERS {
}
A_ROLES {
}
A_PERMISSIONS {
}
A_ROLE_PERMISSIONS {
}
A_USER_ROLES {
}
A_SESSIONS {
}
C_CANVAS_SIZES {
}
C_PLAYLISTS {
}
C_TEMPLATES {
}
C_TEMPLATE_REGIONS {
}
C_SLIDES {
}
C_PLAYLIST_SLIDES {
}
C_PLAYLIST_SLIDE_SCHEDULE_RULES {
}
D_PLAYERS {
}
D_SCREENS {
}
D_ONBOARDING_DEVICES {
}
D_ANNOUNCEMENTS {
}
D_ANNOUNCEMENT_SCREENS {
}
I_RSS_FEEDS {
}
I_RSS_FEED_ITEMS {
}
I_API_SOURCES {
}
I_SCHEDULE_GROUPS {
}
I_SCHEDULE_ENTRIES {
}
O_BACKGROUND_TASKS {
}
A_USERS ||--o{ A_USER_ROLES : has
A_ROLES ||--o{ A_USER_ROLES : assigned_to
A_ROLES ||--o{ A_ROLE_PERMISSIONS : has
A_PERMISSIONS ||--o{ A_ROLE_PERMISSIONS : granted_to
A_USERS ||--o{ A_SESSIONS : owns
C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by
C_CANVAS_SIZES ||--o{ C_PLAYLISTS : used_by
C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains
C_TEMPLATES ||--o{ C_SLIDES : used_by
C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules
D_PLAYERS ||--o{ D_SCREENS : assigned_to
C_PLAYLISTS ||--o{ D_SCREENS : uses
D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds
D_ANNOUNCEMENTS ||--o{ D_ANNOUNCEMENT_SCREENS : targets
D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
I_SCHEDULE_GROUPS ||--o{ I_SCHEDULE_ENTRIES : contains
```
+39 -39
View File
@@ -2,7 +2,7 @@
## Overview ## Overview
Player service websocket base URL: `ws://localhost:3001` Player service websocket base URL: `ws://localhost:8081`
This document uses OpenAPI-style sections, but stays in plain markdown. This document uses OpenAPI-style sections, but stays in plain markdown.
@@ -24,33 +24,18 @@ 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. 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 ## Player Control Channel
### Messages from player to server ### Messages from player to server
The player sends two message types: The player sends a state snapshot message.
- `hello`
- `state`
#### `hello`
Example payload:
```json
{
"type": "hello",
"clientId": "client-id",
"clientName": "friendly label",
"deviceId": "device-id",
"userAgent": "browser ua",
"page": "http://.../screen/demo",
"viewport": { "width": 1920, "height": 1080 },
"paused": false,
"blackout": false,
"currentSlide": null
}
```
#### `state` #### `state`
@@ -67,16 +52,13 @@ Example payload:
"viewport": { "width": 1920, "height": 1080 }, "viewport": { "width": 1920, "height": 1080 },
"paused": false, "paused": false,
"blackout": false, "blackout": false,
"currentSlide": { "currentSlide": null
"id": 12,
"title": "Main Slide",
"kind": "image",
"playlistSignature": "..."
}
} }
``` ```
The server recognizes `clientId`, `clientName`, `deviceId`, `userAgent`, `page`, `viewport`, `paused`, `blackout`, and `currentSlide` from player messages. When `currentSlide` is present, the player includes the active slide id, title, kind, and playlist signature. When `currentSlide` is present, the player includes the active slide id, title, kind, and playlist signature.
The server recognizes `clientId`, `clientName`, `deviceId`, `userAgent`, `page`, `viewport`, `paused`, `blackout`, and `currentSlide` from state messages.
### Messages from server to player ### Messages from server to player
@@ -92,15 +74,13 @@ The server sends command messages with:
Supported commands: Supported commands:
- `blackout`
- `next`
- `pause`
- `previous`
- `refresh` - `refresh`
- `reload` - `reload`
- `redirect` - `redirect`
- `pause`
- `blackout`
- `previous`
- `next`
- `left`
- `right`
- `setclientname` - `setclientname`
#### `refresh` #### `refresh`
@@ -130,16 +110,36 @@ Use `false` to restore and `true` to blackout.
#### `redirect` #### `redirect`
Requests the player page to navigate to a new location. The destination is supplied as `url` by the caller that forwarded the command. Requests the player page to navigate to a new location. The destination is supplied as `url` by the caller that forwarded the command.
#### `previous` / `left` #### `previous`
Moves to the previous slide. Moves to the previous slide.
#### `next` / `right` #### `next`
Moves to the next slide. Moves to the next slide.
#### `setclientname` #### `setclientname`
Updates the client name associated with the player session and onboarding record. 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. 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 ## Snapshot Channel
### Messages from server to client ### Messages from server to client
+9 -207
View File
@@ -1,14 +1,15 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.5.4", "version": "2.5.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.5.4", "version": "2.5.9",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^137.0.0", "@sparticuz/chromium": "^137.0.0",
"animate.css": "^4.1.1",
"bootstrap-icons": "1.11.3", "bootstrap-icons": "1.11.3",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
@@ -18,7 +19,6 @@
"multer": "^2.2.0", "multer": "^2.2.0",
"mysql2": "^3.14.3", "mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0", "puppeteer-core": "^24.16.0",
"qrcode": "^1.5.4",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
@@ -755,6 +755,12 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/animate.css": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz",
"integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==",
"license": "MIT"
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -1082,15 +1088,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -1129,17 +1126,6 @@
"devtools-protocol": "*" "devtools-protocol": "*"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1233,15 +1219,6 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/degenerator": { "node_modules/degenerator": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
@@ -1299,12 +1276,6 @@
"integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.4.2", "version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -1611,19 +1582,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.16.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -2075,18 +2033,6 @@
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/long": { "node_modules/long": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -2404,42 +2350,6 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pac-proxy-agent": { "node_modules/pac-proxy-agent": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
@@ -2504,15 +2414,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -2538,15 +2439,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/progress": { "node_modules/progress": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -2684,23 +2576,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.3", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -2777,12 +2652,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2866,12 +2735,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -3345,32 +3208,12 @@
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wordwrap": { "node_modules/wordwrap": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -3398,47 +3241,6 @@
} }
} }
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yauzl": { "node_modules/yauzl": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.5.9", "version": "2.5.13",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
@@ -13,10 +13,12 @@
"start:web": "node -r dotenv/config src/web.js", "start:web": "node -r dotenv/config src/web.js",
"start:player": "node -r dotenv/config src/player.js", "start:player": "node -r dotenv/config src/player.js",
"dev:web": "nodemon -r dotenv/config src/web.js", "dev:web": "nodemon -r dotenv/config src/web.js",
"dev:player": "nodemon -r dotenv/config src/player.js" "dev:player": "nodemon -r dotenv/config src/player.js",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^137.0.0", "@sparticuz/chromium": "^137.0.0",
"animate.css": "^4.1.1",
"bootstrap-icons": "1.11.3", "bootstrap-icons": "1.11.3",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
@@ -26,7 +28,6 @@
"multer": "^2.2.0", "multer": "^2.2.0",
"mysql2": "^3.14.3", "mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0", "puppeteer-core": "^24.16.0",
"qrcode": "^1.5.4",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
+18
View File
@@ -1,3 +1,5 @@
// Password hashing and session token helpers.
const crypto = require('crypto'); const crypto = require('crypto');
const PASSWORD_ITERATIONS = Number(process.env.PASSWORD_HASH_ITERATIONS || 310000); const PASSWORD_ITERATIONS = Number(process.env.PASSWORD_HASH_ITERATIONS || 310000);
@@ -5,6 +7,21 @@ const PASSWORD_KEY_LENGTH = 32;
const PASSWORD_DIGEST = 'sha256'; const PASSWORD_DIGEST = 'sha256';
const SESSION_BYTES = 32; const SESSION_BYTES = 32;
function validatePasswordStrength(password) {
const value = String(password || '');
const hasLowercase = /[a-z]/.test(value);
const hasUppercase = /[A-Z]/.test(value);
const hasNumber = /[0-9]/.test(value);
const hasSymbol = /[^A-Za-z0-9]/.test(value);
const categoryCount = [hasLowercase, hasUppercase, hasNumber, hasSymbol].filter(Boolean).length;
if (value.length < 10 || categoryCount < 3) {
return 'Password must be at least 10 characters and include 3 of: uppercase, lowercase, number, and symbol.';
}
return '';
}
function hashPassword(password, salt) { function hashPassword(password, salt) {
const safePassword = String(password || ''); const safePassword = String(password || '');
const safeSalt = salt || crypto.randomBytes(16).toString('hex'); const safeSalt = salt || crypto.randomBytes(16).toString('hex');
@@ -38,6 +55,7 @@ function hashSessionToken(token) {
module.exports = { module.exports = {
hashPassword, hashPassword,
verifyPassword, verifyPassword,
validatePasswordStrength,
createSessionToken, createSessionToken,
hashSessionToken hashSessionToken
}; };
+62 -4
View File
@@ -1,20 +1,75 @@
const db = require('./db'); // Module-resolution shims for the app's `#root` and `#src` aliases.
const data = require('./data');
const player = require('./player/render'); const Module = require('module');
const path = require('path');
const rootDir = path.resolve(__dirname, '..');
const srcDir = __dirname;
const resolveFilename = Module._resolveFilename;
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');
const { fetchPlaylistCanvasId, fetchPlaylistCanvasSignature } = require('#src/web/lib/helpers');
module.exports = { module.exports = {
createPool: db.createPool, createPool: dbCommon.createPool,
pruneStaleOnboardingDevices: dbCommon.pruneStaleOnboardingDevices,
ensureSchema: db.ensureSchema, ensureSchema: db.ensureSchema,
bootstrapDatabase: dbBootstrap.bootstrapDatabase,
slugify: data.slugify, slugify: data.slugify,
uniqueScreenSlug: data.uniqueScreenSlug, uniqueScreenSlug: data.uniqueScreenSlug,
parseJsonSafe: data.parseJsonSafe, parseJsonSafe: data.parseJsonSafe,
validateMaxLength: data.validateMaxLength,
truncateToMaxLength: data.truncateToMaxLength,
fetchAdminData: data.fetchAdminData, fetchAdminData: data.fetchAdminData,
fetchPlaylistsPage: data.fetchPlaylistsPage, fetchPlaylistsPage: data.fetchPlaylistsPage,
fetchSlidesPage: data.fetchSlidesPage, fetchSlidesPage: data.fetchSlidesPage,
fetchTemplatesPage: data.fetchTemplatesPage, fetchTemplatesPage: data.fetchTemplatesPage,
fetchCanvasSizesPage: data.fetchCanvasSizesPage, fetchCanvasSizesPage: data.fetchCanvasSizesPage,
fetchScreensPage: data.fetchScreensPage, fetchScreensPage: data.fetchScreensPage,
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, fetchPlaylistById: data.fetchPlaylistById,
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
normalizeDisplayMode: data.normalizeDisplayMode,
fetchTimetablesData: data.fetchTimetablesData,
fetchTimetableGroupsPage: data.fetchTimetableGroupsPage,
fetchTimetableGroupById: data.fetchTimetableGroupById,
fetchTimetableEntriesByGroupId: data.fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload: data.buildTimetableGroupPayload,
fetchApiSourcesData: data.fetchApiSourcesData, fetchApiSourcesData: data.fetchApiSourcesData,
fetchApiSourcesPage: data.fetchApiSourcesPage, fetchApiSourcesPage: data.fetchApiSourcesPage,
fetchApiSourceById: data.fetchApiSourceById, fetchApiSourceById: data.fetchApiSourceById,
@@ -30,12 +85,15 @@ module.exports = {
replaceRssFeedItems: data.replaceRssFeedItems, replaceRssFeedItems: data.replaceRssFeedItems,
fetchScreenById: data.fetchScreenById, fetchScreenById: data.fetchScreenById,
fetchScreenEditData: data.fetchScreenEditData, fetchScreenEditData: data.fetchScreenEditData,
fetchScreenPlayerUrls: data.fetchScreenPlayerUrls,
fetchScreenPlayerRecord: data.fetchScreenPlayerRecord,
fetchTemplateById: data.fetchTemplateById, fetchTemplateById: data.fetchTemplateById,
fetchSlideById: data.fetchSlideById, fetchSlideById: data.fetchSlideById,
fetchTemplatesData: data.fetchTemplatesData, fetchTemplatesData: data.fetchTemplatesData,
fetchCanvasSizesData: data.fetchCanvasSizesData, fetchCanvasSizesData: data.fetchCanvasSizesData,
fetchCanvasSizeById: data.fetchCanvasSizeById, fetchCanvasSizeById: data.fetchCanvasSizeById,
buildCanvasSizePayload: data.buildCanvasSizePayload, buildCanvasSizePayload: data.buildCanvasSizePayload,
MAX_CANVAS_SIZE_DIMENSION: data.MAX_CANVAS_SIZE_DIMENSION,
buildSlidePayload: data.buildSlidePayload, buildSlidePayload: data.buildSlidePayload,
extractTemplateRegions: data.extractTemplateRegions, extractTemplateRegions: data.extractTemplateRegions,
buildTemplatePayload: data.buildTemplatePayload, buildTemplatePayload: data.buildTemplatePayload,
+123 -41
View File
@@ -1,47 +1,83 @@
// Admin data aggregation helpers for dashboards, playlists, slides, templates, and screens.
const { fetchPagedRows } = require('./utils'); const { fetchPagedRows } = require('./utils');
async function fetchAdminData(pool) { async function fetchAdminData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC'); const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC'); const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
const [templates] = await pool.query(` const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st FROM c_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC 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 slide_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(` const [slides] = await pool.query(`
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, 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 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, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height,
FROM slides s (SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
LEFT JOIN slide_templates st ON st.id = s.template_id FROM c_slides s
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_templates st ON st.id = s.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY s.id DESC ORDER BY s.id DESC
`); `);
const [screens] = await pool.query(` const [screens] = await pool.query(`
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name, pl.public_base_url
FROM screens s FROM d_screens s
LEFT JOIN playlists p ON p.id = s.playlist_id LEFT JOIN c_playlists p ON p.id = s.playlist_id
LEFT JOIN d_players pl ON pl.device_id = s.player_id
ORDER BY s.id DESC ORDER BY s.id DESC
`); `);
const [playlistSlides] = await pool.query(` const [playlistSlides] = await pool.query(`
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.disable_audio, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps FROM c_playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC
`); `);
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides }; const [playlistScheduleRules] = await pool.query(`
SELECT id, playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_at, modified_at, created_by, modified_by
FROM c_playlist_slide_schedule_rules
ORDER BY playlist_slide_id ASC, position ASC, id ASC
`);
const rulesBySlideId = new Map();
playlistScheduleRules.forEach(function (rule) {
const slideId = Number(rule.playlist_slide_id);
if (!rulesBySlideId.has(slideId)) {
rulesBySlideId.set(slideId, []);
}
rulesBySlideId.get(slideId).push(rule);
});
const playlistSlidesWithRules = playlistSlides.map(function (slide) {
return Object.assign({}, slide, {
scheduleRules: rulesBySlideId.get(Number(slide.id)) || []
});
});
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides: playlistSlidesWithRules };
} }
async function fetchPlaylistsPage(pool, page, pageSize) { async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { 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, 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 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,
FROM playlists p (SELECT COUNT(*) FROM d_screens s WHERE s.playlist_id = p.id) AS screen_count
FROM c_playlists p
ORDER BY p.id DESC`, ORDER BY p.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM playlists', countSql: 'SELECT COUNT(*) AS count FROM c_playlists',
searchColumns: ['p.name'],
searchTerm: searchTerm,
sortColumns: {
name: 'p.name',
slides: 'slide_count',
created: 'p.created_at',
modified: 'p.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -49,14 +85,25 @@ async function fetchPlaylistsPage(pool, page, pageSize) {
return Object.assign({ playlists: paged.rows }, paged); return Object.assign({ playlists: paged.rows }, paged);
} }
async function fetchSlidesPage(pool, page, pageSize) { async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, 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 selectSql: `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,
FROM slides s (SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
LEFT JOIN slide_templates st ON st.id = s.template_id FROM c_slides s
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_templates st ON st.id = s.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY s.id DESC`, ORDER BY s.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM slides', countSql: 'SELECT COUNT(*) AS count FROM c_slides',
searchColumns: ['s.title', 'st.name'],
searchTerm: searchTerm,
sortColumns: {
title: 's.title',
template: 'st.name',
created: 's.created_at',
modified: 's.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -64,15 +111,28 @@ async function fetchSlidesPage(pool, page, pageSize) {
return Object.assign({ slides: paged.rows }, paged); return Object.assign({ slides: paged.rows }, paged);
} }
async function fetchTemplatesPage(pool, page, pageSize) { async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count (SELECT COUNT(*) FROM c_template_regions str WHERE str.template_id = st.id) AS region_count,
FROM slide_templates st (SELECT COUNT(*) FROM c_slides s WHERE s.template_id = st.id) AS slide_count
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC`, ORDER BY st.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM slide_templates', countSql: 'SELECT COUNT(*) AS count FROM c_templates',
searchColumns: ['st.name', 'st.background_image_path', 'st.background_color', 'cs.name'],
searchTerm: searchTerm,
sortColumns: {
name: 'st.name',
canvas: ['cs.width', 'cs.height', 'cs.name'],
regions: 'region_count',
slides: 'slide_count',
created: 'st.created_at',
modified: 'st.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -80,13 +140,24 @@ async function fetchTemplatesPage(pool, page, pageSize) {
return Object.assign({ templates: paged.rows }, paged); return Object.assign({ templates: paged.rows }, paged);
} }
async function fetchCanvasSizesPage(pool, page, pageSize) { async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by, selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
(SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count (SELECT COUNT(*) FROM c_templates st WHERE st.canvas_size_id = c_canvas_sizes.id) AS template_count
FROM canvas_sizes FROM c_canvas_sizes
ORDER BY width ASC, height ASC, name ASC`, ORDER BY width ASC, height ASC, name ASC`,
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes', countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
dimensions: ['width', 'height', 'name'],
templates: 'template_count',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -94,13 +165,24 @@ async function fetchCanvasSizesPage(pool, page, pageSize) {
return Object.assign({ canvasSizes: paged.rows }, paged); return Object.assign({ canvasSizes: paged.rows }, paged);
} }
async function fetchScreensPage(pool, page, pageSize) { async function fetchScreensPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
FROM screens s FROM d_screens s
LEFT JOIN playlists p ON p.id = s.playlist_id LEFT JOIN c_playlists p ON p.id = s.playlist_id
ORDER BY s.id DESC`, ORDER BY s.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM screens', countSql: 'SELECT COUNT(*) AS count FROM d_screens',
searchColumns: ['s.name', 's.slug', 'p.name'],
searchTerm: searchTerm,
sortColumns: {
name: 's.name',
url: 's.slug',
playlist: 'p.name',
created: 's.created_at',
modified: 's.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
+74
View File
@@ -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
};
+192
View File
@@ -0,0 +1,192 @@
// Announcement data access helpers.
const { fetchPagedRows } = require('./utils');
const {
ANNOUNCEMENT_ICON_KEYS,
DEFAULT_ANNOUNCEMENT_ICON,
normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig
} = require('./announcement-icons');
const { validateMaxLength } = require('./utils');
const SHORT_LABEL_MAX_LENGTH = 255;
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 = validateMaxLength(normalizeAnnouncementShortLabel(input && input.short_label), SHORT_LABEL_MAX_LENGTH, 'Announcement short description');
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
};
+117 -17
View File
@@ -1,24 +1,79 @@
// API source data access, pagination, and remote fetch helpers.
const http = require('http'); const http = require('http');
const https = require('https'); const https = require('https');
const { fetchPagedRows } = require('./utils'); const { fetchPagedRows, validateMaxLength } = require('./utils');
const NAME_MAX_LENGTH = 255;
const URL_MAX_LENGTH = 1024;
const AUTH_MAX_LENGTH = 255;
const ITEMS_PATH_MAX_LENGTH = 255;
function normalizeUpdateIntervalUnit(value) { function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase(); const unit = String(value || '').trim().toLowerCase();
return unit === 'seconds' ? 'seconds' : 'minutes'; 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) { async function fetchApiSourcesData(pool) {
const [apiSources] = await pool.query( 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 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 }; return { apiSources: apiSources };
} }
async function fetchApiSourcesPage(pool, page, pageSize) { async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { 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 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 api_sources', countSql: 'SELECT COUNT(*) AS count FROM i_api_sources',
searchColumns: ['name', 'api_url', 'last_pull_error'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
url: 'api_url',
interval: ['update_interval_value', 'update_interval_unit'],
last_pulled: 'last_pulled_at',
last_response: 'last_response_status',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -28,20 +83,21 @@ async function fetchApiSourcesPage(pool, page, pageSize) {
async function fetchApiSourceById(pool, id) { async function fetchApiSourceById(pool, id) {
const [rows] = await pool.query( 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 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] [id]
); );
return rows[0] || null; return rows[0] || null;
} }
async function loadUrlText(urlValue) { async function loadUrlText(urlValue, requestOptions) {
const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {};
if (typeof fetch === 'function') { if (typeof fetch === 'function') {
const response = await fetch(urlValue, { const response = await fetch(urlValue, {
headers: { headers: Object.assign({
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage API Reader' 'User-Agent': 'Pulse Signage API Reader'
} }, extraHeaders)
}); });
return { return {
@@ -55,12 +111,13 @@ async function loadUrlText(urlValue) {
return await new Promise(function (resolve, reject) { return await new Promise(function (resolve, reject) {
const url = new URL(urlValue); const url = new URL(urlValue);
const transport = url.protocol === 'https:' ? https : http; const transport = url.protocol === 'https:' ? https : http;
const request = transport.get(url, { const requestHeaders = Object.assign({
headers: {
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage API Reader' 'User-Agent': 'Pulse Signage API Reader'
} }, extraHeaders);
}, function (response) { const request = transport.get(url, Object.assign({}, requestOptions || {}, {
headers: requestHeaders
}), function (response) {
response.setEncoding('utf8'); response.setEncoding('utf8');
let body = ''; let body = '';
response.on('data', function (chunk) { response.on('data', function (chunk) {
@@ -81,8 +138,11 @@ async function loadUrlText(urlValue) {
}); });
} }
async function fetchApiSourceResponse(apiUrl) { async function fetchApiSourceResponse(apiSource) {
const response = await loadUrlText(apiUrl); const source = apiSource && typeof apiSource === 'object' ? apiSource : { api_url: apiSource };
const response = await loadUrlText(source.api_url, {
headers: buildAuthHeaders(source)
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Unable to load API response (${response.statusCode}).`); throw new Error(`Unable to load API response (${response.statusCode}).`);
} }
@@ -108,8 +168,23 @@ async function fetchApiSourceResponse(apiUrl) {
function buildApiSourcePayload(req, existingApiSource) { function buildApiSourcePayload(req, existingApiSource) {
const fallback = existingApiSource || {}; const fallback = existingApiSource || {};
const name = String(req.body.name || fallback.name || '').trim(); const body = req && req.body ? req.body : {};
const apiUrl = String(req.body.api_url || req.body.apiUrl || fallback.api_url || '').trim(); function readBodyValue(fieldName, fallbackValue) {
if (Object.prototype.hasOwnProperty.call(body, fieldName)) {
return body[fieldName];
}
return fallbackValue;
}
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'API source name');
const apiUrl = validateMaxLength(req.body.api_url || req.body.apiUrl || fallback.api_url || '', URL_MAX_LENGTH, 'API source URL');
const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none')));
const authUsername = validateMaxLength(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '', AUTH_MAX_LENGTH, 'API source username');
const authPassword = validateMaxLength(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '', AUTH_MAX_LENGTH, 'API source password');
const authBearerToken = validateMaxLength(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '', AUTH_MAX_LENGTH, 'API source bearer token');
const authHeaderName = validateMaxLength(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key', AUTH_MAX_LENGTH, 'API source header name') || 'X-API-Key';
const authHeaderValue = validateMaxLength(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '', AUTH_MAX_LENGTH, 'API source header value');
const itemsPath = validateMaxLength(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '', ITEMS_PATH_MAX_LENGTH, 'API source items path');
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60)); 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'); const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
@@ -146,9 +221,34 @@ function buildApiSourcePayload(req, existingApiSource) {
throw error; 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 { return {
name: name, name: name,
apiUrl: parsedUrl.toString(), apiUrl: parsedUrl.toString(),
authMethod: authMethod,
authUsername: authUsername,
authPassword: authPassword,
authBearerToken: authBearerToken,
authHeaderName: authHeaderName,
authHeaderValue: authHeaderValue,
itemsPath: itemsPath,
updateIntervalValue: Math.floor(updateIntervalValue), updateIntervalValue: Math.floor(updateIntervalValue),
updateIntervalUnit: updateIntervalUnit updateIntervalUnit: updateIntervalUnit
}; };
+43 -8
View File
@@ -1,14 +1,27 @@
// Canvas size data access and pagination helpers.
const { fetchPagedRows } = require('./utils'); const { fetchPagedRows } = require('./utils');
const MAX_CANVAS_SIZE_DIMENSION = 16384;
async function fetchCanvasSizesData(pool) { async function fetchCanvasSizesData(pool) {
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC'); const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
return { canvasSizes }; return { canvasSizes };
} }
async function fetchCanvasSizesPage(pool, page, pageSize) { async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC', selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC',
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes', countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
dimensions: ['width', 'height', 'name'],
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -17,14 +30,35 @@ async function fetchCanvasSizesPage(pool, page, pageSize) {
} }
async function fetchCanvasSizeById(pool, id) { async function fetchCanvasSizeById(pool, id) {
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]); const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]);
return rows[0] || null; return rows[0] || null;
} }
function readCanvasDimension(rawValue, fallbackValue, fieldName) {
const sourceValue = rawValue !== undefined && rawValue !== null && String(rawValue).trim() !== ''
? rawValue
: fallbackValue;
const numericValue = Number(sourceValue);
if (!Number.isFinite(numericValue)) {
const error = new Error('Canvas size ' + fieldName + ' must be a number.');
error.statusCode = 400;
throw error;
}
if (numericValue > MAX_CANVAS_SIZE_DIMENSION) {
const error = new Error('Canvas size dimensions must be 16384 or less.');
error.statusCode = 400;
throw error;
}
return Math.max(1, numericValue);
}
function buildCanvasSizePayload(req, existingCanvasSize) { function buildCanvasSizePayload(req, existingCanvasSize) {
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
const width = Math.max(1, Number(req.body.width || (existingCanvasSize && existingCanvasSize.width) || 0)); const width = readCanvasDimension(req.body.width, existingCanvasSize && existingCanvasSize.width, 'width');
const height = Math.max(1, Number(req.body.height || (existingCanvasSize && existingCanvasSize.height) || 0)); const height = readCanvasDimension(req.body.height, existingCanvasSize && existingCanvasSize.height, 'height');
if (!name) { if (!name) {
const error = new Error('Canvas size name is required.'); const error = new Error('Canvas size name is required.');
@@ -43,5 +77,6 @@ module.exports = {
fetchCanvasSizesData, fetchCanvasSizesData,
fetchCanvasSizesPage, fetchCanvasSizesPage,
fetchCanvasSizeById, fetchCanvasSizeById,
buildCanvasSizePayload buildCanvasSizePayload,
MAX_CANVAS_SIZE_DIMENSION
}; };
+3 -1
View File
@@ -1,3 +1,5 @@
// Client name reservation helpers for onboarding and live connection checks.
const crypto = require('crypto'); const crypto = require('crypto');
function normalizeClientName(value) { function normalizeClientName(value) {
@@ -26,7 +28,7 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
if (pool) { if (pool) {
const [deviceRows] = await pool.query( const [deviceRows] = await pool.query(
`SELECT device_id `SELECT device_id
FROM player_onboarding_devices FROM d_onboarding_devices
WHERE client_name IS NOT NULL WHERE client_name IS NOT NULL
AND TRIM(client_name) <> '' AND TRIM(client_name) <> ''
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?)) AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
+32 -3
View File
@@ -1,24 +1,50 @@
// Aggregated exports for the shared data access layer.
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin'); 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 { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); 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 { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes'); const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
const { fetchSlideById, buildSlidePayload } = require('./slides'); const { fetchSlideById, buildSlidePayload } = require('./slides');
const { parseJsonSafe, fetchDuplicateName } = require('./utils'); const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('./utils');
module.exports = { module.exports = {
slugify, slugify,
uniqueScreenSlug, uniqueScreenSlug,
parseJsonSafe, parseJsonSafe,
validateMaxLength,
truncateToMaxLength,
fetchAdminData, fetchAdminData,
fetchPlaylistsPage, fetchPlaylistsPage,
fetchSlidesPage, fetchSlidesPage,
fetchTemplatesPage, fetchTemplatesPage,
fetchCanvasSizesPage, fetchCanvasSizesPage,
fetchScreensPage, fetchScreensPage,
ANNOUNCEMENT_TYPES,
ANNOUNCEMENT_COLORS,
ANNOUNCEMENT_ICONS,
ANNOUNCEMENT_ICON_OPTIONS,
ANNOUNCEMENT_ICON_LABELS,
DEFAULT_ANNOUNCEMENT_ICON,
normalizeAnnouncementType,
normalizeAnnouncementColor,
normalizeAnnouncementIcon,
fetchAnnouncementsPage,
fetchAnnouncementById,
fetchActiveAnnouncement,
buildAnnouncementPayload,
fetchPlaylistById, fetchPlaylistById,
normalizeDisplayMode,
fetchTimetablesData,
fetchTimetableGroupsPage,
fetchTimetableGroupById,
fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload,
fetchApiSourcesData, fetchApiSourcesData,
fetchApiSourcesPage, fetchApiSourcesPage,
fetchApiSourceById, fetchApiSourceById,
@@ -34,12 +60,15 @@ module.exports = {
replaceRssFeedItems, replaceRssFeedItems,
fetchScreenById, fetchScreenById,
fetchScreenEditData, fetchScreenEditData,
fetchScreenPlayerUrls,
fetchScreenPlayerRecord,
fetchTemplateById, fetchTemplateById,
fetchSlideById, fetchSlideById,
fetchTemplatesData, fetchTemplatesData,
fetchCanvasSizesData, fetchCanvasSizesData,
fetchCanvasSizeById, fetchCanvasSizeById,
buildCanvasSizePayload, buildCanvasSizePayload,
MAX_CANVAS_SIZE_DIMENSION,
buildSlidePayload, buildSlidePayload,
extractTemplateRegions, extractTemplateRegions,
buildTemplatePayload, buildTemplatePayload,
+3 -1
View File
@@ -1,5 +1,7 @@
// Playlist data access helpers.
async function fetchPlaylistById(pool, id) { 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 playlists WHERE id = ?', [id]); const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]);
return rows[0] || null; return rows[0] || null;
} }
+562
View File
@@ -0,0 +1,562 @@
const fs = require('fs');
const path = require('path');
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
const puppeteer = require('puppeteer-core');
const chromiumModule = require('@sparticuz/chromium');
const QR_PNG_WIDTH = 2048;
const QR_STYLING_SCRIPT_PATH = path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js');
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 chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
? chromiumModule
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
? chromiumModule.default
: chromiumModule;
let qrBrowserPromise = null;
function escapeXml(value) {
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, function (character) {
return {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;'
}[character];
});
}
function serializeNode(node) {
if (!node) {
return '';
}
if (node.nodeType === 3) {
return escapeXml(node.textContent || '');
}
const attributeEntries = Object.keys(node.attributes || {}).map(function (name) {
return name + '="' + escapeXml(node.attributes[name]) + '"';
});
if (node.tagName === 'svg' && node.namespaceURI === 'http://www.w3.org/2000/svg' && !Object.prototype.hasOwnProperty.call(node.attributes || {}, 'xmlns')) {
attributeEntries.unshift('xmlns="http://www.w3.org/2000/svg"');
}
const attributes = attributeEntries.join(' ');
const children = (node.childNodes || []).map(serializeNode).join('') + escapeXml(node.textContent || '');
return '<' + node.tagName + (attributes ? ' ' + attributes : '') + '>' + children + '</' + node.tagName + '>';
}
function createDomNode(tagName, namespaceUri) {
return {
tagName: tagName,
namespaceURI: namespaceUri || null,
attributes: {},
childNodes: [],
parentNode: null,
nodeType: 1,
textContent: '',
style: {},
setAttribute: function (name, value) {
this.attributes[name] = String(value);
},
appendChild: function (child) {
if (child) {
this.childNodes.push(child);
child.parentNode = this;
}
return child;
},
removeChild: function (child) {
this.childNodes = this.childNodes.filter(function (node) { return node !== child; });
return child;
},
get firstChild() {
return this.childNodes[0] || null;
},
get outerHTML() {
return serializeNode(this);
}
};
}
function createCanvasContext() {
return {
fillStyle: '#000000',
strokeStyle: '#000000',
lineWidth: 1,
beginPath: function () {},
closePath: function () {},
clearRect: function () {},
fillRect: function () {},
strokeRect: function () {},
moveTo: function () {},
lineTo: function () {},
arc: function () {},
rect: function () {},
fill: function () {},
stroke: function () {},
save: function () {},
restore: function () {},
translate: function () {},
rotate: function () {},
scale: function () {},
setTransform: function () {},
transform: function () {},
drawImage: function () {},
measureText: function (text) {
return { width: String(text === undefined || text === null ? '' : text).length * 8 };
},
createLinearGradient: function () {
return { addColorStop: function () {} };
},
createRadialGradient: function () {
return { addColorStop: function () {} };
},
createPattern: function () {
return null;
},
getImageData: function () {
return { data: [] };
},
putImageData: function () {},
clip: function () {}
};
}
function createCanvasNode() {
const node = createDomNode('canvas', 'http://www.w3.org/1999/xhtml');
node.width = 0;
node.height = 0;
node.getContext = function () {
return createCanvasContext();
};
node.toDataURL = function () {
return '';
};
node.toBlob = function (callback) {
if (typeof callback === 'function') {
callback(null);
}
};
return node;
}
function createQrStylingWindow() {
const document = {
createElement: function (tagName) {
if (String(tagName || '').toLowerCase() === 'canvas') {
return createCanvasNode();
}
return createDomNode(tagName, 'http://www.w3.org/1999/xhtml');
},
createElementNS: function (namespaceUri, tagName) {
return createDomNode(tagName, namespaceUri);
},
createTextNode: function (text) {
return {
nodeType: 3,
textContent: String(text === undefined || text === null ? '' : text),
parentNode: null
};
},
body: createDomNode('body', 'http://www.w3.org/1999/xhtml'),
head: createDomNode('head', 'http://www.w3.org/1999/xhtml')
};
const window = {
document: document,
navigator: { userAgent: 'node' },
URL: {
createObjectURL: function () { return ''; },
revokeObjectURL: function () {}
}
};
window.window = window;
window.self = window;
window.Image = class {
constructor() {
this.onload = null;
this.onerror = null;
this.width = 1;
this.height = 1;
this.naturalWidth = 1;
this.naturalHeight = 1;
this._src = '';
}
set src(value) {
this._src = String(value === undefined || value === null ? '' : value);
if (typeof this.onload === 'function') {
setTimeout(() => this.onload(), 0);
}
}
get src() {
return this._src;
}
};
window.HTMLImageElement = window.Image;
window.XMLSerializer = class {
serializeToString(node) {
return serializeNode(node);
}
};
return { window: window, document: document };
}
async function renderStyledQrRawData(options, format) {
const previousWindow = global.window;
const previousDocument = global.document;
const previousXMLSerializer = global.XMLSerializer;
const dom = createQrStylingWindow();
global.window = dom.window;
global.document = dom.document;
global.XMLSerializer = dom.window.XMLSerializer;
try {
const qrCode = new QRCodeStyling(options);
const blob = await qrCode.getRawData(format);
if (!blob) {
return '';
}
if (typeof blob === 'string') {
return blob;
}
if (typeof blob.text === 'function') {
return blob.text();
}
if (typeof blob.arrayBuffer === 'function') {
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return binary;
}
return '';
} finally {
global.window = previousWindow;
global.document = previousDocument;
global.XMLSerializer = previousXMLSerializer;
}
}
function svgToDataUrl(svg) {
const markup = String(svg === undefined || svg === null ? '' : svg).trim();
if (!markup) {
return '';
}
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup);
}
function normalizeQrStyleColor(value, fallback) {
const raw = String(value === undefined || value === null ? '' : value).trim();
return raw || fallback;
}
function normalizeQrStyleNumber(value, fallback, min, max) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
let next = parsed;
if (typeof min === 'number') {
next = Math.max(min, next);
}
if (typeof max === 'number') {
next = Math.min(max, next);
}
return next;
}
function buildQrStylingOptions(source) {
const text = String(source && source.value === undefined ? '' : source && source.value === null ? '' : source.value || '').trim();
const qrStyle = source && typeof source === 'object' ? source : {};
const dotsGradient = qrStyle.qr_dots_color_mode === 'gradient' ? {
type: String(qrStyle.qr_dots_gradient_type || 'linear').trim() || 'linear',
rotation: normalizeQrStyleNumber(qrStyle.qr_dots_gradient_rotation, 0, undefined, undefined),
colorStops: [
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_dots_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_dots_color, '#000000')) },
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_dots_gradient_color_2, '#ffffff') }
]
} : null;
const cornersSquareGradient = qrStyle.qr_corners_square_color_mode === 'gradient' ? {
type: String(qrStyle.qr_corners_square_gradient_type || 'linear').trim() || 'linear',
rotation: normalizeQrStyleNumber(qrStyle.qr_corners_square_gradient_rotation, 0, undefined, undefined),
colorStops: [
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_corners_square_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_corners_square_color, '#000000')) },
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_corners_square_gradient_color_2, '#ffffff') }
]
} : null;
const cornersDotGradient = qrStyle.qr_corners_dot_color_mode === 'gradient' ? {
type: String(qrStyle.qr_corners_dot_gradient_type || 'linear').trim() || 'linear',
rotation: normalizeQrStyleNumber(qrStyle.qr_corners_dot_gradient_rotation, 0, undefined, undefined),
colorStops: [
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_corners_dot_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_corners_dot_color, '#000000')) },
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_corners_dot_gradient_color_2, '#ffffff') }
]
} : null;
return {
width: QR_PNG_WIDTH,
height: QR_PNG_WIDTH,
type: 'canvas',
data: text,
margin: normalizeQrStyleNumber(qrStyle.qr_margin, 10, 0, undefined),
qrOptions: {},
dotsOptions: {
type: String(qrStyle.qr_dots_type || 'square').trim() || 'square',
color: normalizeQrStyleColor(qrStyle.qr_dots_color, '#000000'),
gradient: dotsGradient || undefined
},
cornersSquareOptions: {
type: String(qrStyle.qr_corners_square_type || 'square').trim() || 'square',
color: normalizeQrStyleColor(qrStyle.qr_corners_square_color, '#000000'),
gradient: cornersSquareGradient || undefined
},
cornersDotOptions: {
type: String(qrStyle.qr_corners_dot_type || 'square').trim() || 'square',
color: normalizeQrStyleColor(qrStyle.qr_corners_dot_color, '#000000'),
gradient: cornersDotGradient || undefined
},
backgroundOptions: {
color: qrStyle.qr_background_transparent ? 'transparent' : normalizeQrStyleColor(qrStyle.qr_background_color, '#ffffff')
},
image: String(qrStyle.qr_image || '').trim() || undefined,
imageOptions: {
hideBackgroundDots: Boolean(qrStyle.qr_image_hide_background_dots),
imageSize: normalizeQrStyleNumber(qrStyle.qr_image_size, 0.4, 0, 1),
margin: normalizeQrStyleNumber(qrStyle.qr_image_margin, 5, 0, undefined)
}
};
}
function getQrBrowser() {
if (qrBrowserPromise) {
return qrBrowserPromise;
}
qrBrowserPromise = (async function () {
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.');
}
return puppeteer.launch({
args: usingSystemChromium
? [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu'
]
: puppeteer.defaultArgs({
args: chromium && chromium.args ? chromium.args : [],
headless: 'shell'
}),
defaultViewport: usingSystemChromium
? { width: QR_PNG_WIDTH, height: QR_PNG_WIDTH, deviceScaleFactor: 1 }
: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
executablePath: executablePath,
headless: usingSystemChromium ? true : 'shell'
});
})();
return qrBrowserPromise;
}
async function blobToDataUrl(blob) {
if (!blob) {
return '';
}
if (typeof blob === 'string') {
return blob;
}
if (typeof blob.arrayBuffer === 'function') {
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return 'data:' + String(blob.type || 'image/png') + ';base64,' + Buffer.from(binary, 'binary').toString('base64');
}
if (typeof blob.text === 'function') {
return blob.text();
}
return '';
}
async function createStyledQrCodeDataUrl(value) {
const source = value && typeof value === 'object' ? value : { value: value };
const options = buildQrStylingOptions(source);
if (!options.data) {
return '';
}
return svgToDataUrl(await createStyledQrCodeSvg(options.data));
}
async function createStyledQrCodeSvg(value) {
const source = value && typeof value === 'object' ? value : { value: value };
const options = buildQrStylingOptions(source);
if (!options.data) {
return '';
}
return renderStyledQrRawData(options, 'svg');
}
async function createQrCodeDataUrlPlain(value) {
return createStyledQrCodeDataUrl(value);
}
async function createQrCodeSvg(value) {
return createStyledQrCodeSvg(value);
}
async function createQrCodeDataUrl(value) {
return createStyledQrCodeDataUrl(value);
}
async function buildQrCodeContent(value, options) {
const source = value && typeof value === 'object' ? value : { value: value };
const forcePreviewRefresh = Boolean(options && options.forcePreviewRefresh);
const text = String(source.value === undefined || source.value === null ? '' : source.value).trim();
const hasBooleanField = function (key) {
return Object.prototype.hasOwnProperty.call(source, key);
};
const margin = Number(source.qr_margin);
const normalizeHex = function (value) {
const raw = String(value === undefined || value === null ? '' : value).trim();
return raw || undefined;
};
const normalizeNumber = function (value, min, max, round) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return undefined;
}
let next = parsed;
if (round) {
next = Math.round(next);
}
if (typeof min === 'number') {
next = Math.max(min, next);
}
if (typeof max === 'number') {
next = Math.min(max, next);
}
return next;
};
const style = {
qr_margin: Number.isFinite(margin) && margin >= 0 ? Math.round(margin) : undefined,
qr_border_radius: Number.isFinite(Number(source.qr_border_radius)) ? Math.max(0, Math.round(Number(source.qr_border_radius))) : undefined,
qr_background_color: String(source.qr_background_color === undefined || source.qr_background_color === null ? '' : source.qr_background_color).trim() || undefined,
qr_background_transparent: hasBooleanField('qr_background_transparent') ? Boolean(source.qr_background_transparent) : undefined,
qr_background_color_mode: 'single',
qr_background_gradient_type: String(source.qr_background_gradient_type === undefined || source.qr_background_gradient_type === null ? '' : source.qr_background_gradient_type).trim() || undefined,
qr_background_gradient_rotation: normalizeNumber(source.qr_background_gradient_rotation),
qr_background_gradient_color_1: normalizeHex(source.qr_background_gradient_color_1),
qr_background_gradient_color_2: normalizeHex(source.qr_background_gradient_color_2),
qr_dots_color: String(source.qr_dots_color === undefined || source.qr_dots_color === null ? '' : source.qr_dots_color).trim() || undefined,
qr_dots_type: String(source.qr_dots_type === undefined || source.qr_dots_type === null ? '' : source.qr_dots_type).trim() || undefined,
qr_dots_color_mode: String(source.qr_dots_color_mode === undefined || source.qr_dots_color_mode === null ? '' : source.qr_dots_color_mode).trim() || undefined,
qr_dots_gradient_type: String(source.qr_dots_gradient_type === undefined || source.qr_dots_gradient_type === null ? '' : source.qr_dots_gradient_type).trim() || undefined,
qr_dots_gradient_rotation: normalizeNumber(source.qr_dots_gradient_rotation),
qr_dots_gradient_color_1: normalizeHex(source.qr_dots_gradient_color_1),
qr_dots_gradient_color_2: normalizeHex(source.qr_dots_gradient_color_2),
qr_corners_square_color: String(source.qr_corners_square_color === undefined || source.qr_corners_square_color === null ? '' : source.qr_corners_square_color).trim() || undefined,
qr_corners_square_type: String(source.qr_corners_square_type === undefined || source.qr_corners_square_type === null ? '' : source.qr_corners_square_type).trim() || undefined,
qr_corners_square_color_mode: String(source.qr_corners_square_color_mode === undefined || source.qr_corners_square_color_mode === null ? '' : source.qr_corners_square_color_mode).trim() || undefined,
qr_corners_square_gradient_type: String(source.qr_corners_square_gradient_type === undefined || source.qr_corners_square_gradient_type === null ? '' : source.qr_corners_square_gradient_type).trim() || undefined,
qr_corners_square_gradient_rotation: normalizeNumber(source.qr_corners_square_gradient_rotation),
qr_corners_square_gradient_color_1: normalizeHex(source.qr_corners_square_gradient_color_1),
qr_corners_square_gradient_color_2: normalizeHex(source.qr_corners_square_gradient_color_2),
qr_corners_dot_color: String(source.qr_corners_dot_color === undefined || source.qr_corners_dot_color === null ? '' : source.qr_corners_dot_color).trim() || undefined,
qr_corners_dot_type: String(source.qr_corners_dot_type === undefined || source.qr_corners_dot_type === null ? '' : source.qr_corners_dot_type).trim() || undefined,
qr_corners_dot_color_mode: String(source.qr_corners_dot_color_mode === undefined || source.qr_corners_dot_color_mode === null ? '' : source.qr_corners_dot_color_mode).trim() || undefined,
qr_corners_dot_gradient_type: String(source.qr_corners_dot_gradient_type === undefined || source.qr_corners_dot_gradient_type === null ? '' : source.qr_corners_dot_gradient_type).trim() || undefined,
qr_corners_dot_gradient_rotation: normalizeNumber(source.qr_corners_dot_gradient_rotation),
qr_corners_dot_gradient_color_1: normalizeHex(source.qr_corners_dot_gradient_color_1),
qr_corners_dot_gradient_color_2: normalizeHex(source.qr_corners_dot_gradient_color_2),
qr_image: String(source.qr_image === undefined || source.qr_image === null ? '' : source.qr_image).trim() || undefined,
qr_image_size: Number.isFinite(Number(source.qr_image_size)) ? Math.max(0, Math.min(1, Number(source.qr_image_size))) : undefined,
qr_image_margin: Number.isFinite(Number(source.qr_image_margin)) ? Math.max(0, Math.round(Number(source.qr_image_margin))) : undefined,
qr_image_hide_background_dots: hasBooleanField('qr_image_hide_background_dots') ? Boolean(source.qr_image_hide_background_dots) : undefined
};
const content = {
type: 'qr-code',
value: text
};
Object.keys(style).forEach((key) => {
if (style[key] !== undefined) {
content[key] = style[key];
}
});
content.qr_background_use_gradient = false;
content.qr_dots_use_gradient = content.qr_dots_gradient_type && content.qr_dots_gradient_type !== 'none';
content.qr_corners_square_use_gradient = content.qr_corners_square_gradient_type && content.qr_corners_square_gradient_type !== 'none';
content.qr_corners_dot_use_gradient = content.qr_corners_dot_gradient_type && content.qr_corners_dot_gradient_type !== 'none';
const svg = String(source.qr_svg === undefined || source.qr_svg === null ? '' : source.qr_svg).trim();
if (svg) {
content.qr_svg = svg;
}
const preview = forcePreviewRefresh ? '' : String(source.qr_preview === undefined || source.qr_preview === null ? '' : source.qr_preview).trim();
if (preview) {
content.qr_preview = preview;
}
if (text && !content.qr_preview) {
const generatedSvg = await createQrCodeSvg(text);
if (generatedSvg) {
content.qr_svg = generatedSvg;
}
const generatedDataUrl = await createStyledQrCodeDataUrl(content);
if (generatedDataUrl) {
content.qr_preview = generatedDataUrl;
}
}
return content;
}
module.exports = {
createQrCodeSvg,
createStyledQrCodeSvg,
createStyledQrCodeDataUrl,
createQrCodeDataUrl,
buildQrCodeContent
};
+28 -11
View File
@@ -1,6 +1,11 @@
// RSS feed data access, pagination, and item normalization helpers.
const http = require('http'); const http = require('http');
const https = require('https'); const https = require('https');
const { fetchPagedRows } = require('./utils'); const { fetchPagedRows, validateMaxLength } = require('./utils');
const NAME_MAX_LENGTH = 255;
const URL_MAX_LENGTH = 1024;
function normalizeUpdateIntervalUnit(value) { function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase(); const unit = String(value || '').trim().toLowerCase();
@@ -9,16 +14,28 @@ function normalizeUpdateIntervalUnit(value) {
async function fetchRssFeedsData(pool) { async function fetchRssFeedsData(pool) {
const [rssFeeds] = await pool.query( const [rssFeeds] = await pool.query(
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC' 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC'
); );
return { rssFeeds: rssFeeds }; return { rssFeeds: rssFeeds };
} }
async function fetchRssFeedsPage(pool, page, pageSize) { async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC', selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM rss_feeds', countSql: 'SELECT COUNT(*) AS count FROM i_rss_feeds',
searchColumns: ['name', 'feed_url'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
url: 'feed_url',
interval: ['update_interval_value', 'update_interval_unit'],
items: 'item_limit',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -28,7 +45,7 @@ async function fetchRssFeedsPage(pool, page, pageSize) {
async function fetchRssFeedById(pool, id) { async function fetchRssFeedById(pool, id) {
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?', 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?',
[id] [id]
); );
@@ -38,7 +55,7 @@ async function fetchRssFeedById(pool, id) {
async function fetchRssFeedItemsByFeedId(pool, rssFeedId) { async function fetchRssFeedItemsByFeedId(pool, rssFeedId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT id, rss_feed_id, position, item_json, created_at, modified_at `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 = ? WHERE rss_feed_id = ?
ORDER BY position ASC, id ASC`, ORDER BY position ASC, id ASC`,
[rssFeedId] [rssFeedId]
@@ -217,7 +234,7 @@ async function fetchRssFeedItems(feedUrl, itemLimit) {
async function replaceRssFeedItems(connection, rssFeedId, items) { async function replaceRssFeedItems(connection, rssFeedId, items) {
const normalizedItems = Array.isArray(items) ? 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) { if (!normalizedItems.length) {
return 0; return 0;
@@ -232,7 +249,7 @@ async function replaceRssFeedItems(connection, rssFeedId, items) {
}); });
await connection.query( 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] [insertValues]
); );
@@ -241,8 +258,8 @@ async function replaceRssFeedItems(connection, rssFeedId, items) {
function buildRssFeedPayload(req, existingRssFeed) { function buildRssFeedPayload(req, existingRssFeed) {
const fallback = existingRssFeed || {}; const fallback = existingRssFeed || {};
const name = String(req.body.name || fallback.name || '').trim(); const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'RSS feed name');
const feedUrl = String(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '').trim(); const feedUrl = validateMaxLength(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '', URL_MAX_LENGTH, 'RSS feed URL');
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60)); 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'); const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
const itemLimit = Math.max(1, Number(req.body.item_limit || req.body.itemLimit || fallback.item_limit || 1)); const itemLimit = Math.max(1, Number(req.body.item_limit || req.body.itemLimit || fallback.item_limit || 1));
+123
View File
@@ -0,0 +1,123 @@
// Timetable group and entry data access helpers.
const { fetchPagedRows, validateMaxLength } = require('./utils');
const NAME_MAX_LENGTH = 255;
const DESCRIPTION_MAX_LENGTH = 255;
function normalizeDisplayMode(value) {
const mode = String(value || 'upcoming').trim().toLowerCase();
if (mode === 'current' || mode === 'both') {
return mode;
}
return 'upcoming';
}
async function fetchTimetablesData(pool) {
const [timetableGroups] = await pool.query(`
SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
FROM i_schedule_groups g
ORDER BY g.modified_at DESC, g.id DESC
`);
const [timetableEntries] = await pool.query(`
SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
FROM i_schedule_entries
ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC
`);
const entriesByGroupId = new Map();
timetableEntries.forEach(function (entry) {
const groupId = Number(entry.schedule_group_id);
if (!entriesByGroupId.has(groupId)) {
entriesByGroupId.set(groupId, []);
}
entriesByGroupId.get(groupId).push(entry);
});
const groups = timetableGroups.map(function (group) {
return Object.assign({}, group, {
entries: entriesByGroupId.get(Number(group.id)) || []
});
});
return {
timetableGroups: groups,
timetableEntries: timetableEntries
};
}
async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
FROM i_schedule_groups g
ORDER BY g.modified_at DESC, g.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM i_schedule_groups',
searchColumns: ['g.name', 'g.short_description'],
searchTerm: searchTerm,
sortColumns: {
name: 'g.name',
description: 'g.short_description',
entries: 'entry_count',
next_start: 'next_start_datetime',
created: 'g.created_at',
modified: 'g.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ timetableGroups: paged.rows }, paged);
}
async function fetchTimetableGroupById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, short_description, created_at, modified_at, created_by, modified_by FROM i_schedule_groups WHERE id = ?',
[id]
);
return rows[0] || null;
}
async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) {
const [rows] = await pool.query(
`SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
FROM i_schedule_entries
WHERE schedule_group_id = ?
ORDER BY start_datetime ASC, id ASC`,
[timetableGroupId]
);
return rows;
}
function buildTimetableGroupPayload(req, existingTimetableGroup) {
const fallback = existingTimetableGroup || {};
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'Timetable group name');
const shortDescription = validateMaxLength(req.body.short_description || req.body.shortDescription || fallback.short_description || '', DESCRIPTION_MAX_LENGTH, 'Timetable group description');
if (!name) {
const error = new Error('Timetable group name is required.');
error.statusCode = 400;
throw error;
}
return {
name: name,
shortDescription: shortDescription
};
}
module.exports = {
normalizeDisplayMode,
fetchTimetablesData,
fetchTimetableGroupsPage,
fetchTimetableGroupById,
fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload
};
+64 -4
View File
@@ -1,3 +1,5 @@
// Screen slug helpers and screen lookup utilities.
function slugify(value) { function slugify(value) {
return String(value || '') return String(value || '')
.toLowerCase() .toLowerCase()
@@ -7,13 +9,67 @@ function slugify(value) {
.replace(/-{2,}/g, '-'); .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) { async function uniqueScreenSlug(pool, baseSlug, excludeId) {
const start = baseSlug || `screen-${Date.now()}`; const start = baseSlug || `screen-${Date.now()}`;
let candidate = start; let candidate = start;
let counter = 2; let counter = 2;
while (true) { while (true) {
const params = [candidate]; const params = [candidate];
let sql = 'SELECT id FROM screens WHERE slug = ?'; let sql = 'SELECT id FROM d_screens WHERE slug = ?';
if (excludeId !== undefined && excludeId !== null) { if (excludeId !== undefined && excludeId !== null) {
sql += ' AND id <> ?'; sql += ' AND id <> ?';
params.push(excludeId); params.push(excludeId);
@@ -30,20 +86,24 @@ async function uniqueScreenSlug(pool, baseSlug, excludeId) {
async function fetchScreenById(pool, id) { async function fetchScreenById(pool, id) {
const [rows] = await pool.query(` const [rows] = await pool.query(`
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
FROM screens s FROM d_screens s
LEFT JOIN playlists p ON p.id = s.playlist_id LEFT JOIN c_playlists p ON p.id = s.playlist_id
WHERE s.id = ? WHERE s.id = ?
`, [id]); `, [id]);
return rows[0] || null; return rows[0] || null;
} }
async function fetchScreenEditData(pool) { async function fetchScreenEditData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC'); const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
return { playlists }; return { playlists };
} }
module.exports = { module.exports = {
slugify, slugify,
normalizePlayerBaseUrl,
buildScreenPlayerUrl,
fetchScreenPlayerUrls,
fetchScreenPlayerRecord,
uniqueScreenSlug, uniqueScreenSlug,
fetchScreenById, fetchScreenById,
fetchScreenEditData fetchScreenEditData
+262 -23
View File
@@ -1,5 +1,10 @@
// Slide data access helpers, including rich-text normalization and payload building.
const { fetchTemplateById } = require('./templates'); const { fetchTemplateById } = require('./templates');
const { parseJsonSafe } = require('./utils'); const { parseJsonSafe, validateMaxLength } = require('./utils');
const TITLE_MAX_LENGTH = 255;
const { buildQrCodeContent } = require('./qr-code');
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li']; const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
const DEFAULT_FONT_SIZE = 32; const DEFAULT_FONT_SIZE = 32;
@@ -25,13 +30,32 @@ function sanitizeRichText(html) {
}); });
} }
function normalizePlainText(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/gi, ' ')
.trim();
}
function stripEditorOnlyMarkup(value) {
return String(value || '')
.replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '')
.replace(/<details[^>]*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) { async function fetchSlideById(pool, id) {
const [slides] = await pool.query(` const [slides] = await pool.query(`
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height st.name AS template_name, st.canvas_size_id, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides s FROM c_slides s
LEFT JOIN slide_templates st ON st.id = s.template_id LEFT JOIN c_templates st ON st.id = s.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE s.id = ? WHERE s.id = ?
`, [id]); `, [id]);
if (!slides.length) { if (!slides.length) {
@@ -53,6 +77,14 @@ function getFilesByField(files) {
return map; 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) { function sanitizeTextColor(value, fallback) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) { if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
@@ -82,15 +114,150 @@ function getTextRegionStyle(body, region, existingContent) {
}; };
} }
function buildTemplateContent(template, body, filesByField, existingContent) { function getQrRegionStyle(body, region, existingContent) {
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const styleKeys = [
'qr_margin',
'qr_background_color',
'qr_background_transparent',
'qr_background_color_mode',
'qr_background_gradient_type',
'qr_background_gradient_rotation',
'qr_background_gradient_color_1',
'qr_background_gradient_color_2',
'qr_dots_color',
'qr_dots_type',
'qr_dots_color_mode',
'qr_dots_gradient_type',
'qr_dots_gradient_rotation',
'qr_dots_gradient_color_1',
'qr_dots_gradient_color_2',
'qr_corners_square_color',
'qr_corners_square_type',
'qr_corners_square_color_mode',
'qr_corners_square_gradient_type',
'qr_corners_square_gradient_rotation',
'qr_corners_square_gradient_color_1',
'qr_corners_square_gradient_color_2',
'qr_corners_dot_color',
'qr_corners_dot_type',
'qr_corners_dot_color_mode',
'qr_corners_dot_gradient_type',
'qr_corners_dot_gradient_rotation',
'qr_corners_dot_gradient_color_1',
'qr_corners_dot_gradient_color_2',
'qr_image',
'qr_image_size',
'qr_image_margin',
'qr_image_hide_background_dots',
'qr_border_radius'
];
const style = {};
styleKeys.forEach((key) => {
const fieldNames = [`region_${key}_${region.id}`, `${key}_${region.id}`];
let fieldName = fieldNames[0];
let hasSubmittedValue = false;
let submitted;
for (let index = 0; index < fieldNames.length; index += 1) {
const candidate = fieldNames[index];
if (Object.prototype.hasOwnProperty.call(body || {}, candidate)) {
fieldName = candidate;
hasSubmittedValue = true;
submitted = body[candidate];
break;
}
}
const value = String(submitted || '').trim();
if (key === 'qr_background_transparent' || key === 'qr_image_hide_background_dots') {
style[key] = hasSubmittedValue;
return;
}
if (key === 'qr_background_color_mode' || key === 'qr_dots_color_mode' || key === 'qr_corners_square_color_mode' || key === 'qr_corners_dot_color_mode' || key === 'qr_background_gradient_type' || key === 'qr_dots_gradient_type' || key === 'qr_corners_square_gradient_type' || key === 'qr_corners_dot_gradient_type') {
style[key] = value === 'none' ? 'none' : value;
return;
}
if (key === 'qr_image') {
style[key] = value;
return;
}
if (submitted === undefined) {
if (existing[key] !== undefined && existing[key] !== null && String(existing[key]).trim() !== '') {
style[key] = existing[key];
}
return;
}
if (value !== '') {
if (key === 'qr_margin') {
const parsedMargin = Number(value);
if (Number.isFinite(parsedMargin)) {
style[key] = Math.max(0, Math.round(parsedMargin));
}
return;
}
if (key === 'qr_image_size') {
const parsedSize = Number(value);
if (Number.isFinite(parsedSize)) {
style[key] = Math.max(0, Math.min(1, parsedSize));
}
return;
}
if (key === 'qr_image_margin' || key === 'qr_border_radius') {
const parsedImageMargin = Number(value);
if (Number.isFinite(parsedImageMargin)) {
style[key] = Math.max(0, Math.round(parsedImageMargin));
}
return;
}
style[key] = value;
}
});
return style;
}
async function getRssFeedItemCount(pool, feedId) {
const [rows] = await pool.query(
'SELECT COUNT(*) AS count FROM i_rss_feed_items WHERE rss_feed_id = ?',
[feedId]
);
return Number(rows && rows[0] && rows[0].count) || 0;
}
async function buildTemplateContent(pool, template, body, filesByField, existingContent) {
const content = {}; const content = {};
template.regions.forEach((region) => { for (const region of template.regions) {
if (region.region_type === 'image') { if (region.region_type === 'image') {
const uploaded = filesByField[`region_image_${region.id}`]; const uploaded = filesByField[`region_image_${region.id}`];
const existing = body[`existing_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] = { content[region.region_key] = {
type: 'image', 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}`];
const existing = body[`existing_region_video_${region.id}`];
const durationValue = body[`existing_region_video_duration_${region.id}`];
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}` : getSubmittedValue(body, `existing_region_video_${region.id}`, current),
duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null
}; };
} else if (region.region_type === 'webpage') { } else if (region.region_type === 'webpage') {
const submitted = body[`region_webpage_${region.id}`]; const submitted = body[`region_webpage_${region.id}`];
@@ -99,6 +266,24 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
type: 'webpage', type: 'webpage',
value: submitted === undefined ? current : String(submitted || '').trim() value: submitted === undefined ? current : String(submitted || '').trim()
}; };
} else if (region.region_type === 'qr-code') {
const uploadedImage = filesByField[`region_qr_image_${region.id}`];
const submitted = body[`region_qr_code_${region.id}`];
const submittedSvg = body[`region_qr_svg_${region.id}`];
const submittedPreview = body[`region_qr_preview_${region.id}`];
const existingImage = body[`existing_region_qr_image_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const nextValue = submitted === undefined ? String(current.value !== undefined ? current.value : current.qr_code || '').trim() : String(submitted || '').trim();
const qrStyle = getQrRegionStyle(body, region, existingContent);
delete qrStyle.qr_image;
content[region.region_key] = {
type: 'qr-code',
value: nextValue,
qr_svg: submittedSvg === undefined ? String(current.qr_svg || '').trim() : String(submittedSvg || '').trim(),
qr_preview: submittedPreview === undefined ? String(current.qr_preview || '').trim() : String(submittedPreview || '').trim(),
qr_image: uploadedImage ? `/media/uploads/${uploadedImage.filename}` : String(existingImage === undefined ? String(current.qr_image || '').trim() : String(existingImage || '').trim()),
...qrStyle
};
} else if (region.region_type === 'rtmp') { } else if (region.region_type === 'rtmp') {
const submitted = body[`region_rtmp_${region.id}`]; const submitted = body[`region_rtmp_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
@@ -114,6 +299,15 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
type: 'html', type: 'html',
value: submitted === undefined ? current : String(submitted || '') 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') { } else if (region.region_type === 'rss') {
const submitted = body[`region_text_${region.id}`]; const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
@@ -121,11 +315,13 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
const feedId = body[`region_rss_feed_id_${region.id}`]; const feedId = body[`region_rss_feed_id_${region.id}`];
const itemNumber = body[`region_rss_item_number_${region.id}`]; const itemNumber = body[`region_rss_item_number_${region.id}`];
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1)); const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
const normalizedFeedId = feedId === undefined || feedId === null || feedId === '' ? Number(current.feed_id || 0) : Number(feedId);
const itemCount = normalizedFeedId > 0 ? Math.max(1, await getRssFeedItemCount(pool, normalizedFeedId) || 1) : 1;
content[region.region_key] = { content[region.region_key] = {
type: 'rss', 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), feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId),
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, item_number: Math.min(itemCount, Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1),
variable_name: 'item', variable_name: 'item',
font_family: style.font_family, font_family: style.font_family,
font_size: style.font_size, font_size: style.font_size,
@@ -137,35 +333,72 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
const style = getTextRegionStyle(body, region, existingContent); const style = getTextRegionStyle(body, region, existingContent);
const sourceId = body[`region_api_source_id_${region.id}`]; const sourceId = body[`region_api_source_id_${region.id}`];
const itemNumber = body[`region_api_item_number_${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)); const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
content[region.region_key] = { content[region.region_key] = {
type: 'api', 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), source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId),
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, 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', variable_name: 'item',
font_family: style.font_family, font_family: style.font_family,
font_size: style.font_size, font_size: style.font_size,
font_color: style.font_color font_color: style.font_color
}; };
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', '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 { } else {
const submitted = body[`region_text_${region.id}`]; const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : ''; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
const style = getTextRegionStyle(body, region, existingContent); const style = getTextRegionStyle(body, region, existingContent);
content[region.region_key] = { content[region.region_key] = {
type: 'text', type: 'text',
value: submitted === undefined ? current : String(submitted), value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || ''))),
font_family: style.font_family, font_family: style.font_family,
font_size: style.font_size, font_size: style.font_size,
font_color: style.font_color font_color: style.font_color
}; };
} }
}); }
return content; return content;
} }
async function buildSlidePayload(pool, req, existingSlide) { async function buildSlidePayload(pool, req, existingSlide) {
const title = String(req.body.title || '').trim(); const title = validateMaxLength(req.body.title || '', TITLE_MAX_LENGTH, 'Slide title');
const templateId = req.body.template_id ? Number(req.body.template_id) : null; const templateId = req.body.template_id ? Number(req.body.template_id) : null;
const filesByField = getFilesByField(req.files || []); const filesByField = getFilesByField(req.files || []);
const template = templateId ? await fetchTemplateById(pool, templateId) : null; const template = templateId ? await fetchTemplateById(pool, templateId) : null;
@@ -184,23 +417,29 @@ async function buildSlidePayload(pool, req, existingSlide) {
} }
if (template) { if (template) {
const content = await buildTemplateContent(pool, template, req.body, filesByField, existingContent);
await Promise.all(Object.keys(content).map(async function (regionKey) {
const region = template.regions.find(function (item) {
return String(item.region_key || '').trim() === regionKey;
});
if (!region || region.region_type !== 'qr-code') {
return;
}
content[regionKey] = await buildQrCodeContent(content[regionKey]);
}));
return { return {
title, title,
body: existingSlide ? existingSlide.body : null,
templateId: template.id, templateId: template.id,
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)), contentJson: JSON.stringify(content)
mediaPath: null,
mediaType: null
}; };
} }
return { return {
title, title,
body: existingSlide ? existingSlide.body : null,
templateId: null, templateId: null,
contentJson: existingSlide ? existingSlide.content_json : null, contentJson: existingSlide ? existingSlide.content_json : null
mediaPath: existingSlide ? existingSlide.media_path : null,
mediaType: existingSlide ? existingSlide.media_type : null
}; };
} }
+114 -18
View File
@@ -1,7 +1,10 @@
const { parseJsonSafe, readFormArray } = require('./utils'); // Template data access helpers and region normalization logic.
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html', 'rtmp', 'rss', 'api']; const { parseJsonSafe, readFormArray, validateMaxLength } = require('./utils');
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api']; const animationPresets = require('../web/public/js/templates/animation-presets');
const TEMPLATE_NAME_MAX_LENGTH = 255;
const REGION_NAME_MAX_LENGTH = 255;
function sanitizeBackgroundColor(value) { function sanitizeBackgroundColor(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
@@ -13,7 +16,7 @@ function sanitizeBackgroundColor(value) {
function normalizeTemplateRegionType(value) { function normalizeTemplateRegionType(value) {
const rawType = String(value || 'text').trim(); const rawType = String(value || 'text').trim();
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text'; return rawType || 'text';
} }
function normalizeTemplateRegionLockRatio(value) { function normalizeTemplateRegionLockRatio(value) {
@@ -24,6 +27,61 @@ function normalizeTemplateRegionLockRatio(value) {
return rawRatio.replace(/\s+/g, ''); return rawRatio.replace(/\s+/g, '');
} }
function normalizeTemplateRegionAnimation(value) {
if (animationPresets && typeof animationPresets.normalize === 'function') {
return animationPresets.normalize(value);
}
const allowed = new Set(
Array.isArray(animationPresets && animationPresets.allValues) && animationPresets.allValues.length
? animationPresets.allValues
: ['none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp', 'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp', 'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp', 'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp', 'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight', 'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight', 'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce']
);
const raw = String(value || '').trim().toLowerCase();
return allowed.has(raw) ? raw : 'none';
}
function normalizeTemplateRegionAnimationStep(value, fallbackPreset) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const normalized = {};
Object.keys(value).forEach((key) => {
normalized[key] = value[key];
});
normalized.preset = normalizeTemplateRegionAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
return normalized;
}
return {
preset: normalizeTemplateRegionAnimation(typeof value === 'string' ? value : fallbackPreset || 'none')
};
}
function normalizeTemplateRegionAnimationConfig(value) {
let raw = value;
if (typeof raw === 'string') {
const text = String(raw || '').trim();
if (!text) {
raw = null;
} else {
try {
raw = JSON.parse(text);
} catch (_error) {
raw = null;
}
}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
raw = {};
}
return {
intro: normalizeTemplateRegionAnimationStep(raw.intro, 'none'),
outro: normalizeTemplateRegionAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
loop: normalizeTemplateRegionAnimationStep(raw.loop, 'none')
};
}
function normalizeTemplateRegionName(value) { function normalizeTemplateRegionName(value) {
return String(value || '').trim(); return String(value || '').trim();
} }
@@ -50,15 +108,15 @@ async function fetchTemplateById(pool, id) {
const [templates] = await pool.query(` const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st FROM c_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id = ? WHERE st.id = ?
`, [id]); `, [id]);
if (!templates.length) { if (!templates.length) {
return null; return null;
} }
const template = templates[0]; 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 slide_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, animation_json, 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; template.regions = regions;
return template; return template;
} }
@@ -67,11 +125,11 @@ async function fetchTemplatesData(pool) {
const [templates] = await pool.query(` const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st FROM c_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC 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 slide_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, animation_json, 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 }; return { templates, templateRegions };
} }
@@ -82,12 +140,13 @@ function extractTemplateRegions(body) {
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
return parsed.map((region) => { return parsed.map((region) => {
const regionType = normalizeTemplateRegionType(region.region_type); const regionType = normalizeTemplateRegionType(region.region_type);
const regionName = validateMaxLength(region.region_name || region.region_key || region.label || '', REGION_NAME_MAX_LENGTH, 'Region name');
return { return {
region_key: String(region.region_name || region.region_key || region.label || '').trim(), region_key: regionName,
region_type: regionType, region_type: regionType,
label: String(region.region_name || region.label || region.region_key || '').trim(), label: regionName,
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(region.font_family || '').trim() || null : null,
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio), lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
x: Number(region.x || 0), x: Number(region.x || 0),
y: Number(region.y || 0), y: Number(region.y || 0),
width: Number(region.width || 100), width: Number(region.width || 100),
@@ -103,16 +162,16 @@ function extractTemplateRegions(body) {
const labels = readFormArray(body, 'region_label[]'); const labels = readFormArray(body, 'region_label[]');
const types = readFormArray(body, 'region_type[]'); const types = readFormArray(body, 'region_type[]');
const ratios = readFormArray(body, 'region_lock_ratio[]'); const ratios = readFormArray(body, 'region_lock_ratio[]');
const animationJsons = readFormArray(body, 'region_animation_json[]');
const xs = readFormArray(body, 'region_x[]'); const xs = readFormArray(body, 'region_x[]');
const ys = readFormArray(body, 'region_y[]'); const ys = readFormArray(body, 'region_y[]');
const widths = readFormArray(body, 'region_width[]'); const widths = readFormArray(body, 'region_width[]');
const heights = readFormArray(body, 'region_height[]'); const heights = readFormArray(body, 'region_height[]');
const zs = readFormArray(body, 'region_z[]'); const zs = readFormArray(body, 'region_z[]');
const fonts = readFormArray(body, 'font_family[]');
const regions = []; const regions = [];
for (let i = 0; i < keys.length; i += 1) { for (let i = 0; i < keys.length; i += 1) {
const name = String(names[i] || keys[i] || labels[i] || '').trim(); const name = validateMaxLength(names[i] || keys[i] || labels[i] || '', REGION_NAME_MAX_LENGTH, 'Region name');
const rawType = String(types[i] || 'text').trim(); const rawType = String(types[i] || 'text').trim();
const regionType = normalizeTemplateRegionType(rawType); const regionType = normalizeTemplateRegionType(rawType);
if (!name) { if (!name) {
@@ -122,8 +181,8 @@ function extractTemplateRegions(body) {
region_key: name, region_key: name,
region_type: regionType, region_type: regionType,
label: name, label: name,
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]), lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
x: Number(xs[i] || 0), x: Number(xs[i] || 0),
y: Number(ys[i] || 0), y: Number(ys[i] || 0),
width: Number(widths[i] || 100), width: Number(widths[i] || 100),
@@ -135,6 +194,43 @@ function extractTemplateRegions(body) {
return regions; 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) { function getFilesByField(files) {
const map = {}; const map = {};
(files || []).forEach((file) => { (files || []).forEach((file) => {
@@ -144,7 +240,7 @@ function getFilesByField(files) {
} }
async function buildTemplatePayload(pool, req, existingTemplate) { async function buildTemplatePayload(pool, req, existingTemplate) {
const name = String(req.body.name || '').trim(); const name = validateMaxLength(req.body.name || '', TEMPLATE_NAME_MAX_LENGTH, 'Template name');
const canvasSizeId = req.body.canvas_size_id ? Number(req.body.canvas_size_id) : null; const canvasSizeId = req.body.canvas_size_id ? Number(req.body.canvas_size_id) : null;
let canvasWidth = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_width) || 1920)); let canvasWidth = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_width) || 1920));
let canvasHeight = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_height) || 1080)); let canvasHeight = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_height) || 1080));
@@ -167,7 +263,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
let resolvedCanvasSizeId = canvasSizeId; let resolvedCanvasSizeId = canvasSizeId;
if (resolvedCanvasSizeId) { if (resolvedCanvasSizeId) {
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]); const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
const canvasSize = canvasSizes[0]; const canvasSize = canvasSizes[0];
if (!canvasSize) { if (!canvasSize) {
const error = new Error('Canvas size not found.'); const error = new Error('Canvas size not found.');
+323 -2
View File
@@ -1,3 +1,5 @@
// Shared helpers for parsing JSON, reading form arrays, and duplicate-name checks.
function parseJsonSafe(value) { function parseJsonSafe(value) {
if (!value) { if (!value) {
return null; return null;
@@ -22,28 +24,340 @@ function readFormArray(body, key) {
return [body[key]]; return [body[key]];
} }
function validateMaxLength(value, maxLength, fieldName) {
const text = String(value || '').trim();
const limit = Number(maxLength);
if (Number.isFinite(limit) && limit > 0 && text.length > limit) {
const error = new Error(fieldName + ' must be ' + limit + ' characters or fewer.');
error.statusCode = 400;
throw error;
}
return text;
}
function truncateToMaxLength(value, maxLength) {
const text = String(value || '').trim();
const limit = Number(maxLength);
if (!Number.isFinite(limit) || limit <= 0 || text.length <= limit) {
return text;
}
return text.slice(0, limit);
}
function normalizePageNumber(value) { function normalizePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1); const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber); return Math.max(1, pageNumber);
} }
function escapeLikeValue(value) {
return String(value || '').replace(/[\\%_]/g, '\\$&');
}
function normalizeSortDirection(value) {
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
function buildSortOrderClause(sortColumns, sortKey, sortDirection) {
const normalizedSortKey = String(sortKey || '').trim();
const normalizedSortDirection = normalizeSortDirection(sortDirection);
const sortColumnMap = sortColumns && typeof sortColumns === 'object' ? sortColumns : {};
const sortExpression = normalizedSortKey ? sortColumnMap[normalizedSortKey] : null;
if (!sortExpression) {
return {
clause: '',
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
const expressions = Array.isArray(sortExpression) ? sortExpression : [sortExpression];
const orderBySql = expressions.map(function (expression) {
return `${expression} ${normalizedSortDirection.toUpperCase()}`;
}).join(', ');
return {
clause: ` ORDER BY ${orderBySql}`,
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
function findTopLevelOrderByIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastOrderByIndex = -1;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const previousCharacter = index > 0 ? text[index - 1] : '';
if (inSingleQuote) {
if (character === '\'' && previousCharacter !== '\\') {
inSingleQuote = false;
}
continue;
}
if (inDoubleQuote) {
if (character === '"' && previousCharacter !== '\\') {
inDoubleQuote = false;
}
continue;
}
if (inBacktick) {
if (character === '`') {
inBacktick = false;
}
continue;
}
if (character === '\'') {
inSingleQuote = true;
continue;
}
if (character === '"') {
inDoubleQuote = true;
continue;
}
if (character === '`') {
inBacktick = true;
continue;
}
if (character === '(') {
depth += 1;
continue;
}
if (character === ')' && depth > 0) {
depth -= 1;
continue;
}
if (depth === 0 && /[oO]/.test(character)) {
const remaining = text.slice(index);
if (/^order\s+by\b/i.test(remaining)) {
lastOrderByIndex = index;
}
}
}
return lastOrderByIndex;
}
function findTopLevelWhereIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastWhereIndex = -1;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const previousCharacter = index > 0 ? text[index - 1] : '';
if (inSingleQuote) {
if (character === '\'' && previousCharacter !== '\\') {
inSingleQuote = false;
}
continue;
}
if (inDoubleQuote) {
if (character === '"' && previousCharacter !== '\\') {
inDoubleQuote = false;
}
continue;
}
if (inBacktick) {
if (character === '`') {
inBacktick = false;
}
continue;
}
if (character === '\'') {
inSingleQuote = true;
continue;
}
if (character === '"') {
inDoubleQuote = true;
continue;
}
if (character === '`') {
inBacktick = true;
continue;
}
if (character === '(') {
depth += 1;
continue;
}
if (character === ')' && depth > 0) {
depth -= 1;
continue;
}
if (depth === 0 && /[wW]/.test(character)) {
const remaining = text.slice(index);
if (/^where\b/i.test(remaining)) {
lastWhereIndex = index;
}
}
}
return lastWhereIndex;
}
function findTopLevelGroupByIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastGroupByIndex = -1;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const previousCharacter = index > 0 ? text[index - 1] : '';
if (inSingleQuote) {
if (character === '\'' && previousCharacter !== '\\') {
inSingleQuote = false;
}
continue;
}
if (inDoubleQuote) {
if (character === '"' && previousCharacter !== '\\') {
inDoubleQuote = false;
}
continue;
}
if (inBacktick) {
if (character === '`') {
inBacktick = false;
}
continue;
}
if (character === '\'') {
inSingleQuote = true;
continue;
}
if (character === '"') {
inDoubleQuote = true;
continue;
}
if (character === '`') {
inBacktick = true;
continue;
}
if (character === '(') {
depth += 1;
continue;
}
if (character === ')' && depth > 0) {
depth -= 1;
continue;
}
if (depth === 0 && /[gG]/.test(character)) {
const remaining = text.slice(index);
if (/^group\s+by\b/i.test(remaining)) {
lastGroupByIndex = index;
}
}
}
return lastGroupByIndex;
}
function buildSearchFilter(searchColumns, searchTerm) {
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
return String(column || '').trim();
}).filter(Boolean) : [];
const normalizedSearchTerm = String(searchTerm || '').trim().toLowerCase();
if (!columns.length || !normalizedSearchTerm) {
return {
clause: '',
params: []
};
}
const likeValue = `%${escapeLikeValue(normalizedSearchTerm)}%`;
return {
clause: ` WHERE (${columns.map(function (column) {
return `LOWER(COALESCE(CAST(${column} AS CHAR), '')) LIKE ? ESCAPE '\\\\'`;
}).join(' OR ')})`,
params: columns.map(function () {
return likeValue;
})
};
}
async function fetchPagedRows(pool, options) { async function fetchPagedRows(pool, options) {
const selectSql = String(options && options.selectSql || '').trim(); const selectSql = String(options && options.selectSql || '').trim();
const countSql = String(options && options.countSql || '').trim(); const countSql = String(options && options.countSql || '').trim();
const params = Array.isArray(options && options.params) ? options.params : []; const params = Array.isArray(options && options.params) ? options.params : [];
const searchFilter = buildSearchFilter(options && options.searchColumns, options && options.searchTerm);
const sortOrder = buildSortOrderClause(options && options.sortColumns, options && options.sortKey, options && options.sortDirection);
const pageSize = Math.max(1, Number(options && options.pageSize) || 10); const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
const currentPage = normalizePageNumber(options && options.page); const currentPage = normalizePageNumber(options && options.page);
if (!selectSql || !countSql) { if (!selectSql || !countSql) {
throw new Error('fetchPagedRows requires selectSql and countSql.'); throw new Error('fetchPagedRows requires selectSql and countSql.');
} }
const orderByIndex = findTopLevelOrderByIndex(selectSql);
const groupByIndex = findTopLevelGroupByIndex(selectSql);
let baseSelectSql = selectSql;
let orderBySql = '';
let groupBySql = '';
const [countRows] = await pool.query(countSql, params); if (orderByIndex >= 0) {
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
orderBySql = selectSql.slice(orderByIndex).trim();
}
if (groupByIndex >= 0 && (orderByIndex < 0 || groupByIndex < orderByIndex)) {
baseSelectSql = selectSql.slice(0, groupByIndex).trim();
groupBySql = selectSql.slice(groupByIndex, orderByIndex >= 0 ? orderByIndex : selectSql.length).trim();
}
const hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0;
const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : '';
const filteredSelectSql = `${baseSelectSql}${searchClause}${groupBySql ? ' ' + groupBySql : ''}`;
const countQuery = searchFilter.clause
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
: countSql;
const countParams = searchFilter.clause ? params.concat(searchFilter.params) : params;
const [countRows] = await pool.query(countQuery, countParams);
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0; const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages); const safeCurrentPage = Math.min(currentPage, totalPages);
const offset = (safeCurrentPage - 1) * pageSize; const offset = (safeCurrentPage - 1) * pageSize;
const [rows] = await pool.query(`${selectSql} LIMIT ? OFFSET ?`, params.concat([pageSize, offset]));
const activeOrderBySql = sortOrder.clause || (orderBySql ? ` ${orderBySql}` : '');
const selectQuery = `${filteredSelectSql}${activeOrderBySql} LIMIT ? OFFSET ?`;
const [rows] = await pool.query(selectQuery, params.concat(searchFilter.params, [pageSize, offset]));
return { return {
rows: rows || [], rows: rows || [],
@@ -76,7 +390,14 @@ async function fetchDuplicateName(pool, tableName, name, excludeId, columnName)
module.exports = { module.exports = {
parseJsonSafe, parseJsonSafe,
readFormArray, readFormArray,
validateMaxLength,
truncateToMaxLength,
normalizePageNumber, normalizePageNumber,
normalizeSortDirection,
buildSortOrderClause,
findTopLevelOrderByIndex,
findTopLevelGroupByIndex,
buildSearchFilter,
fetchPagedRows, fetchPagedRows,
fetchDuplicateName fetchDuplicateName
}; };
+84
View File
@@ -0,0 +1,84 @@
const { hashPassword } = require('#src/auth');
const { PERMISSIONS, DEFAULT_ROLE } = require('#src/rbac');
async function bootstrapDatabase(pool) {
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes');
if (!canvasSizeCountRows.length || Number(canvasSizeCountRows[0].canvas_size_count) === 0) {
await pool.query(`
INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
('Full HD', 1920, 1080),
('HD', 1280, 720),
('4K UHD', 3840, 2160),
('Portrait Full HD', 1080, 1920),
('Portrait HD', 720, 1280)
`);
}
for (const permission of PERMISSIONS) {
await pool.query(
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
);
}
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
const passwordRecord = hashPassword(password);
await pool.query(
'INSERT IGNORE INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
);
}
await pool.query('UPDATE a_users SET name = username WHERE name IS NULL OR name = ""');
await pool.query(
`INSERT INTO a_roles (role_key, name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
);
const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
SELECT ?, id, NULL, NULL FROM a_permissions`,
[defaultRoleId]
);
}
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by)
SELECT id, ?, NULL, NULL FROM a_users`,
[defaultRoleId]
);
}
}
const [defaultAdminRows] = await pool.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]);
if (defaultAdminRows.length) {
const defaultAdminId = Number(defaultAdminRows[0].id);
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM a_user_roles WHERE user_id = ?', [defaultAdminId]);
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
if (defaultRoleId) {
await pool.query(
'INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
[defaultAdminId, defaultRoleId, null, null]
);
}
}
}
}
module.exports = {
bootstrapDatabase
};
+27
View File
@@ -0,0 +1,27 @@
const mysql = require('mysql2/promise');
function createPool() {
return mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'signage_user',
password: process.env.DB_PASSWORD || 'signage_password',
database: process.env.DB_NAME || 'signage',
timezone: 'Z',
waitForConnections: true,
connectionLimit: 10,
namedPlaceholders: true
});
}
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM d_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
module.exports = {
createPool,
pruneStaleOnboardingDevices
};
+121 -133
View File
@@ -1,31 +1,7 @@
const mysql = require('mysql2/promise'); // Snapshot only: keep this file aligned with the current schema state.
const { hashPassword } = require('../auth');
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
const migrations = require('./migrations');
function createPool() {
return mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'signage_user',
password: process.env.DB_PASSWORD || 'signage_password',
database: process.env.DB_NAME || 'signage',
waitForConnections: true,
connectionLimit: 10,
namedPlaceholders: true
});
}
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM player_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
async function ensureSchema(pool, options) { async function ensureSchema(pool, options) {
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS canvas_sizes ( CREATE TABLE IF NOT EXISTS c_canvas_sizes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
width INT NOT NULL, width INT NOT NULL,
@@ -39,20 +15,22 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS playlists ( CREATE TABLE IF NOT EXISTS c_playlists (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0, fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
skip_unavailable_rtmp TINYINT(1) NOT NULL DEFAULT 0, skip_unavailable_rtmp TINYINT(1) NOT NULL DEFAULT 0,
canvas_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL modified_by INT NULL,
CONSTRAINT fk_playlists_canvas FOREIGN KEY (canvas_id) REFERENCES c_canvas_sizes(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS slide_templates ( CREATE TABLE IF NOT EXISTS c_templates (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
canvas_size_id INT NULL, canvas_size_id INT NULL,
@@ -66,22 +44,15 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` await pool.query(`
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES CREATE TABLE IF NOT EXISTS c_template_regions (
('Full HD', 1920, 1080),
('HD', 1280, 720),
('4K UHD', 3840, 2160),
('Portrait Full HD', 1080, 1920),
('Portrait HD', 720, 1280)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_template_regions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL, template_id INT NOT NULL,
region_key VARCHAR(100) NOT NULL, region_key VARCHAR(100) NOT NULL,
region_type VARCHAR(20) NOT NULL, region_type VARCHAR(20) NOT NULL,
label VARCHAR(255) NOT NULL, label VARCHAR(255) NOT NULL,
font_family VARCHAR(100) NULL,
lock_ratio VARCHAR(20) NULL, lock_ratio VARCHAR(20) NULL,
animation_json JSON NULL,
x INT NOT NULL DEFAULT 0, x INT NOT NULL DEFAULT 0,
y INT NOT NULL DEFAULT 0, y INT NOT NULL DEFAULT 0,
width INT NOT NULL DEFAULT 100, width INT NOT NULL DEFAULT 100,
@@ -95,14 +66,11 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS slides ( CREATE TABLE IF NOT EXISTS c_slides (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
body TEXT NULL,
template_id INT NULL, template_id INT NULL,
content_json JSON NULL, content_json JSON NULL,
media_path VARCHAR(512) NULL,
media_type VARCHAR(100) NULL,
thumbnail_path VARCHAR(512) NULL, thumbnail_path VARCHAR(512) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
@@ -112,43 +80,93 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS playlist_slides ( CREATE TABLE IF NOT EXISTS c_playlist_slides (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
playlist_id INT NOT NULL, playlist_id INT NOT NULL,
slide_id INT NOT NULL, slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0, position INT NOT NULL DEFAULT 0,
duration_seconds INT NOT NULL DEFAULT 10, duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always', use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
schedule_start_datetime DATETIME NULL, disable_audio TINYINT(1) NOT NULL DEFAULT 1,
schedule_end_datetime DATETIME NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
schedule_start_time TIME NULL, created_by INT NULL,
schedule_end_time TIME NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES c_slides(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
start_datetime DATETIME NULL,
end_datetime DATETIME NULL,
start_time TIME NULL,
end_time TIME NULL,
schedule_days_json JSON NULL, schedule_days_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL, modified_by INT NULL,
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS screens ( CREATE TABLE IF NOT EXISTS d_screens (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE, slug VARCHAR(255) NOT NULL UNIQUE,
playlist_id INT NULL, playlist_id INT NULL,
player_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL, modified_by INT NULL,
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS rss_feeds ( 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, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
feed_url VARCHAR(1024) NOT NULL, feed_url VARCHAR(1024) NOT NULL,
@@ -163,7 +181,7 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS rss_feed_items ( CREATE TABLE IF NOT EXISTS i_rss_feed_items (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
rss_feed_id INT NOT NULL, rss_feed_id INT NOT NULL,
position INT NOT NULL, position INT NOT NULL,
@@ -172,13 +190,13 @@ async function ensureSchema(pool, options) {
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL, modified_by INT NULL,
CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES rss_feeds(id) ON DELETE CASCADE, CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES i_rss_feeds(id) ON DELETE CASCADE,
UNIQUE KEY uq_rss_feed_items_feed_position (rss_feed_id, position) UNIQUE KEY uq_rss_feed_items_feed_position (rss_feed_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS api_sources ( CREATE TABLE IF NOT EXISTS i_api_sources (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
api_url VARCHAR(1024) NOT NULL, api_url VARCHAR(1024) NOT NULL,
@@ -197,7 +215,36 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS player_onboarding_devices ( CREATE TABLE IF NOT EXISTS i_schedule_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
short_description VARCHAR(255) 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
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS i_schedule_entries (
id INT AUTO_INCREMENT PRIMARY KEY,
schedule_group_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
short_description VARCHAR(255) NULL,
start_datetime DATETIME NOT NULL,
end_datetime DATETIME 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,
CONSTRAINT fk_schedule_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_schedule_groups(id) ON DELETE CASCADE,
INDEX idx_schedule_entries_group_start (schedule_group_id, start_datetime)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS d_onboarding_devices (
device_id VARCHAR(128) PRIMARY KEY, device_id VARCHAR(128) PRIMARY KEY,
client_name VARCHAR(255) NULL, client_name VARCHAR(255) NULL,
screen_id INT NULL, screen_id INT NULL,
@@ -205,12 +252,12 @@ async function ensureSchema(pool, options) {
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL, modified_by INT NULL,
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS a_users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NULL, name VARCHAR(255) NULL,
username VARCHAR(255) NOT NULL UNIQUE, username VARCHAR(255) NOT NULL UNIQUE,
@@ -225,7 +272,7 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS roles ( CREATE TABLE IF NOT EXISTS a_roles (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
role_key VARCHAR(100) NOT NULL UNIQUE, role_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -238,7 +285,7 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS permissions ( CREATE TABLE IF NOT EXISTS a_permissions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
permission_key VARCHAR(100) NOT NULL UNIQUE, permission_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -251,17 +298,8 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
for (const permission of PERMISSIONS) {
await pool.query(
`INSERT INTO permissions (permission_key, name, section_name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
);
}
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS role_permissions ( CREATE TABLE IF NOT EXISTS a_role_permissions (
role_id INT NOT NULL, role_id INT NOT NULL,
permission_id INT NOT NULL, permission_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -269,13 +307,13 @@ async function ensureSchema(pool, options) {
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL, modified_by INT NULL,
PRIMARY KEY (role_id, permission_id), PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES a_permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS user_roles ( CREATE TABLE IF NOT EXISTS a_user_roles (
user_id INT NOT NULL, user_id INT NOT NULL,
role_id INT NOT NULL, role_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -283,13 +321,13 @@ async function ensureSchema(pool, options) {
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL, modified_by INT NULL,
PRIMARY KEY (user_id, role_id), PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS auth_sessions ( CREATE TABLE IF NOT EXISTS a_sessions (
session_hash CHAR(64) PRIMARY KEY, session_hash CHAR(64) PRIMARY KEY,
user_id INT NOT NULL, user_id INT NOT NULL,
expires_at DATETIME NOT NULL, expires_at DATETIME NOT NULL,
@@ -297,12 +335,12 @@ async function ensureSchema(pool, options) {
created_by INT NULL, created_by INT NULL,
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL, modified_by INT NULL,
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS background_tasks ( CREATE TABLE IF NOT EXISTS o_background_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_key VARCHAR(191) NULL, task_key VARCHAR(191) NULL,
task_type VARCHAR(100) NOT NULL, task_type VARCHAR(100) NOT NULL,
@@ -322,60 +360,10 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users'); const { runMigrations } = require('./migrations');
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin'; await runMigrations(pool, options);
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
const passwordRecord = hashPassword(password);
await pool.query(
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
);
}
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
await pool.query(
`INSERT INTO roles (role_key, name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
);
await migrations.runMigrations(pool, options || {});
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by)
SELECT id, ?, NULL, NULL FROM users`,
[defaultRoleId]
);
}
}
const [defaultAdminRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
if (defaultAdminRows.length) {
const defaultAdminId = Number(defaultAdminRows[0].id);
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM user_roles WHERE user_id = ?', [defaultAdminId]);
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
'INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
[defaultAdminId, defaultRoleId, null, null]
);
}
}
}
} }
module.exports = { module.exports = {
createPool,
ensureSchema, ensureSchema,
pruneStaleOnboardingDevices
}; };
+438 -811
View File
File diff suppressed because it is too large Load Diff
+19 -5
View File
@@ -9,14 +9,17 @@ const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding'); const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
const { createOnboardingStore } = require('./player/onboarding/store'); const { createOnboardingStore } = require('./player/onboarding/store');
const { registerPlayerRoutes } = require('./player/routes'); const { registerPlayerRoutes } = require('./player/routes');
const { pruneStaleOnboardingDevices } = require('./db'); const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
// Player runtime, media API, and websocket wiring. // Player runtime, media API, and websocket wiring.
async function start() { async function start() {
const app = express(); const app = express();
const pool = common.createPool(); const pool = common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 3001); 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 ASSET_DIR = path.join(__dirname, 'player', 'public');
const MEDIA_DIR = path.join(__dirname, '..', 'media'); const MEDIA_DIR = path.join(__dirname, '..', 'media');
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json'); const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
@@ -42,7 +45,8 @@ async function start() {
common: common, common: common,
playerRuntime: playerRuntime, playerRuntime: playerRuntime,
onboardingStore: onboardingStore, onboardingStore: onboardingStore,
QRCode: require('qrcode') playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
}); });
registerPlayerRoutes(app, { registerPlayerRoutes(app, {
pool: pool, pool: pool,
@@ -51,7 +55,10 @@ async function start() {
assetDir: ASSET_DIR, assetDir: ASSET_DIR,
playerRuntime: playerRuntime, playerRuntime: playerRuntime,
playerPlaylistService: playerPlaylistService, 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) { app.use(function (error, _req, res, _next) {
@@ -60,6 +67,7 @@ async function start() {
}); });
fs.mkdirSync(MEDIA_DIR, { recursive: true }); fs.mkdirSync(MEDIA_DIR, { recursive: true });
await ensureFontLibrary(MEDIA_DIR);
server.listen(PORT, function () { server.listen(PORT, function () {
console.log(`Pulse Signage app listening on port ${PORT}`); console.log(`Pulse Signage app listening on port ${PORT}`);
@@ -70,7 +78,7 @@ async function start() {
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR }); await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
if (playerRuntime.snapshotAllConnections().length > 0) { if (playerRuntime.snapshotAllConnections().length > 0) {
await pruneStaleOnboardingDevices(pool); await common.pruneStaleOnboardingDevices(pool);
} }
await onboardingStore.flushBindings(function (entry) { await onboardingStore.flushBindings(function (entry) {
@@ -89,6 +97,12 @@ async function start() {
} }
await syncDatabaseState(); 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 () { setInterval(function () {
syncDatabaseState().catch(function (error) { syncDatabaseState().catch(function (error) {
console.error(error); console.error(error);
@@ -0,0 +1,55 @@
<style>
.player-announcement--fullscreen {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
overflow: hidden;
color: {{FOREGROUND}};
background: {{ACCENT}};
--announcement-canvas-min: min(var(--player-canvas-width, 100vw), var(--player-canvas-height, 100vh));
}
.player-announcement--fullscreen .player-announcement__body {
width: 100%;
height: 100%;
display: grid;
place-items: center;
padding: clamp(36px, calc(var(--announcement-canvas-min) * 0.06), 96px);
box-sizing: border-box;
}
.player-announcement--fullscreen .player-announcement__stack {
display: grid;
justify-items: center;
align-content: center;
gap: clamp(12px, calc(var(--announcement-canvas-min) * 0.015), 24px);
text-align: center;
}
.player-announcement--fullscreen .player-announcement__icon {
width: clamp(6rem, calc(var(--announcement-canvas-min) * 0.16), 12rem);
height: clamp(6rem, calc(var(--announcement-canvas-min) * 0.16), 12rem);
display: grid;
place-items: center;
font-size: clamp(4rem, calc(var(--announcement-canvas-min) * 0.16), 10rem);
justify-content: center;
}
.player-announcement--fullscreen .player-announcement__message {
width: min(100%, calc(var(--announcement-canvas-min) * 1.35));
text-align: center;
font-size: clamp(2rem, calc(var(--announcement-canvas-min) * 0.065), 8rem);
font-weight: 900;
line-height: 1.08;
}
</style>
<section class="player-announcement player-announcement--fullscreen player-announcement--color-{{COLOR_KEY}} shadow-lg" style="--announcement-accent: {{ACCENT}}; --announcement-foreground: {{FOREGROUND}}; --announcement-glow: {{GLOW}};">
<div class="player-announcement__body">
<div class="player-announcement__stack">
<div class="player-announcement__icon player-announcement__icon--fullscreen" aria-hidden="true"><i class="bi bi-{{ICON}}"></i></div>
<div class="player-announcement__message">{{MESSAGE}}</div>
</div>
</div>
</section>
@@ -0,0 +1,186 @@
<style>
.player-announcement--lower-third {
position: relative;
width: 100%;
height: auto;
min-height: clamp(104px, calc(var(--announcement-canvas-min) * 0.098), 220px);
display: flex;
align-self: flex-end;
align-items: stretch;
overflow: hidden;
color: var(--announcement-foreground, #fff);
background: var(--announcement-accent, #0d6efd);
--announcement-canvas-min: min(var(--player-canvas-width), var(--player-canvas-height));
}
.player-announcement--lower-third .player-announcement__body {
position: relative;
width: 100%;
height: auto;
min-height: 0;
display: flex;
align-items: stretch;
overflow: hidden;
border-radius: inherit;
}
.player-announcement--lower-third .player-announcement__icon {
flex: 0 0 clamp(88px, calc(var(--announcement-canvas-min) * 0.08), 128px);
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
padding: clamp(8px, calc(var(--announcement-canvas-min) * 0.01), 14px);
box-sizing: border-box;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(0, 0, 0, 0.12)),
var(--announcement-accent, #0d6efd);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10) inset;
}
.player-announcement--lower-third .player-announcement__icon i {
font-size: clamp(2.2rem, calc(var(--announcement-canvas-min) * 0.032), 3rem);
color: rgba(255, 255, 255, 0.92);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.player-announcement--lower-third .player-announcement__content {
position: relative;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(6px, calc(var(--announcement-canvas-min) * 0.008), 12px) 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.03)),
linear-gradient(90deg, rgba(255, 255, 255, 0.06), transparent 20%),
var(--announcement-accent, #0d6efd);
box-shadow:
inset 1px 0 0 rgba(255, 255, 255, 0.10),
0 0 0 1px rgba(255, 255, 255, 0.12) inset;
}
.player-announcement--lower-third .player-announcement__content::after {
content: '';
position: absolute;
inset: auto 0 0 0;
height: 3px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.68), transparent);
opacity: 0.75;
}
.player-announcement--lower-third .player-announcement__kicker {
position: relative;
z-index: 1;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.78);
font-size: clamp(0.8rem, calc(var(--announcement-canvas-min) * 0.009), 1rem);
font-weight: 900;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.player-announcement--lower-third .player-announcement__message {
position: relative;
z-index: 1;
width: 100%;
color: #fff;
font-size: clamp(2.5rem, calc(var(--announcement-canvas-min) * 0.028), 3.4rem);
font-weight: 700;
line-height: 1.05;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
overflow: hidden;
white-space: nowrap;
}
.player-announcement--lower-third .player-announcement__message-track {
display: inline-flex;
align-items: center;
gap: 2.5rem;
width: max-content;
backface-visibility: hidden;
will-change: transform;
animation: lower-third-scroll var(--announcement-scroll-duration, 20s) linear infinite;
}
.player-announcement--lower-third .player-announcement__message-text {
display: inline-block;
flex: 0 0 auto;
}
@media (orientation: portrait) {
.player-announcement--lower-third {
min-height: clamp(76px, calc(var(--announcement-canvas-min) * 0.068), 142px);
}
.player-announcement--lower-third .player-announcement__icon {
flex-basis: clamp(56px, calc(var(--announcement-canvas-min) * 0.052), 84px);
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px);
}
.player-announcement--lower-third .player-announcement__icon i {
font-size: clamp(1.6rem, calc(var(--announcement-canvas-min) * 0.022), 2.3rem);
}
.player-announcement--lower-third .player-announcement__content {
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px) 0;
}
.player-announcement--lower-third .player-announcement__kicker {
margin-bottom: 2px;
font-size: clamp(0.68rem, calc(var(--announcement-canvas-min) * 0.007), 0.86rem);
}
.player-announcement--lower-third .player-announcement__message {
font-size: clamp(1.7rem, calc(var(--announcement-canvas-min) * 0.021), 2.35rem);
}
.player-announcement--lower-third .player-announcement__message-track {
gap: 1.25rem;
}
}
@keyframes lower-third-scroll {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(calc(-1 * var(--announcement-scroll-distance, 0px)), 0, 0);
}
}
</style>
<section class="player-announcement player-announcement--lower-third player-announcement--color-{{COLOR_KEY}} shadow-lg" style="--announcement-accent: {{ACCENT}}; --announcement-foreground: {{FOREGROUND}}; --announcement-glow: {{GLOW}};">
<div class="player-announcement__body">
<div class="player-announcement__icon" aria-hidden="true">
<i class="bi bi-{{ICON}}"></i>
</div>
<div class="player-announcement__content">
<div class="player-announcement__message">
<span class="player-announcement__message-track" data-announcement-marquee-rate="88">
<span class="player-announcement__message-text">{{MESSAGE}}</span>
<span class="player-announcement__message-text" aria-hidden="true">{{MESSAGE}}</span>
</span>
</div>
</div>
</div>
</section>
<script>
(function () {
var currentScript = document.currentScript;
var root = currentScript && currentScript.previousElementSibling && currentScript.previousElementSibling.classList && currentScript.previousElementSibling.classList.contains('player-announcement--lower-third')
? currentScript.previousElementSibling
: null;
if (!root) {
return;
}
var track = root.querySelector('.player-announcement__message-track');
if (!track || typeof window.initPlayerAnnouncementMarquee !== 'function') {
return;
}
window.initPlayerAnnouncementMarquee(track, { container: root.querySelector('.player-announcement__message') || root });
}());
</script>
@@ -0,0 +1,188 @@
<style>
.player-announcement--top-banner {
position: absolute;
inset: 0 0 auto 0;
width: 100%;
height: auto;
min-height: clamp(104px, calc(var(--announcement-canvas-min) * 0.098), 220px);
display: flex;
align-self: flex-start;
align-items: stretch;
overflow: hidden;
color: var(--announcement-foreground, #fff);
background: var(--announcement-accent, #0d6efd);
--announcement-canvas-min: min(var(--player-canvas-width), var(--player-canvas-height));
z-index: 1;
}
.player-announcement--top-banner .player-announcement__body {
position: relative;
width: 100%;
height: auto;
min-height: 0;
display: flex;
align-items: stretch;
overflow: hidden;
border-radius: inherit;
}
.player-announcement--top-banner .player-announcement__icon {
flex: 0 0 clamp(88px, calc(var(--announcement-canvas-min) * 0.08), 128px);
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
padding: clamp(8px, calc(var(--announcement-canvas-min) * 0.01), 14px);
box-sizing: border-box;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(0, 0, 0, 0.12)),
var(--announcement-accent, #0d6efd);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10) inset;
}
.player-announcement--top-banner .player-announcement__icon i {
font-size: clamp(2.2rem, calc(var(--announcement-canvas-min) * 0.032), 3rem);
color: rgba(255, 255, 255, 0.92);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.player-announcement--top-banner .player-announcement__content {
position: relative;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(6px, calc(var(--announcement-canvas-min) * 0.008), 12px) 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.03)),
linear-gradient(90deg, rgba(255, 255, 255, 0.06), transparent 20%),
var(--announcement-accent, #0d6efd);
box-shadow:
inset 1px 0 0 rgba(255, 255, 255, 0.10),
0 0 0 1px rgba(255, 255, 255, 0.12) inset;
}
.player-announcement--top-banner .player-announcement__content::after {
content: '';
position: absolute;
inset: auto 0 0 0;
height: 3px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.68), transparent);
opacity: 0.75;
}
.player-announcement--top-banner .player-announcement__kicker {
position: relative;
z-index: 1;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.78);
font-size: clamp(0.8rem, calc(var(--announcement-canvas-min) * 0.009), 1rem);
font-weight: 900;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.player-announcement--top-banner .player-announcement__message {
position: relative;
z-index: 1;
width: 100%;
color: #fff;
font-size: clamp(2.5rem, calc(var(--announcement-canvas-min) * 0.028), 3.4rem);
font-weight: 700;
line-height: 1.05;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
overflow: hidden;
white-space: nowrap;
}
.player-announcement--top-banner .player-announcement__message-track {
display: inline-flex;
align-items: center;
gap: 2.5rem;
width: max-content;
backface-visibility: hidden;
will-change: transform;
animation: top-banner-scroll var(--announcement-scroll-duration, 20s) linear infinite;
}
.player-announcement--top-banner .player-announcement__message-text {
display: inline-block;
flex: 0 0 auto;
}
@media (orientation: portrait) {
.player-announcement--top-banner {
min-height: clamp(76px, calc(var(--announcement-canvas-min) * 0.068), 142px);
}
.player-announcement--top-banner .player-announcement__icon {
flex-basis: clamp(56px, calc(var(--announcement-canvas-min) * 0.052), 84px);
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px);
}
.player-announcement--top-banner .player-announcement__icon i {
font-size: clamp(1.6rem, calc(var(--announcement-canvas-min) * 0.022), 2.3rem);
}
.player-announcement--top-banner .player-announcement__content {
padding: clamp(4px, calc(var(--announcement-canvas-min) * 0.005), 7px) 0;
}
.player-announcement--top-banner .player-announcement__kicker {
margin-bottom: 2px;
font-size: clamp(0.68rem, calc(var(--announcement-canvas-min) * 0.007), 0.86rem);
}
.player-announcement--top-banner .player-announcement__message {
font-size: clamp(1.7rem, calc(var(--announcement-canvas-min) * 0.021), 2.35rem);
}
.player-announcement--top-banner .player-announcement__message-track {
gap: 1.25rem;
}
}
@keyframes top-banner-scroll {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(calc(-1 * var(--announcement-scroll-distance, 0px)), 0, 0);
}
}
</style>
<section class="player-announcement player-announcement--top-banner player-announcement--color-{{COLOR_KEY}} shadow-lg" style="--announcement-accent: {{ACCENT}}; --announcement-foreground: {{FOREGROUND}}; --announcement-glow: {{GLOW}};">
<div class="player-announcement__body">
<div class="player-announcement__icon" aria-hidden="true">
<i class="bi bi-{{ICON}}"></i>
</div>
<div class="player-announcement__content">
<div class="player-announcement__message">
<span class="player-announcement__message-track" data-announcement-marquee-rate="88">
<span class="player-announcement__message-text">{{MESSAGE}}</span>
<span class="player-announcement__message-text" aria-hidden="true">{{MESSAGE}}</span>
</span>
</div>
</div>
</div>
</section>
<script>
(function () {
var currentScript = document.currentScript;
var root = currentScript && currentScript.previousElementSibling && currentScript.previousElementSibling.classList && currentScript.previousElementSibling.classList.contains('player-announcement--top-banner')
? currentScript.previousElementSibling
: null;
if (!root) {
return;
}
var track = root.querySelector('.player-announcement__message-track');
if (!track || typeof window.initPlayerAnnouncementMarquee !== 'function') {
return;
}
window.initPlayerAnnouncementMarquee(track, { container: root.querySelector('.player-announcement__message') || root });
}());
</script>
+2
View File
@@ -1,3 +1,5 @@
// RTMP stream session management and ffmpeg probe orchestration.
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
+91 -15
View File
@@ -1,5 +1,8 @@
const { isClientNameAvailable, withClientNameReservation } = require('../../data/client-name-check'); // Player onboarding routes and signup flow helpers.
const { getSharedSecret, verifyPageAuthToken } = require('../../request-auth');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth');
const { isTransientDbError } = require('./store'); const { isTransientDbError } = require('./store');
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000; const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
@@ -10,8 +13,8 @@ function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128); return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
} }
function getPublicBaseUrl(req) { function getPublicBaseUrl(req, configuredUrl) {
const configured = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
if (configured) { if (configured) {
return configured; return configured;
} }
@@ -40,6 +43,15 @@ function getOnboardingLimitKey(req, deviceId) {
return [getRequestIp(req), normalizeDeviceId(deviceId) || 'anonymous'].join('|'); 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() { function clearOnboardingSignupAttempts() {
if (onboardingSignupAttempts.size > 1000) { if (onboardingSignupAttempts.size > 1000) {
onboardingSignupAttempts.clear(); onboardingSignupAttempts.clear();
@@ -74,8 +86,8 @@ async function getOnboardingStatus(pool, deviceId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id `SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id
FROM player_onboarding_devices d FROM d_onboarding_devices d
LEFT JOIN screens s ON s.id = d.screen_id LEFT JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?`, WHERE d.device_id = ?`,
[normalizedDeviceId] [normalizedDeviceId]
); );
@@ -99,7 +111,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
} }
return withClientNameReservation(pool, normalizedClientName, async function () { return withClientNameReservation(pool, normalizedClientName, async function () {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]); const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [normalizedScreenSlug]);
if (!screenRows.length) { if (!screenRows.length) {
throw new Error('Screen not found.'); throw new Error('Screen not found.');
} }
@@ -113,7 +125,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
} }
await pool.query( await pool.query(
'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP', 'INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
[normalizedDeviceId, normalizedClientName, screen.id] [normalizedDeviceId, normalizedClientName, screen.id]
); );
@@ -121,6 +133,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) { async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function' const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function'
? playerRuntime.snapshotAllConnections() ? playerRuntime.snapshotAllConnections()
@@ -155,11 +223,10 @@ function registerPlayerOnboardingRoutes(app, options) {
const pool = options && options.pool ? options.pool : null; const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null; const common = options && options.common ? options.common : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const QRCode = options && options.QRCode ? options.QRCode : null;
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null; const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
if (!app || !pool || !common || !playerRuntime || !QRCode) { if (!app || !pool || !common || !playerRuntime) {
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.'); throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, and playerRuntime.');
} }
const sharedSecret = getSharedSecret(); const sharedSecret = getSharedSecret();
@@ -184,9 +251,13 @@ function registerPlayerOnboardingRoutes(app, options) {
res.send(common.renderPlayerOnboardingLandingPage()); 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.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
try {
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim())); res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
} catch (error) {
next(error);
}
}); });
app.get('/api/onboarding/status', function (req, res, next) { app.get('/api/onboarding/status', function (req, res, next) {
@@ -218,7 +289,7 @@ function registerPlayerOnboardingRoutes(app, options) {
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) { app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try { try {
const [rows] = await pool.query('SELECT id, name, slug FROM screens ORDER BY name ASC, id ASC'); const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
res.json({ screens: rows }); res.json({ screens: rows });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -231,8 +302,8 @@ function registerPlayerOnboardingRoutes(app, options) {
if (!deviceId) { if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' }); 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' }); const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
res.set('Content-Type', 'image/svg+xml; charset=utf-8'); res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
res.send(svg); res.send(svg);
@@ -262,6 +333,7 @@ function registerPlayerOnboardingRoutes(app, options) {
} }
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore); const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
await bindPlayerToScreen(pool, deviceId, screenSlug);
res.json({ res.json({
deviceId: deviceId, deviceId: deviceId,
clientName: status ? status.client_name : clientName, clientName: status ? status.client_name : clientName,
@@ -280,8 +352,12 @@ function registerPlayerOnboardingRoutes(app, options) {
module.exports = { module.exports = {
normalizeDeviceId: normalizeDeviceId, normalizeDeviceId: normalizeDeviceId,
getPublicBaseUrl: getPublicBaseUrl, getPublicBaseUrl: getPublicBaseUrl,
getPlayerPublicBaseUrl: getPlayerPublicBaseUrl,
getPlayerInternalBaseUrl: getPlayerInternalBaseUrl,
getOnboardingStatus: getOnboardingStatus, getOnboardingStatus: getOnboardingStatus,
commitDeviceBinding: commitDeviceBinding, commitDeviceBinding: commitDeviceBinding,
upsertPlayerRegistration: upsertPlayerRegistration,
bindPlayerToScreen: bindPlayerToScreen,
bindDeviceToScreen: bindDeviceToScreen, bindDeviceToScreen: bindDeviceToScreen,
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
}; };
+2
View File
@@ -1,3 +1,5 @@
// File-backed storage for onboarding device mappings.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
+1 -1
View File
@@ -58,7 +58,7 @@
// ignore storage errors // ignore storage errors
} }
if (socket && socket.readyState === WebSocket.OPEN) { if (socket && socket.readyState === WebSocket.OPEN) {
sendCommandHello(socket); sendCommandState(socket);
} }
} }
+260
View File
@@ -0,0 +1,260 @@
// Announcement overlay polling and rendering helpers.
(function () {
if (window.__pulseThumbnailPreview) {
return;
}
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 socket = new WebSocket(new URL(announcementSocketPath, window.location.origin).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();
});
})();
+37 -2
View File
@@ -1,7 +1,41 @@
<script> <script>
const slug = {{SLUG_JSON}}; const slug = {{SLUG_JSON}};
const initialData = {{INITIAL_DATA_JSON}}; const initialData = {{INITIAL_DATA_JSON}};
window.slug = slug;
window.initialData = initialData;
const app = document.getElementById('app'); const app = document.getElementById('app');
(function () {
var root = window;
var registry = root.pulsePlayerRegionTypes && typeof root.pulsePlayerRegionTypes === 'object' ? root.pulsePlayerRegionTypes : {};
function normalizeType(type) {
return String(type || '').trim().toLowerCase();
}
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] || {}
};
});
}
root.pulsePlayerRegionTypes = {
register: register,
get: get,
list: list
};
}());
let slides = Array.isArray(initialData && initialData.slides) ? initialData.slides.map(normalizeSlide) : []; let slides = Array.isArray(initialData && initialData.slides) ? initialData.slides.map(normalizeSlide) : [];
let currentPlaylistSignature = ''; let currentPlaylistSignature = '';
let currentPlaylistEtag = ''; let currentPlaylistEtag = '';
@@ -13,6 +47,7 @@
let slideMarkupCache = Object.create(null); let slideMarkupCache = Object.create(null);
let templateLayoutCache = Object.create(null); let templateLayoutCache = Object.create(null);
let templateRenderPlanCache = Object.create(null); let templateRenderPlanCache = Object.create(null);
let videoRegionRenderVersion = 0;
let renderCacheViewportKey = ''; let renderCacheViewportKey = '';
let index = 0; let index = 0;
let timer = null; let timer = null;
@@ -71,13 +106,13 @@
} }
// Announce the player to the command websocket. // Announce the player to the command websocket.
function sendCommandHello(socket) { function sendPlayerBootstrapState(socket) {
if (!socket || socket.readyState !== WebSocket.OPEN) { if (!socket || socket.readyState !== WebSocket.OPEN) {
return; return;
} }
var clientName = getOnboardingClientName(); var clientName = getOnboardingClientName();
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'hello', type: 'state',
clientId: getCommandClientId(), clientId: getCommandClientId(),
clientName: clientName || null, clientName: clientName || null,
deviceId: getOnboardingDeviceId() || null, deviceId: getOnboardingDeviceId() || null,
+4 -1
View File
@@ -5,7 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{TITLE}}</title> <title>{{TITLE}}</title>
<link rel="icon" type="image/png" href="/assets/favicon.png" /> <link rel="icon" type="image/png" href="/assets/favicon.png" />
<link rel="stylesheet" href="/assets/css/player.css" /> <link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css" />
<link rel="stylesheet" href="/assets/css/player.css?v=36" />
{{{STYLESHEETS}}}
</head> </head>
<body class="{{BODY_CLASS}}"> <body class="{{BODY_CLASS}}">
{{{BODY}}} {{{BODY}}}
+130 -39
View File
@@ -1,6 +1,9 @@
// Player playlist assembly, snapshot persistence, and playlist revision helpers.
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { createStyledQrCodeDataUrl } = require('../data/qr-code');
function createPlayerPlaylistService(options) { function createPlayerPlaylistService(options) {
const pool = options && options.pool ? options.pool : null; const pool = options && options.pool ? options.pool : null;
@@ -54,11 +57,15 @@ function createPlayerPlaylistService(options) {
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8'); await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
} }
async function createQrPreviewDataUrl(value) {
return createStyledQrCodeDataUrl(value);
}
async function buildScreenPlaylist(slug) { async function buildScreenPlaylist(slug) {
try { try {
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]); const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
if (!screenRows.length) { if (!screenRows.length) {
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [] }; return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [] };
} }
const screen = screenRows[0]; const screen = screenRows[0];
@@ -67,25 +74,43 @@ function createPlayerPlaylistService(options) {
screen: screen, screen: screen,
playlist: null, playlist: null,
slides: [], slides: [],
revision: getPlaylistRevision(screen, null, [], [], [], [], []) rssFeeds: [],
apiSources: [],
timetableGroups: [],
revision: getPlaylistRevision(screen, null, [], [], [], [], [], [])
}; };
await writeSnapshot(slug, payloadWithoutPlaylist); await writeSnapshot(slug, payloadWithoutPlaylist);
return payloadWithoutPlaylist; return payloadWithoutPlaylist;
} }
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]); const [playlistRows] = 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 = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null; const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(` const [slideRows] = await pool.query(`
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at, SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at,
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.disable_audio,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps FROM c_playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ? WHERE ps.playlist_id = ?
ORDER BY ps.position ASC, ps.id ASC ORDER BY ps.position ASC, ps.id ASC
`, [screen.playlist_id]); `, [screen.playlist_id]);
const [scheduleRuleRows] = await pool.query(`
SELECT r.id, r.playlist_slide_id, r.position, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json, r.created_at, r.modified_at, r.created_by, r.modified_by
FROM c_playlist_slide_schedule_rules r
JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
WHERE ps.playlist_id = ?
ORDER BY r.playlist_slide_id ASC, r.position ASC, r.id ASC
`, [screen.playlist_id]);
const rulesBySlideId = {};
scheduleRuleRows.forEach(function (rule) {
const slideId = Number(rule.playlist_slide_id);
if (!rulesBySlideId[slideId]) {
rulesBySlideId[slideId] = [];
}
rulesBySlideId[slideId].push(rule);
});
const templateIds = slideRows const templateIds = slideRows
.filter(function (slide) { return slide.template_id; }) .filter(function (slide) { return slide.template_id; })
@@ -97,37 +122,92 @@ function createPlayerPlaylistService(options) {
[templateRows] = await pool.query(` [templateRows] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM slide_templates st FROM c_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id IN (?) WHERE st.id IN (?)
`, [templateIds]); `, [templateIds]);
[regionRows] = 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 slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]); [regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
templateRows.forEach(function (template) { templateRows.forEach(function (template) {
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; }); template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
templatesById[template.id] = template; templatesById[template.id] = template;
}); });
} }
const slides = slideRows.map(function (slide) { function getVideoRegionDurationSeconds(contentJson) {
if (!contentJson) {
return null;
}
try {
const parsed = common.parseJsonSafe(contentJson) || {};
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
}
}
const slides = await Promise.all(slideRows.map(async function (slide) {
const storedDuration = Number(slide.duration_seconds || 0);
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
const disableAudio = slide.disable_audio === undefined || slide.disable_audio === null ? true : Boolean(slide.disable_audio);
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
const content = common.parseJsonSafe(slide.content_json) || {};
const qrBackfillTasks = [];
Object.keys(content).forEach(function (key) {
const region = content[key];
if (region && typeof region === 'object') {
const regionType = String(region.type || '').trim().toLowerCase();
if (regionType === 'video' || regionType === 'rtmp') {
region.disable_audio = disableAudio;
}
}
if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
region.cache_bust = videoCacheBust;
}
});
Object.keys(content).forEach(function (key) {
const region = content[key];
if (!region || typeof region !== 'object' || String(region.type || '').trim().toLowerCase() !== 'qr-code') {
return;
}
if (region.qr_preview && /^data:image\//i.test(String(region.qr_preview || '').trim())) {
return;
}
qrBackfillTasks.push(createQrPreviewDataUrl(region.value).then(function (preview) {
if (preview) {
region.qr_preview = preview;
}
return null;
}).catch(function () {
return null;
}));
});
await Promise.all(qrBackfillTasks);
return { return {
id: slide.id, id: slide.id,
title: slide.title, title: slide.title,
body: slide.body, modified_at: slide.modified_at,
duration_seconds: slide.duration_seconds, duration_seconds: videoDuration || storedDuration,
schedule_mode: slide.schedule_mode, use_video_duration: Boolean(slide.use_video_duration),
schedule_start_datetime: slide.schedule_start_datetime, disable_audio: disableAudio,
schedule_end_datetime: slide.schedule_end_datetime, scheduleRules: rulesBySlideId[Number(slide.id)] || [],
schedule_start_time: slide.schedule_start_time,
schedule_end_time: slide.schedule_end_time,
schedule_days_json: slide.schedule_days_json,
media_url: slide.media_path,
media_type: slide.media_type,
kind: common.mediaKind(slide.media_path),
template_id: slide.template_id, template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null, template: slide.template_id ? templatesById[slide.template_id] || null : null,
content: common.parseJsonSafe(slide.content_json) || {} content: content
}; };
}); }));
let rssFeeds = []; let rssFeeds = [];
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') { if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
@@ -152,8 +232,14 @@ function createPlayerPlaylistService(options) {
}); });
} }
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources); let timetableGroups = [];
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, revision: revision }; if (typeof common.fetchTimetablesData === 'function') {
const timetableData = await common.fetchTimetablesData(pool);
timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
}
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision };
await writeSnapshot(slug, payload); await writeSnapshot(slug, payload);
return payload; return payload;
} catch (error) { } catch (error) {
@@ -170,7 +256,7 @@ function createPlayerPlaylistService(options) {
hash.update('\0'); hash.update('\0');
} }
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources) { function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups) {
const hash = crypto.createHash('sha1'); const hash = crypto.createHash('sha1');
updatePlaylistRevisionHash(hash, screen && screen.id); updatePlaylistRevisionHash(hash, screen && screen.id);
@@ -185,20 +271,24 @@ function createPlayerPlaylistService(options) {
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) { (Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
updatePlaylistRevisionHash(hash, slide.id); updatePlaylistRevisionHash(hash, slide.id);
updatePlaylistRevisionHash(hash, slide.title); updatePlaylistRevisionHash(hash, slide.title);
updatePlaylistRevisionHash(hash, slide.body);
updatePlaylistRevisionHash(hash, slide.template_id); updatePlaylistRevisionHash(hash, slide.template_id);
updatePlaylistRevisionHash(hash, slide.content_json); updatePlaylistRevisionHash(hash, slide.content_json);
updatePlaylistRevisionHash(hash, slide.media_path);
updatePlaylistRevisionHash(hash, slide.media_type);
updatePlaylistRevisionHash(hash, slide.modified_at); updatePlaylistRevisionHash(hash, slide.modified_at);
updatePlaylistRevisionHash(hash, slide.position); updatePlaylistRevisionHash(hash, slide.position);
updatePlaylistRevisionHash(hash, slide.duration_seconds); updatePlaylistRevisionHash(hash, slide.duration_seconds);
updatePlaylistRevisionHash(hash, slide.schedule_mode); updatePlaylistRevisionHash(hash, slide.use_video_duration);
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime); updatePlaylistRevisionHash(hash, slide.disable_audio);
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime); });
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
updatePlaylistRevisionHash(hash, slide.schedule_end_time); (Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) {
updatePlaylistRevisionHash(hash, slide.schedule_days_json); updatePlaylistRevisionHash(hash, rule.id);
updatePlaylistRevisionHash(hash, rule.playlist_slide_id);
updatePlaylistRevisionHash(hash, rule.position);
updatePlaylistRevisionHash(hash, rule.start_datetime);
updatePlaylistRevisionHash(hash, rule.end_datetime);
updatePlaylistRevisionHash(hash, rule.start_time);
updatePlaylistRevisionHash(hash, rule.end_time);
updatePlaylistRevisionHash(hash, rule.schedule_days_json);
}); });
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) { (Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
@@ -229,6 +319,7 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || [])); updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || [])); updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || []));
return hash.digest('hex'); return hash.digest('hex');
} }
+103 -41
View File
@@ -22,6 +22,21 @@ body.onboarding-page #app {
display: none; display: none;
} }
body.thumbnail-preview *,
body.thumbnail-preview *::before,
body.thumbnail-preview *::after {
animation: none !important;
animation-delay: 0s !important;
animation-duration: 0s !important;
animation-iteration-count: 1 !important;
transition: none !important;
}
body.thumbnail-preview .player-announcement-layer,
body.thumbnail-preview .player-offline-banner {
display: none !important;
}
#app { #app {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -101,6 +116,8 @@ body.onboarding-page #app {
display: block; display: block;
background: #fff; background: #fff;
border-radius: 18px; border-radius: 18px;
padding: 16px;
box-sizing: border-box;
} }
.onboarding-form { .onboarding-form {
@@ -199,6 +216,8 @@ body.onboarding-page #app {
height: 100%; height: 100%;
opacity: 0; opacity: 0;
transition: opacity 560ms ease; transition: opacity 560ms ease;
will-change: opacity;
backface-visibility: hidden;
} }
.slide-shell.is-visible { .slide-shell.is-visible {
@@ -220,6 +239,31 @@ body.screen-blackout #app {
opacity: 0; opacity: 0;
} }
.player-announcement-layer {
position: fixed;
left: 50%;
top: 50%;
width: var(--player-canvas-width, 100vw);
height: var(--player-canvas-height, 100vh);
max-width: 100vw;
max-height: 100vh;
transform: translate3d(-50%, -50%, 0);
backface-visibility: hidden;
z-index: 9999;
pointer-events: none;
display: flex;
opacity: 0;
transition: opacity 220ms ease;
}
.player-announcement-layer.is-visible {
opacity: 1;
}
.player-announcement-layer.is-closing {
opacity: 0;
}
.slide { .slide {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -312,7 +356,9 @@ body.screen-blackout #app {
box-sizing: border-box; box-sizing: border-box;
} }
.template-region.text { .template-region.text,
.template-region.api,
.template-region.rss {
color: #fff; color: #fff;
display: block; display: block;
padding: 0; padding: 0;
@@ -322,38 +368,39 @@ body.screen-blackout #app {
line-height: 1.35; line-height: 1.35;
} }
.template-region.text .template-region-text-scale { .template-region .template-region-text-scale {
display: block; display: block;
transform-origin: top left; transform-origin: top left;
overflow: hidden;
} }
.template-region.text .template-region-text-scale > * { .template-region .template-region-text-scale > * {
margin: 0; margin: 1em 0;
} }
.template-region.text .template-region-text-scale > * + * { .template-region .template-region-text-scale > *:first-child {
margin-top: 0.5em; margin-top: 0;
} }
.template-region.text .template-region-text-scale ul, .template-region .template-region-text-scale > *:last-child {
.template-region.text .template-region-text-scale ol { margin-bottom: 1em;
}
.template-region .template-region-text-scale ul,
.template-region .template-region-text-scale ol {
padding-left: 1.2em; padding-left: 1.2em;
} }
.template-region.text .template-region-text-scale code { .template-region .template-region-text-scale code {
white-space: pre-wrap; white-space: pre-wrap;
} }
.template-region.text > * {
margin: 0;
}
.template-region.text > * + * {
margin-top: 0.5em;
}
.template-region.text ul, .template-region.text ul,
.template-region.text ol { .template-region.text ol,
.template-region.api ul,
.template-region.api ol,
.template-region.rss ul,
.template-region.rss ol {
padding-left: 1.2em; padding-left: 1.2em;
} }
@@ -364,6 +411,19 @@ body.screen-blackout #app {
display: block; display: block;
} }
.template-region.video {
background: transparent;
}
.template-region.video video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
background: transparent;
}
.template-region.webpage iframe { .template-region.webpage iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -373,6 +433,14 @@ body.screen-blackout #app {
overflow: hidden; overflow: hidden;
} }
.template-region.qr-code img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
background: #fff;
}
.template-region.html iframe { .template-region.html iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -411,29 +479,6 @@ body.screen-blackout #app {
font-size: 0.9rem; font-size: 0.9rem;
} }
.template-region.text table,
.template-region.text th,
.template-region.text td {
border: 1px solid rgba(255, 255, 255, 0.35);
border-collapse: collapse;
}
.template-region.text table {
width: 100%;
border-spacing: 0;
}
.template-region.text th,
.template-region.text td {
padding: 0.35em 0.5em;
text-align: left;
vertical-align: top;
}
.template-region.text th {
font-weight: 700;
}
.webpage-preloads { .webpage-preloads {
position: fixed; position: fixed;
width: 1px; width: 1px;
@@ -451,3 +496,20 @@ body.screen-blackout #app {
border: 0; border: 0;
display: block; display: block;
} }
.template-region table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
}
.template-region th,
.template-region td {
padding: 0.35em 0.5em;
text-align: left;
vertical-align: top;
}
.template-region th {
font-weight: 700;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,137 @@
(function () {
var ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
var ANNOUNCEMENT_ICONS = Array.isArray(window.pulseAnnouncementIconKeys) ? window.pulseAnnouncementIconKeys : [];
var DEFAULT_ANNOUNCEMENT_ICON = String(window.pulseAnnouncementDefaultIconKey || ANNOUNCEMENT_ICONS[0] || '').trim().toLowerCase();
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function normalizeAnnouncementType(value) {
var normalized = String(value || '').trim().toLowerCase();
if (normalized === 'center-card') {
return 'fullscreen';
}
return ANNOUNCEMENT_TYPES.indexOf(normalized) !== -1 ? normalized : 'lower-third';
}
function normalizeAnnouncementColor(value) {
var normalized = String(value || '').trim().toLowerCase();
if (['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light'].indexOf(normalized) !== -1) {
return normalized;
}
return 'primary';
}
function normalizeAnnouncementIcon(value) {
var normalized = String(value || '').trim().toLowerCase();
return ANNOUNCEMENT_ICONS.indexOf(normalized) !== -1 ? normalized : DEFAULT_ANNOUNCEMENT_ICON;
}
function getAnnouncementColorTokens(colorKey) {
var tokens = {
primary: { accent: '#0d6efd', foreground: '#ffffff', glow: 'rgba(13, 110, 253, 0.32)' },
secondary: { accent: '#6c757d', foreground: '#ffffff', glow: 'rgba(108, 117, 125, 0.30)' },
success: { accent: '#198754', foreground: '#ffffff', glow: 'rgba(25, 135, 84, 0.34)' },
info: { accent: '#0dcaf0', foreground: '#052c36', glow: 'rgba(13, 202, 240, 0.28)' },
warning: { accent: '#ffc107', foreground: '#201400', glow: 'rgba(255, 193, 7, 0.30)' },
danger: { accent: '#dc3545', foreground: '#ffffff', glow: 'rgba(220, 53, 69, 0.34)' },
dark: { accent: '#212529', foreground: '#ffffff', glow: 'rgba(33, 37, 41, 0.34)' },
light: { accent: '#f8f9fa', foreground: '#1f2937', glow: 'rgba(248, 249, 250, 0.34)' }
};
return tokens[colorKey] || tokens.primary;
}
function replaceTemplate(template, replacements) {
return String(template || '').replace(/{{([A-Z0-9_]+)}}/g, function (_match, key) {
return Object.prototype.hasOwnProperty.call(replacements, key) ? String(replacements[key]) : '';
});
}
function getComputedGapWidth(track) {
if (!track || !window.getComputedStyle) {
return 0;
}
var computedStyle = window.getComputedStyle(track);
var gapValue = String(computedStyle.columnGap || computedStyle.gap || '0').trim();
var gap = Number.parseFloat(gapValue);
return Number.isFinite(gap) ? gap : 0;
}
function getElementWidth(element) {
if (!element || !element.getBoundingClientRect) {
return 0;
}
return Math.max(0, element.getBoundingClientRect().width || 0);
}
function initPlayerAnnouncementMarquee(track, options) {
if (!track || track.dataset.marqueeMeasured === 'true') {
return;
}
var settings = options || {};
var rate = Number(settings.rate || track.getAttribute('data-announcement-marquee-rate') || 44) || 44;
var textSelector = String(settings.textSelector || '.player-announcement__message-text').trim() || '.player-announcement__message-text';
var textNodes = Array.prototype.slice.call(track.querySelectorAll(textSelector));
if (!textNodes.length) {
return;
}
var container = settings.container || track.parentElement || track;
var containerWidth = Math.max(1, getElementWidth(container) || 0);
var gapWidth = getComputedGapWidth(track);
var firstItemWidth = Math.max(1, getElementWidth(textNodes[0]) || 0);
var cycleWidth = Math.max(1, firstItemWidth + gapWidth);
var minimumTrackWidth = Math.max(containerWidth + cycleWidth, cycleWidth * 2);
var cloneIndex = 0;
while (getElementWidth(track) < minimumTrackWidth && cloneIndex < 8) {
var clone = textNodes[cloneIndex % textNodes.length].cloneNode(true);
clone.setAttribute('aria-hidden', 'true');
track.appendChild(clone);
cloneIndex += 1;
}
var durationSeconds = Math.max(8, cycleWidth / rate);
var durationValue = durationSeconds.toFixed(2) + 's';
track.dataset.marqueeMeasured = 'true';
track.style.setProperty('--announcement-scroll-distance', cycleWidth.toFixed(2) + 'px');
track.style.setProperty('--announcement-scroll-duration', durationValue);
track.style.animationDuration = durationValue;
}
function renderPlayerAnnouncementMarkup(announcement) {
var normalizedType = normalizeAnnouncementType(announcement && announcement.announcement_type);
var colorKey = normalizeAnnouncementColor(announcement && announcement.color_key);
var colorTokens = getAnnouncementColorTokens(colorKey);
var iconKey = normalizeAnnouncementIcon(announcement && announcement.icon_key);
var text = escapeHtml(String(announcement && announcement.message || '').trim()).replace(/[\r\n]+/g, ' ');
var titleText = escapeHtml(String(announcement && announcement.short_label || 'Announcement').trim() || 'Announcement');
var templates = window.playerAnnouncementTemplates || {};
var templateName = normalizedType === 'fullscreen' ? 'fullscreen' : normalizedType === 'top-banner' ? 'topBanner' : 'lowerThird';
var template = templates[templateName] || '';
return replaceTemplate(template, {
ACCENT: colorTokens.accent,
FOREGROUND: colorTokens.foreground,
GLOW: colorTokens.glow,
ICON: iconKey,
COLOR_KEY: colorKey,
TITLE: titleText,
MESSAGE: text
});
}
window.renderPlayerAnnouncementMarkup = renderPlayerAnnouncementMarkup;
window.initPlayerAnnouncementMarquee = initPlayerAnnouncementMarquee;
})();
+212 -13
View File
@@ -6,6 +6,8 @@ function getCurrentViewport() {
}; };
} }
var slideOutroTimers = [];
// Command websocket and player-state helpers. // Command websocket and player-state helpers.
// Send the current playback state to the command websocket. // Send the current playback state to the command websocket.
function sendCommandState(currentSlide) { function sendCommandState(currentSlide) {
@@ -65,6 +67,56 @@ function clearSlideTimer() {
window.clearTimeout(timer); window.clearTimeout(timer);
timer = null; timer = null;
} }
clearSlideOutroTimer();
}
// Cancel any pending outro triggers for the current slide.
function clearSlideOutroTimer() {
if (!Array.isArray(slideOutroTimers) || !slideOutroTimers.length) {
slideOutroTimers = [];
return;
}
slideOutroTimers.forEach(function (timerId) {
window.clearTimeout(timerId);
});
slideOutroTimers = [];
}
// Return the rendered slide root that is currently on screen.
function getCurrentSlideRoot() {
var shells = Array.prototype.slice.call(app ? app.querySelectorAll('.slide-shell') : []);
if (shells.length) {
return shells[shells.length - 1];
}
return app && app.firstElementChild ? app.firstElementChild : app;
}
// Schedule the outgoing slide animation for each region so it finishes before removal.
function scheduleSlideOutro(holdDelayMs) {
clearSlideOutroTimer();
var currentRoot = getCurrentSlideRoot();
if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') {
return;
}
var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro');
if (!regionTimings.length) {
return;
}
var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0)));
slideOutroTimers = regionTimings.map(function (entry) {
var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0)));
var triggerDelayMs = Math.max(0, slideDurationMs - timingMs);
return window.setTimeout(function () {
if (!entry || !entry.element || !entry.element.isConnected) {
return;
}
playRegionAnimation(entry.element, 'outro');
}, triggerDelayMs);
});
} }
// Schedule the next slide transition. // Schedule the next slide transition.
@@ -72,13 +124,16 @@ function scheduleSlideAdvance(delayMs) {
clearSlideTimer(); clearSlideTimer();
var holdDelayMs = Math.max(1, Number(delayMs || 0)); var holdDelayMs = Math.max(1, Number(delayMs || 0));
slideExpiresAt = Date.now() + holdDelayMs; slideExpiresAt = Date.now() + holdDelayMs;
scheduleSlideOutro(holdDelayMs);
timer = window.setTimeout(function () { timer = window.setTimeout(function () {
timer = null; timer = null;
slideExpiresAt = null; slideExpiresAt = null;
pausedRemainingMs = null; pausedRemainingMs = null;
clearSlideOutroTimer();
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides(); const activeSlides = getCurrentActiveSlides();
if (activeSlides.length < 2) { if (activeSlides.length < 2) {
refresh(); showCurrent();
return; return;
} }
if (index >= activeSlides.length) { if (index >= activeSlides.length) {
@@ -89,10 +144,10 @@ function scheduleSlideAdvance(delayMs) {
}, holdDelayMs); }, holdDelayMs);
} }
// Add the fade time to a slide's hold duration so the configured duration remains visible. // Return the slide duration without shifting it for fade timing.
function getSlideHoldDelay(delayMs) { function getSlideHoldDelay(delayMs) {
var holdDelayMs = Math.max(1, Number(delayMs || 0)); var holdDelayMs = Math.max(1, Number(delayMs || 0));
return holdDelayMs + (currentPlaylistFadeBetweenSlides ? slideFadeDurationMs : 0); return holdDelayMs;
} }
// Cancel any pending fade-transition cleanup. // Cancel any pending fade-transition cleanup.
@@ -103,17 +158,125 @@ function clearSlideTransitionTimer() {
} }
} }
function getPlayerRegionModules() {
if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') {
return [];
}
return window.pulsePlayerRegionTypes.list();
}
function isThumbnailPreview() {
return Boolean(window.__pulseThumbnailPreview);
}
function runRegionLifecycle(root, lifecycleName) {
if (!root) {
return;
}
if (isThumbnailPreview() && lifecycleName === 'initRegion') {
return;
}
getPlayerRegionModules().forEach(function (entry) {
var module = entry && entry.definition ? entry.definition : null;
if (!module || typeof module[lifecycleName] !== 'function') {
return;
}
try {
module[lifecycleName](root);
} catch (_error) {
// Keep slide rendering resilient if a region lifecycle hook fails.
}
});
}
function destroyRegionInstances(root) {
runRegionLifecycle(root, 'destroyRegion');
}
function initializeRegionInstances(root) {
runRegionLifecycle(root, 'initRegion');
}
// Swap slide markup with optional fade animation. // Swap slide markup with optional fade animation.
function renderSlideMarkup(markup, shouldFade) { function renderSlideMarkup(markup, shouldFade) {
clearSlideTransitionTimer(); clearSlideTransitionTimer();
destroyRegionInstances(app);
if (typeof destroyRtmpRegions === 'function') { if (typeof destroyRtmpRegions === 'function') {
destroyRtmpRegions(app); destroyRtmpRegions(app);
} }
function initializeRenderedVideoPlayback(root, delayMs) {
if (!root) {
return;
}
var startDelayMs = Math.max(0, Number(delayMs || 0));
var videos = root.querySelectorAll('.template-region.video video');
Array.prototype.forEach.call(videos, function (video) {
if (!video) {
return;
}
var playbackScheduled = false;
video.autoplay = true;
video.loop = true;
video.muted = !(video.dataset && video.dataset.disableAudio === '0');
video.playsInline = true;
function startPlayback() {
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
}
function schedulePlaybackStart() {
if (playbackScheduled) {
return;
}
playbackScheduled = true;
if (startDelayMs > 0) {
window.setTimeout(startPlayback, startDelayMs);
return;
}
startPlayback();
}
if (video.readyState >= 2) {
schedulePlaybackStart();
return;
}
video.addEventListener('canplay', schedulePlaybackStart, { once: true });
video.addEventListener('loadedmetadata', function () {
if (video.readyState >= 2) {
schedulePlaybackStart();
}
}, { once: true });
});
}
if (!shouldFade) { if (!shouldFade) {
app.innerHTML = markup; app.innerHTML = markup;
if (typeof syncRtmpRegions === 'function') { if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(app); syncRtmpRegions(app);
} }
if (!isThumbnailPreview()) {
initializeRegionInstances(app);
}
initializeRenderedVideoPlayback(app);
if (typeof playRegionAnimations === 'function') {
window.requestAnimationFrame(function () {
playRegionAnimations(app, 'intro');
});
}
return app.firstElementChild; return app.firstElementChild;
} }
@@ -131,6 +294,28 @@ function renderSlideMarkup(markup, shouldFade) {
}); });
} }
function pauseRenderedVideoPlayback(root, delayMs) {
if (!root) {
return;
}
var pauseDelayMs = Math.max(0, Number(delayMs || 0));
var videos = root.querySelectorAll('.template-region.video video, .slide-media video');
Array.prototype.forEach.call(videos, function (video) {
if (!video || typeof video.pause !== 'function') {
return;
}
window.setTimeout(function () {
try {
video.pause();
} catch (_error) {
// Ignore pause errors from detached or unsupported media elements.
}
}, pauseDelayMs);
});
}
var nextShell = document.createElement('div'); var nextShell = document.createElement('div');
nextShell.className = 'slide-shell'; nextShell.className = 'slide-shell';
nextShell.style.opacity = '0'; nextShell.style.opacity = '0';
@@ -143,6 +328,8 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof syncRtmpRegions === 'function') { if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell); syncRtmpRegions(nextShell);
} }
initializeRegionInstances(nextShell);
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
return nextShell; return nextShell;
} }
@@ -150,18 +337,27 @@ function renderSlideMarkup(markup, shouldFade) {
previousShell.classList.add('slide-shell'); previousShell.classList.add('slide-shell');
} }
previousShell.style.opacity = '1'; previousShell.style.opacity = '1';
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
app.appendChild(nextShell); app.appendChild(nextShell);
void nextShell.offsetHeight; void nextShell.offsetHeight;
window.requestAnimationFrame(function () { window.requestAnimationFrame(function () {
nextShell.style.opacity = '1'; nextShell.style.opacity = '1';
previousShell.style.opacity = '0'; previousShell.style.opacity = '0';
if (typeof playRegionAnimations === 'function') {
playRegionAnimations(nextShell, 'intro');
}
}); });
if (typeof syncRtmpRegions === 'function') { if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell); syncRtmpRegions(nextShell);
} }
if (!isThumbnailPreview()) {
initializeRegionInstances(nextShell);
}
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
slideTransitionTimer = window.setTimeout(function () { slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) { if (previousShell && previousShell.parentNode) {
previousShell.parentNode.removeChild(previousShell); previousShell.parentNode.removeChild(previousShell);
@@ -280,7 +476,12 @@ function handleCommandMessage(rawMessage) {
} }
return; return;
case 'pause': case 'pause':
var desiredPause = normalizeBoolean(payload.paused);
if (desiredPause !== null) {
setPaused(desiredPause);
} else {
setPaused(!isPaused); setPaused(!isPaused);
}
return; return;
case 'blackout': case 'blackout':
var desiredBlackout = normalizeBoolean(payload.blackout); var desiredBlackout = normalizeBoolean(payload.blackout);
@@ -291,16 +492,19 @@ function handleCommandMessage(rawMessage) {
} }
return; return;
case 'previous': case 'previous':
case 'left':
navigateSlides(-1); navigateSlides(-1);
return; return;
case 'next': case 'next':
case 'right':
navigateSlides(1); navigateSlides(1);
return; return;
case 'reload': case 'reload':
window.location.reload(); window.location.reload();
return; return;
case 'announcement-refresh':
if (typeof refreshAnnouncementOverlay === 'function') {
refreshAnnouncementOverlay();
}
return;
} }
} }
@@ -323,22 +527,17 @@ function connectCommandSocket() {
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) { if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
return; return;
} }
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; var socket = new WebSocket(new URL(commandSocketPath, window.location.origin).toString());
var socketUrl = new URL(commandSocketPath, window.location.origin);
if (window.__pulsePageAuthToken) {
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
}
var socket = new WebSocket(socketUrl.toString());
commandSocket = socket; commandSocket = socket;
socket.onopen = function () { socket.onopen = function () {
if (typeof syncOnboardingClientNameFromServer === 'function') { if (typeof syncOnboardingClientNameFromServer === 'function') {
syncOnboardingClientNameFromServer(socket).then(function () { syncOnboardingClientNameFromServer(socket).then(function () {
sendCommandHello(socket); sendCommandState(socket);
}); });
return; return;
} }
sendCommandHello(socket); sendCommandState(socket);
}; };
socket.onmessage = function (event) { socket.onmessage = function (event) {
+25 -11
View File
@@ -85,7 +85,6 @@ function applyPendingPlaylistUpdate() {
// Render the current active slide or the empty state. // Render the current active slide or the empty state.
function showCurrent() { function showCurrent() {
clearSlideTimer(); clearSlideTimer();
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides(); const activeSlides = getCurrentActiveSlides();
syncWebpagePreloads(activeSlides, index); syncWebpagePreloads(activeSlides, index);
if (typeof syncRtmpWarmups === 'function') { if (typeof syncRtmpWarmups === 'function') {
@@ -100,6 +99,16 @@ function showCurrent() {
lastRenderedViewKey = getCurrentRenderKey(activeSlides); lastRenderedViewKey = getCurrentRenderKey(activeSlides);
} }
function isSlideInList(slide, slideList) {
if (!slide || !Array.isArray(slideList)) {
return false;
}
return slideList.some(function (item) {
return item && Number(item.id) === Number(slide.id);
});
}
// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it. // Skip the current slide when an RTMP feed is unavailable and the playlist asks for it.
function handleRtmpPlaybackFailure(message, options) { function handleRtmpPlaybackFailure(message, options) {
if (!currentPlaylistSkipUnavailableRtmp) { if (!currentPlaylistSkipUnavailableRtmp) {
@@ -166,6 +175,7 @@ function refresh() {
const data = JSON.parse(request.responseText || '{}'); const data = JSON.parse(request.responseText || '{}');
const nextSignature = getPlaylistRevision(data); const nextSignature = getPlaylistRevision(data);
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : []; const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides); const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp); const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
savePlaylistSnapshot({ savePlaylistSnapshot({
@@ -197,19 +207,23 @@ function refresh() {
} }
return; return;
} }
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index); syncWebpagePreloads(nextActiveSlides, index);
if (typeof syncRtmpWarmups === 'function') { if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index); syncRtmpWarmups(nextActiveSlides, index);
} }
if (currentActiveSlides.length < 2) { if (nextActiveSlides.length < 2) {
slides = nextSlides; pendingPlaylistUpdate = {
currentPlaylistSignature = nextSignature; slides: nextSlides,
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides; signature: nextSignature,
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp; fadeBetweenSlides: nextFadeBetweenSlides,
pendingPlaylistUpdate = null; skipUnavailableRtmp: nextSkipUnavailableRtmp
index = 0; };
if (!isSlideInList(lastRenderedSlide, nextSlides)) {
applyPendingPlaylistUpdate();
showCurrent(); showCurrent();
sendCommandState(lastRenderedSlide); return;
}
logDebug('Playlist update detected; applying on next slide transition.');
return; return;
} }
pendingPlaylistUpdate = { pendingPlaylistUpdate = {
+411 -26
View File
@@ -53,6 +53,287 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
}; };
} }
function normalizePlayerAnimationStep(value, fallbackPreset) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return {
preset: String(value.preset || fallbackPreset || 'none').trim(),
duration_ms: Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null,
delay_ms: Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null,
iterations: Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null
};
}
return {
preset: String(typeof value === 'string' ? value : fallbackPreset || 'none').trim(),
duration_ms: null,
delay_ms: null,
iterations: null
};
}
function normalizePlayerAnimationConfig(value) {
var raw = value;
if (typeof raw === 'string') {
var text = String(raw || '').trim();
if (!text) {
raw = null;
} else {
try {
raw = JSON.parse(text);
} catch (_error) {
raw = null;
}
}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
raw = {};
}
return {
intro: normalizePlayerAnimationStep(raw.intro, 'none'),
outro: normalizePlayerAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
loop: normalizePlayerAnimationStep(raw.loop, 'none')
};
}
function hasPlayerAnimation(config) {
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
return String((config[stepName] && config[stepName].preset) || '').trim() && String((config[stepName] && config[stepName].preset) || '').trim() !== 'none';
}));
}
function decorateRegionMarkup(markup, region) {
if (!markup || !region || !hasPlayerAnimation(region.animationConfig)) {
return markup;
}
var animationJson = escapeHtml(JSON.stringify(region.animationConfig));
return markup.replace(/^(\s*)<([a-z0-9-]+)(\s[^>]*)?>/i, function (match, leadingWhitespace, tagName, attributes) {
return leadingWhitespace + '<' + tagName + (attributes || '') + ' data-animation-json="' + animationJson + '">';
});
}
function clearRegionAnimationClasses(root) {
if (!root) {
return;
}
var elements = [];
if (typeof root.matches === 'function' && root.matches('[data-animation-json]')) {
elements.push(root);
}
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
elements.push(element);
});
elements.forEach(function (element) {
if (!element) {
return;
}
element.classList.remove('animate__animated', 'animate__infinite');
element.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
Array.prototype.slice.call(element.classList || []).forEach(function (className) {
if (String(className || '').indexOf('animate__') === 0) {
element.classList.remove(className);
}
});
element.style.removeProperty('--animate-duration');
element.style.removeProperty('--animate-delay');
element.style.removeProperty('--animate-repeat');
});
}
function isAttentionSeekerAnimation(preset) {
return ['bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat'].indexOf(String(preset || '').trim()) !== -1;
}
function applyAnimationStep(element, step, phase) {
if (!element || !step) {
return;
}
var preset = String(step.preset || 'none').trim();
if (!preset || preset === 'none') {
return;
}
element.classList.add('animate__animated', 'animate__' + preset);
element.style.setProperty('--animate-duration', String(Math.max(1, Number(step.duration_ms || 0) || 1000)) + 'ms');
if (Number(step.delay_ms || 0) > 0) {
element.style.setProperty('--animate-delay', String(Math.max(0, Number(step.delay_ms || 0))) + 'ms');
} else {
element.style.removeProperty('--animate-delay');
}
if (phase === 'loop') {
var repeatCount = Number(step.iterations);
if (!Number.isFinite(repeatCount) || repeatCount < 1) {
repeatCount = 1;
}
if (repeatCount > 1) {
element.classList.add('animate__repeat-1');
}
element.style.setProperty('--animate-repeat', String(repeatCount));
return;
}
if (isAttentionSeekerAnimation(preset) && Number(step.iterations || 0) > 1) {
element.style.setProperty('--animate-repeat', String(Math.max(1, Number(step.iterations || 1))));
}
}
function getAnimationStepTimingMs(step) {
if (!step) {
return 0;
}
var preset = String(step.preset || 'none').trim();
if (!preset || preset === 'none') {
return 0;
}
var durationMs = Number(step.duration_ms);
if (!Number.isFinite(durationMs) || durationMs <= 0) {
durationMs = 1000;
}
var delayMs = Number(step.delay_ms);
if (!Number.isFinite(delayMs) || delayMs < 0) {
delayMs = 0;
}
var iterations = Number(step.iterations);
if (!Number.isFinite(iterations) || iterations < 1) {
iterations = 1;
}
return delayMs + (durationMs * iterations);
}
function getRegionAnimationPhaseTimingMs(root, phase) {
var timings = getRegionAnimationPhaseTimings(root, phase);
return timings.reduce(function (maxTimingMs, entry) {
return Math.max(maxTimingMs, Number(entry && entry.timingMs || 0));
}, 0);
}
function getRegionAnimationPhaseTimings(root, phase) {
if (!root || isThumbnailPreview()) {
return [];
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var timings = [];
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
if (!element) {
return;
}
var config;
try {
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
} catch (_error) {
config = null;
}
if (!config) {
return;
}
var timingMs = getAnimationStepTimingMs(normalizedPhase === 'outro' ? config.outro : config.intro);
if (timingMs > 0) {
timings.push({
element: element,
timingMs: timingMs
});
}
});
return timings;
}
function playRegionAnimation(element, phase) {
if (!element || isThumbnailPreview()) {
return;
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var config;
try {
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
} catch (_error) {
config = null;
}
if (!config) {
return;
}
element.dataset.animationPhase = normalizedPhase;
clearRegionAnimationClasses(element);
if (normalizedPhase === 'outro') {
applyAnimationStep(element, config.outro, 'outro');
return;
}
if (config.intro && String(config.intro.preset || '').trim() && String(config.intro.preset || '').trim() !== 'none') {
applyAnimationStep(element, config.intro, 'intro');
if (config.loop && String(config.loop.preset || '').trim() && String(config.loop.preset || '').trim() !== 'none') {
element.addEventListener('animationend', function handleAnimationEnd(event) {
if (event.target !== element) {
return;
}
if (String(element.dataset.animationPhase || '').trim() !== 'intro') {
return;
}
element.removeEventListener('animationend', handleAnimationEnd);
clearRegionAnimationClasses(element);
applyAnimationStep(element, config.loop, 'loop');
});
}
return;
}
applyAnimationStep(element, config.loop, 'loop');
}
function playRegionAnimations(root, phase) {
if (!root || isThumbnailPreview()) {
return;
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var elements = Array.prototype.slice.call(root.querySelectorAll('[data-animation-json]'));
elements.forEach(function (element) {
playRegionAnimation(element, normalizedPhase);
});
}
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
if (!document || !document.documentElement) {
return;
}
var width = Math.max(1, Math.round(Number(canvasWidth) || 0) || 0);
var height = Math.max(1, Math.round(Number(canvasHeight) || 0) || 0);
document.documentElement.style.setProperty('--player-canvas-width', width + 'px');
document.documentElement.style.setProperty('--player-canvas-height', height + 'px');
}
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
function sanitizeRichTextAttributes(tagName, attrText) { function sanitizeRichTextAttributes(tagName, attrText) {
@@ -114,7 +395,7 @@ function sanitizeRichTextAttributes(tagName, attrText) {
return attrs.join(''); return attrs.join('');
} }
// Remove unsafe markup while preserving richer CKEditor formatting. // Remove unsafe markup while preserving richer rich-text formatting.
function sanitizeRichText(html) { function sanitizeRichText(html) {
var output = String(html || ''); var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, ''); output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
@@ -202,6 +483,17 @@ function renderEditorJsTable(data) {
return '<table class="ck-content-table">' + tableRows + '</table>'; return '<table class="ck-content-table">' + tableRows + '</table>';
} }
function wrapRichTextParagraph(html) {
var raw = String(html || '').trim();
if (!raw) {
return '';
}
if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) {
return raw;
}
return '<p>' + raw + '</p>';
}
// Render Editor.js JSON or plain content safely. // Render Editor.js JSON or plain content safely.
function renderEditorJsContent(value) { function renderEditorJsContent(value) {
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
@@ -221,7 +513,7 @@ function renderEditorJsContent(value) {
} catch (_error) { } catch (_error) {
// fall through to legacy HTML rendering // fall through to legacy HTML rendering
} }
return sanitizeRichText(raw); return wrapRichTextParagraph(sanitizeRichText(raw));
} }
// Parse string values that look like JSON. // Parse string values that look like JSON.
@@ -318,31 +610,82 @@ function parseTimeToMinutes(value) {
return Number(match[1]) * 60 + Number(match[2]); return Number(match[1]) * 60 + Number(match[2]);
} }
// Determine whether a slide should be shown at the current time. function normalizeScheduleRule(rule) {
function isSlideActive(slide, now) { const input = rule && typeof rule === 'object' ? rule : {};
const mode = String(slide.schedule_mode || 'always'); const startDatetime = String(input.start_datetime || input.startDateTime || '').trim();
if (mode === 'always') { const endDatetime = String(input.end_datetime || input.endDateTime || '').trim();
return true; const startTime = String(input.start_time || input.startTime || '').trim();
const endTime = String(input.end_time || input.endTime || '').trim();
const days = parseScheduleDays(input.days);
if (startDatetime || endDatetime) {
if (!startDatetime || !endDatetime) {
return null;
} }
if (mode === 'dates') { }
const start = slide.schedule_start_datetime ? new Date(slide.schedule_start_datetime) : null; if (startTime || endTime) {
const end = slide.schedule_end_datetime ? new Date(slide.schedule_end_datetime) : null; if (!startTime || !endTime) {
if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return null;
}
}
if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
return null;
}
return {
start_datetime: startDatetime || null,
end_datetime: endDatetime || null,
start_time: startTime || null,
end_time: endTime || null,
days: days
};
}
function parseScheduleRules(value) {
let rawRules = [];
if (Array.isArray(value)) {
rawRules = value;
} else {
const raw = String(value || '').trim();
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
rawRules = Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
}
return rawRules.map(normalizeScheduleRule).filter(Boolean);
}
function matchesScheduleRule(rule, now) {
const normalized = normalizeScheduleRule(rule);
if (!normalized) {
return false; return false;
} }
return now >= start && now <= end;
} if (normalized.start_datetime && normalized.end_datetime) {
if (mode === 'times') { const start = new Date(normalized.start_datetime);
const days = parseScheduleDays(slide.schedule_days_json); const end = new Date(normalized.end_datetime);
if (!days.length) { if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return false; return false;
} }
const day = now.getDay(); if (now < start || now > end) {
if (days.indexOf(day) === -1) {
return false; return false;
} }
const startMinutes = parseTimeToMinutes(slide.schedule_start_time); }
const endMinutes = parseTimeToMinutes(slide.schedule_end_time);
if (normalized.days.length && normalized.days.indexOf(now.getDay()) === -1) {
return false;
}
if (normalized.start_time && normalized.end_time) {
const startMinutes = parseTimeToMinutes(normalized.start_time);
const endMinutes = parseTimeToMinutes(normalized.end_time);
if (startMinutes === null || endMinutes === null) { if (startMinutes === null || endMinutes === null) {
return false; return false;
} }
@@ -352,8 +695,20 @@ function isSlideActive(slide, now) {
} }
return nowMinutes >= startMinutes || nowMinutes <= endMinutes; return nowMinutes >= startMinutes || nowMinutes <= endMinutes;
} }
return true; return true;
} }
// Determine whether a slide should be shown at the current time.
function isSlideActive(slide, now) {
const rules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : [];
if (!rules.length) {
return true;
}
return rules.some(function (rule) {
return matchesScheduleRule(rule, now);
});
}
// Build the cache key for a template layout. // Build the cache key for a template layout.
function getTemplateLayoutCacheKey(template) { function getTemplateLayoutCacheKey(template) {
var viewportKey = window.innerWidth + 'x' + window.innerHeight; var viewportKey = window.innerWidth + 'x' + window.innerHeight;
@@ -389,6 +744,7 @@ function getTemplateLayout(template) {
regionKey: region.region_key, regionKey: region.region_key,
regionType: region.region_type, regionType: region.region_type,
label: region.label, label: region.label,
animationJson: region.animation_json || null,
baseStyle: baseStyle, baseStyle: baseStyle,
pixelWidth: pixelWidth, pixelWidth: pixelWidth,
pixelHeight: pixelHeight, pixelHeight: pixelHeight,
@@ -415,15 +771,14 @@ function getTemplateLayout(template) {
function buildBackdropStyle(backgroundColor, backgroundImagePath) { function buildBackdropStyle(backgroundColor, backgroundImagePath) {
var color = String(backgroundColor || '#111111').trim() || '#111111'; var color = String(backgroundColor || '#111111').trim() || '#111111';
var style = 'background-color:' + escapeHtml(color) + ';'; var style = 'background-color:' + escapeHtml(color) + ';';
var gradient = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))';
if (backgroundImagePath) { if (backgroundImagePath) {
style += 'background-image:' + gradient + ',url("' + escapeHtml(backgroundImagePath) + '");'; style += 'background-image:url("' + escapeHtml(backgroundImagePath) + '");';
style += 'background-position:center,center;background-size:100% 100%,cover;background-repeat:no-repeat,no-repeat;'; style += 'background-position:center;background-size:contain;background-repeat:no-repeat;';
return style; return style;
} }
style += 'background-image:' + gradient + ';background-position:center;background-size:100% 100%;background-repeat:no-repeat;'; style += 'background-image:none;background-position:center;background-size:cover;background-repeat:no-repeat;';
return style; return style;
} }
@@ -446,12 +801,23 @@ function getTemplateRenderPlan(template) {
} }
var layout = getTemplateLayout(template); var layout = getTemplateLayout(template);
function getPlayerRegionModule(regionType) {
return window.pulsePlayerRegionTypes && typeof window.pulsePlayerRegionTypes.get === 'function' ? window.pulsePlayerRegionTypes.get(regionType) : null;
}
var plan = { var plan = {
layout: layout, layout: layout,
renderRegion: function (region, regionContent) { renderRegion: function (region, regionContent) {
var regionModule = getPlayerRegionModule(region.regionType);
if (regionModule && typeof regionModule.renderRegion === 'function') {
return regionModule.renderRegion(region, regionContent);
}
if (region.regionType === 'image') { if (region.regionType === 'image') {
return renderImageRegion(region, regionContent); return renderImageRegion(region, regionContent);
} }
if (region.regionType === 'video') {
return renderVideoRegion(region, regionContent);
}
if (region.regionType === 'webpage') { if (region.regionType === 'webpage') {
return renderWebpageRegion(region, regionContent); return renderWebpageRegion(region, regionContent);
} }
@@ -483,9 +849,15 @@ function renderTemplateSlideMarkup(slide) {
const layout = plan ? plan.layout : null; const layout = plan ? plan.layout : null;
const regions = layout ? layout.regions.map(function (region) { const regions = layout ? layout.regions.map(function (region) {
const regionContent = content[region.regionKey] || {}; const regionContent = content[region.regionKey] || {};
return plan.renderRegion(region, regionContent); const markup = plan.renderRegion(region, regionContent);
return decorateRegionMarkup(markup, {
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
});
}).join('') : ''; }).join('') : '';
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : ''; const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
if (layout) {
setPlayerCanvasDimensions(layout.canvasWidth, layout.canvasHeight);
}
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>'); return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>');
} }
@@ -503,6 +875,7 @@ function renderMediaSlideMarkup(slide) {
const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight); const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight);
const media = renderMediaSlideContent(slide); const media = renderMediaSlideContent(slide);
const canvasStyle = slide.kind === 'image' ? buildBackdropStyle('#111111', slide.media_url) : ''; const canvasStyle = slide.kind === 'image' ? buildBackdropStyle('#111111', slide.media_url) : '';
setPlayerCanvasDimensions(canvasSize.width, canvasSize.height);
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media, canvasStyle); return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media, canvasStyle);
} }
@@ -517,9 +890,21 @@ function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHt
// Build the cache key for rendered slide markup. // Build the cache key for rendered slide markup.
function getSlideMarkupCacheKey(slide) { function getSlideMarkupCacheKey(slide) {
var viewportKey = window.innerWidth + 'x' + window.innerHeight; var viewportKey = window.innerWidth + 'x' + window.innerHeight;
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', viewportKey].join('|'); return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
} }
function notifyVideoRegionSourceReady() {
if (typeof videoRegionRenderVersion === 'number') {
videoRegionRenderVersion += 1;
}
slideMarkupCache = Object.create(null);
if (typeof showCurrent === 'function' && slides && slides.length) {
showCurrent();
}
}
window.notifyVideoRegionSourceReady = notifyVideoRegionSourceReady;
// Look up a previously rendered slide in the cache. // Look up a previously rendered slide in the cache.
function getCachedSlideMarkup(slide) { function getCachedSlideMarkup(slide) {
var cacheKey = getSlideMarkupCacheKey(slide); var cacheKey = getSlideMarkupCacheKey(slide);
+28 -3
View File
@@ -1,4 +1,6 @@
const CACHE_VERSION = 'v1'; // Service worker cache strategy for player pages, assets, media, and playlists.
const CACHE_VERSION = 'v38';
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`; const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`; const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`; const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
@@ -14,6 +16,20 @@ function normalizeRequest(request) {
}); });
} }
function shouldBypassCache(request) {
if (!request) {
return false;
}
if (request.cache === 'reload' || request.cache === 'no-store') {
return true;
}
const cacheControl = String(request.headers.get('cache-control') || '').toLowerCase();
const pragma = String(request.headers.get('pragma') || '').toLowerCase();
return cacheControl.includes('no-cache') || cacheControl.includes('max-age=0') || pragma.includes('no-cache');
}
async function cacheResponse(cacheName, request, response, cacheKeyRequest) { async function cacheResponse(cacheName, request, response, cacheKeyRequest) {
if (!response || !response.ok) { if (!response || !response.ok) {
return; return;
@@ -45,7 +61,7 @@ async function networkFirst(request, cacheName, cacheKeyRequest) {
if (cached) { if (cached) {
return cached; return cached;
} }
throw _error; return new Response('', { status: 504, statusText: 'Offline' });
} }
} }
@@ -87,6 +103,10 @@ async function staleWhileRevalidate(request, cacheName) {
return new Response('', { status: 504, statusText: 'Offline' }); return new Response('', { status: 504, statusText: 'Offline' });
} }
async function networkOnly(request) {
return fetch(request);
}
self.addEventListener('install', function (event) { self.addEventListener('install', function (event) {
self.skipWaiting(); self.skipWaiting();
event.waitUntil(Promise.resolve()); event.waitUntil(Promise.resolve());
@@ -120,13 +140,18 @@ self.addEventListener('fetch', function (event) {
return; return;
} }
if (shouldBypassCache(request)) {
event.respondWith(networkOnly(request));
return;
}
if (url.pathname.startsWith('/assets/')) { if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirst(request, ASSET_CACHE)); event.respondWith(cacheFirst(request, ASSET_CACHE));
return; return;
} }
if (url.pathname.startsWith('/media/')) { if (url.pathname.startsWith('/media/')) {
event.respondWith(staleWhileRevalidate(request, MEDIA_CACHE)); event.respondWith(networkOnly(request));
return; return;
} }
File diff suppressed because one or more lines are too long
+69 -29
View File
@@ -1,3 +1,8 @@
// API region helpers for resolving source data and nested values.
var registry = window.pulsePlayerRegionTypes;
var placeholderUtils = window.placeholderUtils || {};
function getApiSourceById(sourceId) { function getApiSourceById(sourceId) {
var sources = Array.isArray(initialData && initialData.apiSources) ? initialData.apiSources : []; var sources = Array.isArray(initialData && initialData.apiSources) ? initialData.apiSources : [];
var normalizedId = Number(sourceId || 0); var normalizedId = Number(sourceId || 0);
@@ -6,9 +11,29 @@ function getApiSourceById(sourceId) {
}) || null; }) || null;
} }
function getApiSourceItems(sourceId) { function getApiSourceItemsPath(source, overridePath) {
if (overridePath === undefined || overridePath === null) {
return String(source && (source.items_path || source.itemsPath) || '').trim();
}
return String(overridePath || '').trim();
}
function getApiSourceItems(sourceId, itemsPathOverride) {
var source = getApiSourceById(sourceId); var source = getApiSourceById(sourceId);
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null; var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
var itemsPath = getApiSourceItemsPath(source, itemsPathOverride);
if (itemsPath !== undefined && itemsPath !== null && itemsPath !== '') {
var current = responseJson;
String(itemsPath).split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = '';
return;
}
current = current[segment];
});
return Array.isArray(current) ? current : [];
}
if (Array.isArray(responseJson)) { if (Array.isArray(responseJson)) {
return responseJson; return responseJson;
} }
@@ -24,44 +49,31 @@ function getApiSourceItems(sourceId) {
return responseJson ? [responseJson] : []; return responseJson ? [responseJson] : [];
} }
function getApiItem(sourceId, itemNumber) { function getApiItem(sourceId, itemNumber, itemsPathOverride) {
var items = getApiSourceItems(sourceId); var items = getApiSourceItems(sourceId, itemsPathOverride);
var parsedItemNumber = Math.max(1, Number(itemNumber || 1)); var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0; var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
return items[index] || null; return items[index] || null;
} }
function resolveApiPath(value, path) {
var current = value;
if (!path) {
return current;
}
String(path).split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = '';
return;
}
current = current[segment];
});
return current === undefined || current === null ? '' : current;
}
function substituteApiVariables(html, item) { function substituteApiVariables(html, item) {
var source = String(html || ''); var source = String(html || '');
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) { return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
return ''; return '';
} }
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
return escapeHtml(resolveApiPath(item, key || '')); if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
return '';
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression)));
}); });
} }
function getApiPreviewFallback(item) { function getApiPreviewFallback(item) {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
return '<div class="template-region-placeholder">API item</div>'; return '';
} }
var title = String(item.title || item.name || '').trim(); var title = String(item.title || item.name || '').trim();
@@ -74,24 +86,52 @@ function getApiPreviewFallback(item) {
summary.push('<div>' + sanitizeRichText(description) + '</div>'); summary.push('<div>' + sanitizeRichText(description) + '</div>');
} }
if (!summary.length) { if (!summary.length) {
return '<div class="template-region-placeholder">API item</div>'; return '';
} }
return summary.join(''); return summary.join('');
} }
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return value.value;
}
if (value.text !== undefined) {
return value.text;
}
if (value.html !== undefined) {
return value.html;
}
try {
return JSON.stringify(value);
} catch (_error) {
return '';
}
}
return value;
}
function renderApiRegion(region, regionContent) { function renderApiRegion(region, regionContent) {
var content = regionContent && regionContent.value !== undefined ? regionContent.value : ''; var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : null; var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : null;
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1; var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
var itemsPath = regionContent && regionContent.items_path !== undefined ? regionContent.items_path : '';
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily); var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var item = getApiItem(sourceId, itemNumber); var item = getApiItem(sourceId, itemNumber, itemsPath);
var body = item ? substituteApiVariables(content, item) : ''; var body = item ? substituteApiVariables(content, item) : '';
if (!body && item) { if (!body && item) {
body = getApiPreviewFallback(item); body = getApiPreviewFallback(item);
} }
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';'; var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
var renderedBody = body ? renderEditorJsContent(body) : '<div class="template-region-placeholder">API item</div>'; if (!body) {
return '<div class="template-region api" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>'; return '';
} }
var renderedBody = renderEditorJsContent(body);
return renderedBody ? '<div class="template-region api" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
}
registry.register('api', {
renderRegion: renderApiRegion
});
+9 -1
View File
@@ -1,7 +1,11 @@
// HTML region rendering with sandboxed iframe output.
var registry = window.pulsePlayerRegionTypes;
function renderHtmlRegionContent(value) { function renderHtmlRegionContent(value) {
var html = String(value || '').trim(); var html = String(value || '').trim();
if (!html) { if (!html) {
return '<div class="template-region-placeholder">HTML</div>'; return '';
} }
return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML region" loading="eager"></iframe>'; return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML region" loading="eager"></iframe>';
} }
@@ -9,3 +13,7 @@ function renderHtmlRegionContent(value) {
function renderHtmlRegion(region, regionContent) { function renderHtmlRegion(region, regionContent) {
return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent.value || '') + '</div>'; return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent.value || '') + '</div>';
} }
registry.register('html', {
renderRegion: renderHtmlRegion
});
+11
View File
@@ -1,4 +1,15 @@
// Image region rendering for direct slide media references.
var registry = window.pulsePlayerRegionTypes;
function renderImageRegion(region, regionContent) { function renderImageRegion(region, regionContent) {
var src = regionContent.value || ''; var src = regionContent.value || '';
if (!String(src || '').trim()) {
return '';
}
return '<div class="template-region image" style="' + region.baseStyle + '"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label) + '" /></div>'; return '<div class="template-region image" style="' + region.baseStyle + '"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label) + '" /></div>';
} }
registry.register('image', {
renderRegion: renderImageRegion
});
+20
View File
@@ -0,0 +1,20 @@
// QR code region rendering for embedded URL-to-image output.
var registry = window.pulsePlayerRegionTypes;
function renderQrCodeRegion(region, regionContent) {
var borderRadius = Math.max(0, Math.round(Number(regionContent && regionContent.qr_border_radius || 0)));
var radiusStyle = borderRadius > 0 ? 'border-radius:' + borderRadius + 'px;overflow:hidden;' : '';
var preview = String(regionContent && regionContent.qr_preview || '').trim();
if (!preview) {
return '';
}
return '<div class="template-region qr-code" style="' + region.baseStyle + radiusStyle + '"><img class="template-region-qr-code-image" src="' + escapeHtml(preview) + '" alt="QR code" role="img" draggable="false" style="background:transparent;display:block;width:100%;height:100%;object-fit:contain;' + radiusStyle + '" /></div>';
}
registry.register('qr-code', {
renderRegion: renderQrCodeRegion,
initRegion: function () {}
});
+48 -7
View File
@@ -1,3 +1,35 @@
// RSS region helpers for resolving feeds, items, and nested values.
var registry = window.pulsePlayerRegionTypes;
function getDefaultStyle() {
return {
font_family: 'Arial',
font_size: 32,
font_color: '#000000'
};
}
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return value.value;
}
if (value.text !== undefined) {
return value.text;
}
if (value.html !== undefined) {
return value.html;
}
try {
return JSON.stringify(value);
} catch (_error) {
return '';
}
}
return value;
}
function getRssFeedById(feedId) { function getRssFeedById(feedId) {
var feeds = Array.isArray(initialData && initialData.rssFeeds) ? initialData.rssFeeds : []; var feeds = Array.isArray(initialData && initialData.rssFeeds) ? initialData.rssFeeds : [];
var normalizedId = Number(feedId || 0); var normalizedId = Number(feedId || 0);
@@ -37,18 +69,19 @@ function substituteRssVariables(html, item) {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
return ''; return '';
} }
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName; var key = tokenPath ? tokenName + '.' + tokenPath : tokenName;
return escapeHtml(resolveRssPath(item, key || '')); return escapeHtml(resolveRssPath(item, key || ''));
}); });
} }
function renderRssRegion(region, regionContent) { function renderRssRegion(region, regionContent) {
var content = regionContent && regionContent.value !== undefined ? regionContent.value : ''; var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
var feedId = regionContent && regionContent.feed_id !== undefined ? regionContent.feed_id : null; var feedId = regionContent && regionContent.feed_id !== undefined ? regionContent.feed_id : null;
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1; var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily); var defaultStyle = getDefaultStyle();
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily || defaultStyle.font_family);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
var item = getRssFeedItem(feedId, itemNumber); var item = getRssFeedItem(feedId, itemNumber);
var body = item ? substituteRssVariables(content, item) : ''; var body = item ? substituteRssVariables(content, item) : '';
if (!body && item) { if (!body && item) {
@@ -62,6 +95,14 @@ function renderRssRegion(region, regionContent) {
body = summaryParts.join(''); body = summaryParts.join('');
} }
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';'; var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
var renderedBody = body ? renderEditorJsContent(body) : '<div class="template-region-placeholder">RSS item</div>'; if (!body) {
return '<div class="template-region rss" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>'; return '';
} }
var renderedBody = renderEditorJsContent(body);
return renderedBody ? '<div class="template-region rss" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
}
registry.register('rss', {
getDefaultStyle: getDefaultStyle,
renderRegion: renderRssRegion
});
+8 -2
View File
@@ -1,12 +1,14 @@
var registry = window.pulsePlayerRegionTypes;
function renderRtmpRegion(region, regionContent) { function renderRtmpRegion(region, regionContent) {
var url = String(regionContent.value || '').trim(); var url = String(regionContent.value || '').trim();
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio); var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
var skipUnavailable = Boolean(currentPlaylistSkipUnavailableRtmp); var skipUnavailable = Boolean(currentPlaylistSkipUnavailableRtmp);
if (!url) { if (!url) {
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><div class="template-region-placeholder">RTMP stream</div></div>'; return '';
} }
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>'; return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture' + (disableAudio ? ' muted' : '') + '></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
} }
function getRtmpSessionUrl(sourceUrl, disableAudio) { function getRtmpSessionUrl(sourceUrl, disableAudio) {
@@ -823,3 +825,7 @@ function destroyRtmpRegions(root) {
} }
}); });
} }
registry.register('rtmp', {
renderRegion: renderRtmpRegion
});
+253
View File
@@ -0,0 +1,253 @@
// Timetable region rendering for live playback.
var registry = window.pulsePlayerRegionTypes;
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeRichTextAttributes(tagName, attrText) {
var allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
col: ['class', 'style', 'span', 'width'],
colgroup: ['class', 'style', 'span'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
tbody: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
thead: ['class', 'style'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
var allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
var attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
var lowerKey = String(key || '').toLowerCase();
if (allowed.indexOf(lowerKey) === -1) {
return '';
}
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target') {
var targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
return '';
});
return attrs.join('');
}
function sanitizeRichText(html) {
var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, function (tag) {
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
var closing = Boolean(match[1]);
var name = String(match[2] || '').toLowerCase();
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
if (allowed.indexOf(name) === -1) {
return '';
}
if (closing) {
return '</' + name + '>';
}
return '<' + name + sanitizeRichTextAttributes(name, String(match[3] || '')) + '>';
});
}
function substituteTimetableVariables(html, entry) {
var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!entry || typeof entry !== 'object') {
return '';
}
if (!window.placeholderUtils || typeof window.placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof window.placeholderUtils.formatPlaceholderValue !== 'function') {
return '';
}
return escapeHtml(window.placeholderUtils.formatPlaceholderValue(window.placeholderUtils.resolvePlaceholderExpression(entry, expression)));
});
}
function renderTemplate(template, context) {
var source = String(template || '');
if (!source) {
return '';
}
return substituteTimetableVariables(source, context);
}
function getTimetableGroups() {
return Array.isArray(initialData && initialData.timetableGroups) ? initialData.timetableGroups : [];
}
function getGroupById(groupId, groups) {
var normalizedId = Number(groupId || 0);
return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) {
return Number(group.id) === normalizedId;
}) || null;
}
function getEntries(groupId, groups) {
var group = getGroupById(groupId, groups);
return group && Array.isArray(group.entries) ? group.entries : [];
}
function toDate(value) {
if (!value) {
return null;
}
var date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function isUpcoming(entry, now) {
var start = toDate(entry && entry.start_datetime);
return Boolean(start && now < start);
}
function isLive(entry, now) {
var start = toDate(entry && entry.start_datetime);
var end = toDate(entry && entry.end_datetime);
return Boolean(start && end && now >= start && now < end);
}
function getVisibleEntries(groupId, displayMode, maxItems, groups) {
var now = new Date();
var entries = getEntries(groupId, groups).slice().sort(function (left, right) {
var leftStart = toDate(left && left.start_datetime);
var rightStart = toDate(right && right.start_datetime);
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
});
var mode = String(displayMode || 'upcoming').trim().toLowerCase();
entries = entries.filter(function (entry) {
if (mode === 'current') {
return isLive(entry, now);
}
if (mode === 'both') {
return isUpcoming(entry, now) || isLive(entry, now);
}
return isUpcoming(entry, now);
});
if (!entries.length && mode === 'upcoming') {
entries = getEntries(groupId, groups).filter(function (entry) {
return isLive(entry, now);
});
}
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
}
function formatDateTime(value) {
var date = toDate(value);
if (!date) {
return '';
}
try {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: '2-digit',
hour: 'numeric',
minute: '2-digit'
}).format(date);
} catch (_error) {
return date.toLocaleString();
}
}
function getDefaultStyle() {
return {
font_family: 'Arial',
font_size: 28,
font_color: '#000000'
};
}
function getTextStyle(region, regionContent) {
var current = regionContent && typeof regionContent === 'object' ? regionContent : {};
var defaultStyle = getDefaultStyle();
return {
font_family: String(current.font_family || region.font_family || defaultStyle.font_family || 'Arial').trim() || 'Arial',
font_size: Math.max(8, Number(current.font_size || region.font_size || defaultStyle.font_size || 28)),
font_color: String(current.font_color || region.font_color || defaultStyle.font_color || '#000000').trim() || '#000000'
};
}
function renderRegion(region, regionContent) {
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
var style = getTextStyle(region, regionContent || {});
var groups = getTimetableGroups();
var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
var width = Math.max(1, Math.round(Number(region && region.pixelWidth ? region.pixelWidth : 0) || 1));
var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1));
var canvasScale = Number(region && region.canvasScale ? region.canvasScale : 1) || 1;
var contentStyle = 'width:' + width + 'px;height:' + height + 'px;transform:scale(' + canvasScale + ');transform-origin:top left;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '');
if (!value) {
return '<div class="template-region timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;"></div></div>';
}
return '<div class="template-region timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;">' + entries.map(function (entry, index) {
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(substituteTimetableVariables(value, Object.assign({}, entry || {}, {
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
group: group || {},
entries: entries,
index: index + 1
}))) + '</div>';
}).join('') + '</div></div>';
}
registry.register('timetable', {
renderRegion: renderRegion
});
+50 -6
View File
@@ -1,7 +1,51 @@
function renderTextRegion(region, regionContent) { // Text region rendering with font and color normalization.
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var registry = window.pulsePlayerRegionTypes;
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';'; function getDefaultStyle() {
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(regionContent.value || '') + '</div></div>'; return {
font_family: 'Arial',
font_size: 32,
font_color: '#000000'
};
} }
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return value.value;
}
if (value.text !== undefined) {
return value.text;
}
if (value.html !== undefined) {
return value.html;
}
try {
return JSON.stringify(value);
} catch (_error) {
return '';
}
}
return value;
}
function renderTextRegion(region, regionContent) {
var rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
if (!String(rawValue || '').trim()) {
return '';
}
var defaultStyle = getDefaultStyle();
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily || defaultStyle.font_family);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;line-height:1.5;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
var renderedBody = renderEditorJsContent(rawValue);
return renderedBody ? '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
}
registry.register('text', {
getDefaultStyle: getDefaultStyle,
renderRegion: renderTextRegion
});
+337
View File
@@ -0,0 +1,337 @@
// Time/date region rendering and live updates.
var registry = window.pulsePlayerRegionTypes;
var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
var DEFAULT_STYLE = {
font_family: 'Arial',
font_size: 32,
font_color: '#000000'
};
var timeDateFormatterCache = Object.create(null);
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeTagAttributes(tagName, attrText) {
var allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
var allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
var attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
var lowerKey = String(key || '').toLowerCase();
if (allowed.indexOf(lowerKey) === -1) {
return '';
}
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target') {
var targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
return '';
});
return attrs.join('');
}
function sanitizePreviewHtml(html) {
var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, function (tag) {
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
var closing = Boolean(match[1]);
var name = String(match[2] || '').toLowerCase();
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
if (allowed.indexOf(name) === -1) {
return '';
}
if (closing) {
return '</' + name + '>';
}
return '<' + name + sanitizeTagAttributes(name, String(match[3] || '')) + '>';
});
}
function getDefaultStyle() {
return {
font_family: DEFAULT_STYLE.font_family,
font_size: DEFAULT_STYLE.font_size,
font_color: DEFAULT_STYLE.font_color
};
}
function getDefaultTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch (_error) {
return 'UTC';
}
}
function resolveTimeZone(value) {
var raw = String(value || '').trim();
if (!raw) {
return getDefaultTimeZone();
}
try {
new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date());
return raw;
} catch (_error) {
return getDefaultTimeZone();
}
}
function getFormatter(key, options) {
if (!timeDateFormatterCache[key]) {
timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options);
}
return timeDateFormatterCache[key];
}
function getFormattedParts(timeZone, date) {
var targetDate = date instanceof Date ? date : new Date();
var resolvedTimeZone = resolveTimeZone(timeZone);
var numericParts = getFormatter('numeric:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).formatToParts(targetDate);
var weekdayLong = getFormatter('weekday-long:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
weekday: 'long'
}).formatToParts(targetDate);
var weekdayShort = getFormatter('weekday-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
weekday: 'short'
}).formatToParts(targetDate);
var monthLong = getFormatter('month-long:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
month: 'long'
}).formatToParts(targetDate);
var monthShort = getFormatter('month-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
month: 'short'
}).formatToParts(targetDate);
var ampm = getFormatter('ampm:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
hour12: true,
hour: '2-digit',
minute: '2-digit'
}).formatToParts(targetDate);
var timezoneShort = getFormatter('tz-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
timeZoneName: 'short'
}).formatToParts(targetDate);
function getPart(parts, type) {
var match = parts.find(function (part) {
return part && part.type === type;
});
return match ? String(match.value || '') : '';
}
function toTitleCase(value) {
return String(value || '').toLowerCase().replace(/\b([a-z])/g, function (match, letter) {
return String(letter || '').toUpperCase();
});
}
return {
h: String(Number(getPart(numericParts, 'hour')) || 0),
hh: getPart(numericParts, 'hour'),
m: String(Number(getPart(numericParts, 'minute')) || 0),
mm: getPart(numericParts, 'minute'),
s: String(Number(getPart(numericParts, 'second')) || 0),
ss: getPart(numericParts, 'second'),
d: String(Number(getPart(numericParts, 'day')) || 0),
dd: getPart(numericParts, 'day'),
M: String(Number(getPart(numericParts, 'month')) || 0),
MM: getPart(numericParts, 'month'),
y: String(Number(getPart(numericParts, 'year')) || 0),
yyyy: getPart(numericParts, 'year'),
yy: String(Number(String(getPart(numericParts, 'year')).slice(-2)) || 0).padStart(2, '0'),
ddd: toTitleCase(getPart(weekdayShort, 'weekday')),
dddd: toTitleCase(getPart(weekdayLong, 'weekday')),
MMM: toTitleCase(getPart(monthShort, 'month')),
MMMM: toTitleCase(getPart(monthLong, 'month')),
a: toTitleCase(getPart(ampm, 'dayPeriod')),
tz: resolvedTimeZone,
tz_short: getPart(timezoneShort, 'timeZoneName'),
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
};
}
function resolveTimeDatePlaceholder(values, expression) {
if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression));
}
var parsed = String(expression || '').trim();
return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : '';
}
function renderTemplate(format, timeZone, date) {
var template = String(format || '').trim() || DEFAULT_FORMAT;
var values = getFormattedParts(timeZone, date);
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
return String(resolveTimeDatePlaceholder(values, key) || '');
});
}
function getTextStyle(regionContent, region) {
var defaultStyle = getDefaultStyle();
return {
font_family: regionContent.font_family || region.fontFamily || defaultStyle.font_family,
font_size: regionContent.font_size || region.fontSize || defaultStyle.font_size,
font_color: regionContent.font_color || region.fontColor || defaultStyle.font_color
};
}
function renderTimeDateRegion(region, regionContent) {
var content = regionContent && typeof regionContent === 'object' ? regionContent : {};
var format = String(content.value !== undefined ? content.value : content.text || '').trim() || DEFAULT_FORMAT;
var timeZone = resolveTimeZone(content.timezone || content.time_zone || '');
var style = getTextStyle(content, region);
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
var fontColor = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? fontFamily : '') + fontSize + fontColor + 'white-space:pre-wrap;line-height:1.1;';
var renderedText = renderTemplate(format, timeZone, new Date());
return '<div class="template-region time-date" data-time-date-format="' + escapeHtml(format) + '" data-time-date-timezone="' + escapeHtml(timeZone) + '" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(renderedText) + '</div></div>';
}
function updateTimeDateRegion(element) {
if (!element) {
return;
}
var format = String(element.dataset.timeDateFormat || '').trim() || DEFAULT_FORMAT;
var timeZone = String(element.dataset.timeDateTimezone || '').trim();
var scaleWrapper = element.querySelector('.template-region-text-scale');
if (!scaleWrapper) {
return;
}
scaleWrapper.innerHTML = renderEditorJsContent(renderTemplate(format, timeZone, new Date()));
}
function scheduleTimeDateRegionUpdate(element) {
if (!element) {
return;
}
if (element.__timeDateTimer) {
window.clearTimeout(element.__timeDateTimer);
element.__timeDateTimer = null;
}
function tick() {
if (!element.isConnected) {
if (element.__timeDateTimer) {
window.clearTimeout(element.__timeDateTimer);
element.__timeDateTimer = null;
}
return;
}
updateTimeDateRegion(element);
element.__timeDateTimer = window.setTimeout(tick, Math.max(100, 1000 - (Date.now() % 1000)));
}
tick();
}
function destroyTimeDateRegions(root) {
if (!root) {
return;
}
Array.prototype.forEach.call(root.querySelectorAll('.template-region.time-date'), function (element) {
if (element && element.__timeDateTimer) {
window.clearTimeout(element.__timeDateTimer);
element.__timeDateTimer = null;
}
});
}
function initializeTimeDateRegions(root) {
if (!root) {
return;
}
Array.prototype.forEach.call(root.querySelectorAll('.template-region.time-date'), function (element) {
scheduleTimeDateRegionUpdate(element);
});
}
registry.register('time-date', {
label: 'Time / Date',
getDefaultStyle: getDefaultStyle,
getDefaultRegionSize: function () {
return { width: 420, height: 180 };
},
renderRegion: renderTimeDateRegion,
initRegion: initializeTimeDateRegions,
destroyRegion: destroyTimeDateRegions
});
+154
View File
@@ -0,0 +1,154 @@
// Video region playback, retry, and source probe helpers.
var videoRegionLastGoodSrcCache = Object.create(null);
var videoRegionProbeStateCache = Object.create(null);
var videoRegionProbeTimerCache = Object.create(null);
var VIDEO_REGION_RETRY_DELAY_MS = 5000;
function getVideoRegionCacheKey(region) {
return String(region && (region.regionKey || region.label) || '').trim();
}
function isDirectlyRenderableSource(src) {
return /^(?:https?:)?\/\//i.test(src) || /^data:/i.test(src) || /^blob:/i.test(src) || /^\/media\//i.test(src);
}
function appendCacheBust(src, cacheBust) {
var key = String(cacheBust || '').trim();
var raw = String(src || '').trim();
if (!raw || !key) {
return raw;
}
return raw + (raw.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(key);
}
function getVideoSourceAvailability(src) {
return videoRegionProbeStateCache[String(src || '').trim()] || null;
}
function setVideoSourceAvailability(src, available) {
var key = String(src || '').trim();
if (!key) {
return;
}
videoRegionProbeStateCache[key] = {
available: available === null ? null : Boolean(available),
checkedAt: Date.now()
};
}
function logVideoRegionStatus(message, details, level) {
if (typeof logDebug === 'function') {
logDebug(message, details || '', level || 'info');
}
}
function triggerVideoRegionSourceRefresh() {
if (typeof window.notifyVideoRegionSourceReady === 'function') {
window.notifyVideoRegionSourceReady();
return;
}
if (typeof videoRegionRenderVersion === 'number') {
videoRegionRenderVersion += 1;
}
slideMarkupCache = Object.create(null);
if (typeof showCurrent === 'function' && slides && slides.length) {
showCurrent();
}
}
function scheduleVideoSourceProbe(regionKey, src, isRetry) {
var key = String(src || '').trim();
if (!regionKey || !key || isDirectlyRenderableSource(key)) {
if (key) {
setVideoSourceAvailability(key, true);
}
return;
}
if (videoRegionProbeTimerCache[key]) {
return;
}
if (!isRetry) {
logVideoRegionStatus('Video source changed; probing mirrored file before swapping.', 'region=' + regionKey + ' src=' + key);
} else {
logVideoRegionStatus('Video source still unavailable; retrying mirrored file probe.', 'region=' + regionKey + ' src=' + key, 'warn');
}
videoRegionProbeTimerCache[key] = window.setTimeout(function () {
delete videoRegionProbeTimerCache[key];
fetch(key, {
method: 'HEAD',
cache: 'no-store',
credentials: 'same-origin'
}).then(function (response) {
if (response && response.ok) {
setVideoSourceAvailability(key, true);
if (videoRegionLastGoodSrcCache[regionKey] !== key) {
videoRegionLastGoodSrcCache[regionKey] = key;
logVideoRegionStatus('Mirrored video is ready; switching to the new source.', 'region=' + regionKey + ' src=' + key);
triggerVideoRegionSourceRefresh();
}
return;
}
setVideoSourceAvailability(key, false);
logVideoRegionStatus('Mirrored video probe returned unavailable; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
scheduleVideoSourceProbe(regionKey, key, true);
}).catch(function () {
setVideoSourceAvailability(key, false);
logVideoRegionStatus('Mirrored video probe failed; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
scheduleVideoSourceProbe(regionKey, key, true);
});
}, isRetry ? VIDEO_REGION_RETRY_DELAY_MS : 0);
}
function renderVideoRegion(region, regionContent) {
var requestedSrc = String(regionContent && regionContent.value || '').trim();
var requestedSrcVersioned = appendCacheBust(requestedSrc, regionContent && regionContent.cache_bust);
var regionKey = getVideoRegionCacheKey(region);
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
var disableAudio = regionContent && regionContent.disable_audio === undefined ? true : Boolean(regionContent && regionContent.disable_audio);
if (!requestedSrc) {
return '';
}
if (isDirectlyRenderableSource(requestedSrc)) {
if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
setVideoSourceAvailability(requestedSrc, true);
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
var requestedState = getVideoSourceAvailability(requestedSrc);
var requestedReady = Boolean(requestedState && requestedState.available === true);
if (requestedReady) {
if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
if (cachedSrc) {
if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
return '';
}
if (window.pulsePlayerRegionTypes && typeof window.pulsePlayerRegionTypes.register === 'function') {
window.pulsePlayerRegionTypes.register('video', {
renderRegion: renderVideoRegion
});
}
+12 -2
View File
@@ -1,5 +1,15 @@
// Webpage region rendering for embedded live URLs.
var registry = window.pulsePlayerRegionTypes;
function renderWebpageRegion(region, regionContent) { function renderWebpageRegion(region, regionContent) {
var url = String(regionContent.value || '').trim(); var url = String(regionContent.value || '').trim();
var iframe = url ? '<iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>' : '<div class="template-region-placeholder">Webpage</div>'; if (!url) {
return '<div class="template-region webpage" style="' + region.baseStyle + '">' + iframe + '</div>'; return '';
} }
return '<div class="template-region webpage" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>';
}
registry.register('webpage', {
renderRegion: renderWebpageRegion
});
+110 -12
View File
@@ -1,5 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const announcementIcons = require('#src/data/announcement-icons');
function mediaKind(mediaPath) { function mediaKind(mediaPath) {
const ext = path.extname(mediaPath || '').toLowerCase(); const ext = path.extname(mediaPath || '').toLowerCase();
@@ -235,6 +236,17 @@ function renderEditorJsTable(data) {
return '<table class="ck-content-table">' + tableRows + '</table>'; return '<table class="ck-content-table">' + tableRows + '</table>';
} }
function wrapRichTextParagraph(html) {
const raw = String(html || '').trim();
if (!raw) {
return '';
}
if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) {
return raw;
}
return '<p>' + raw + '</p>';
}
function renderEditorJsContent(value) { function renderEditorJsContent(value) {
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
if (Array.isArray(value.blocks)) { if (Array.isArray(value.blocks)) {
@@ -253,7 +265,7 @@ function renderEditorJsContent(value) {
} catch (_error) { } catch (_error) {
// fall through to legacy HTML rendering // fall through to legacy HTML rendering
} }
return sanitizeRichText(raw); return wrapRichTextParagraph(sanitizeRichText(raw));
} }
function renderHtmlRegionContent(value) { function renderHtmlRegionContent(value) {
@@ -283,16 +295,13 @@ const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'playe
const playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js'); const playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js');
const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js'); const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js');
const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js'); const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js');
const playerAnnouncementTemplatesScriptPath = path.join(__dirname, 'public', 'js', 'player-announcement-templates.js');
const playerAnnouncementLowerThirdTemplatePath = path.join(__dirname, 'announcement-templates', 'lower-third.template.html');
const playerAnnouncementTopBannerTemplatePath = path.join(__dirname, 'announcement-templates', 'top-banner.template.html');
const playerAnnouncementFullscreenTemplatePath = path.join(__dirname, 'announcement-templates', 'fullscreen.template.html');
const playerPageAnnouncementsScriptPath = path.join(__dirname, 'player-page-announcements.js');
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html'); const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
const playerRegionScriptPaths = [ const playerRegionScriptDir = path.join(__dirname, 'regions');
path.join(__dirname, 'regions', 'image.js'),
path.join(__dirname, 'regions', 'webpage.js'),
path.join(__dirname, 'regions', 'html.js'),
path.join(__dirname, 'regions', 'rtmp.js'),
path.join(__dirname, 'regions', 'rss.js'),
path.join(__dirname, 'regions', 'api.js'),
path.join(__dirname, 'regions', 'text.js')
];
const playerOnboardingLandingScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-landing.script.html'); const playerOnboardingLandingScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-landing.script.html');
const playerOnboardingFormScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-form.script.html'); const playerOnboardingFormScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-form.script.html');
let playerPageTemplateCache = null; let playerPageTemplateCache = null;
@@ -302,6 +311,9 @@ let playerPagePlaylistScriptCache = null;
let playerPageCommandsScriptCache = null; let playerPageCommandsScriptCache = null;
let playerPageRenderingScriptCache = null; let playerPageRenderingScriptCache = null;
let playerPagePlaybackScriptCache = null; let playerPagePlaybackScriptCache = null;
let playerAnnouncementTemplatesScriptCache = null;
let playerAnnouncementTemplatesDataScriptCache = null;
let playerPageAnnouncementsScriptCache = null;
let playerPageScriptCache = null; let playerPageScriptCache = null;
let playerRegionScriptsCache = null; let playerRegionScriptsCache = null;
let playerOnboardingLandingScriptCache = null; let playerOnboardingLandingScriptCache = null;
@@ -346,19 +358,101 @@ function getPlayerPagePlaybackScript() {
return loadTemplate(playerPagePlaybackScriptPath, playerPagePlaybackScriptCache || (playerPagePlaybackScriptCache = {})); return loadTemplate(playerPagePlaybackScriptPath, playerPagePlaybackScriptCache || (playerPagePlaybackScriptCache = {}));
} }
function getPlayerAnnouncementTemplatesScript() {
if (playerAnnouncementTemplatesScriptCache && playerAnnouncementTemplatesScriptCache.path === playerAnnouncementTemplatesScriptPath) {
return playerAnnouncementTemplatesScriptCache.value;
}
const value = fs.readFileSync(playerAnnouncementTemplatesScriptPath, 'utf8').trim();
playerAnnouncementTemplatesScriptCache = {
path: playerAnnouncementTemplatesScriptPath,
value: value
};
return value;
}
function getAnnouncementIconsDataScript() {
return [
'(function () {',
' window.pulseAnnouncementIconKeys = ' + safeJsonForScript(announcementIcons.ANNOUNCEMENT_ICON_KEYS) + ';',
' window.pulseAnnouncementDefaultIconKey = ' + safeJsonForScript(announcementIcons.DEFAULT_ANNOUNCEMENT_ICON) + ';',
'}());'
].join('\n');
}
function loadAnnouncementTemplate(filePath) {
return fs.readFileSync(filePath, 'utf8').trim();
}
function getPlayerAnnouncementTemplatesDataScript() {
const templatePaths = [
playerAnnouncementLowerThirdTemplatePath,
playerAnnouncementTopBannerTemplatePath,
playerAnnouncementFullscreenTemplatePath
];
const signature = templatePaths.map(function (filePath) {
return fs.statSync(filePath).mtimeMs;
}).join('|');
if (playerAnnouncementTemplatesDataScriptCache && playerAnnouncementTemplatesDataScriptCache.signature === signature) {
return playerAnnouncementTemplatesDataScriptCache.value;
}
const value = [
'(function () {',
' window.playerAnnouncementTemplates = {',
' lowerThird: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementLowerThirdTemplatePath)) + ',',
' topBanner: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementTopBannerTemplatePath)) + ',',
' fullscreen: ' + safeJsonForScript(loadAnnouncementTemplate(playerAnnouncementFullscreenTemplatePath)),
' };',
'}());'
].join('\n');
playerAnnouncementTemplatesDataScriptCache = {
signature: signature,
value: value
};
return value;
}
function getPlayerPageAnnouncementsScript() {
return loadTemplate(playerPageAnnouncementsScriptPath, playerPageAnnouncementsScriptCache || (playerPageAnnouncementsScriptCache = {}));
}
function getPlayerPageScript() { function getPlayerPageScript() {
return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {})); return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {}));
} }
function getPlayerRegionScriptPaths() {
if (!fs.existsSync(playerRegionScriptDir)) {
return [];
}
return fs.readdirSync(playerRegionScriptDir, { withFileTypes: true })
.filter(function (entry) {
return entry.isFile() && entry.name.toLowerCase().endsWith('.js');
})
.map(function (entry) {
return path.join(playerRegionScriptDir, entry.name);
})
.sort(function (left, right) {
return left.localeCompare(right);
});
}
function getPlayerRegionScripts() { function getPlayerRegionScripts() {
const statSignature = playerRegionScriptPaths.map(function (filePath) { const sharedPlaceholderUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js');
const sharedQrSvgUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js');
const playerRegionScriptPaths = getPlayerRegionScriptPaths();
const scriptPaths = [sharedPlaceholderUtilsPath, sharedQrSvgUtilsPath].concat(playerRegionScriptPaths);
const statSignature = scriptPaths.map(function (filePath) {
return fs.statSync(filePath).mtimeMs; return fs.statSync(filePath).mtimeMs;
}).join('|'); }).join('|');
if (playerRegionScriptsCache && playerRegionScriptsCache.signature === statSignature) { if (playerRegionScriptsCache && playerRegionScriptsCache.signature === statSignature) {
return playerRegionScriptsCache.value; return playerRegionScriptsCache.value;
} }
const value = playerRegionScriptPaths.map(function (filePath) { const value = scriptPaths.map(function (filePath) {
return fs.readFileSync(filePath, 'utf8').trim(); return fs.readFileSync(filePath, 'utf8').trim();
}).join('\n\n'); }).join('\n\n');
playerRegionScriptsCache = { playerRegionScriptsCache = {
@@ -402,6 +496,10 @@ module.exports = {
getPlayerPageCommandsScript: getPlayerPageCommandsScript, getPlayerPageCommandsScript: getPlayerPageCommandsScript,
getPlayerPageRenderingScript: getPlayerPageRenderingScript, getPlayerPageRenderingScript: getPlayerPageRenderingScript,
getPlayerPagePlaybackScript: getPlayerPagePlaybackScript, getPlayerPagePlaybackScript: getPlayerPagePlaybackScript,
getAnnouncementIconsDataScript: getAnnouncementIconsDataScript,
getPlayerAnnouncementTemplatesDataScript: getPlayerAnnouncementTemplatesDataScript,
getPlayerAnnouncementTemplatesScript: getPlayerAnnouncementTemplatesScript,
getPlayerPageAnnouncementsScript: getPlayerPageAnnouncementsScript,
getPlayerPageScript: getPlayerPageScript, getPlayerPageScript: getPlayerPageScript,
getPlayerRegionScripts: getPlayerRegionScripts, getPlayerRegionScripts: getPlayerRegionScripts,
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript, getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
+22 -4
View File
@@ -1,13 +1,23 @@
// Player page rendering and bootstrap script assembly.
const Handlebars = require('handlebars'); const Handlebars = require('handlebars');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers'); const path = require('path');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getAnnouncementIconsDataScript, getPlayerAnnouncementTemplatesDataScript, getPlayerAnnouncementTemplatesScript, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview'); const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth'); const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
const PLAYER_MEDIA_DIR = path.join(__dirname, '..', '..', 'media');
function renderPage(template, options) { function renderPage(template, options) {
const stylesheetLinks = Array.isArray(options && options.stylesheets) ? options.stylesheets : [];
return template({ return template({
TITLE: options.title, TITLE: options.title,
BODY_CLASS: options.bodyClass || '', BODY_CLASS: options.bodyClass || '',
BODY: new Handlebars.SafeString(options.body || ''), BODY: new Handlebars.SafeString(options.body || ''),
STYLESHEETS: new Handlebars.SafeString(stylesheetLinks.map(function (href) {
return `<link rel="stylesheet" href="${Handlebars.escapeExpression(href)}" />`;
}).join('')),
SCRIPT_BLOCK: new Handlebars.SafeString(options.script || '') SCRIPT_BLOCK: new Handlebars.SafeString(options.script || '')
}); });
} }
@@ -17,7 +27,7 @@ function getPlayerServiceWorkerRegistrationScript() {
'<script>', '<script>',
' if ("serviceWorker" in navigator) {', ' if ("serviceWorker" in navigator) {',
' window.addEventListener("load", function () {', ' window.addEventListener("load", function () {',
' navigator.serviceWorker.register("/sw.js").catch(function () {', ' navigator.serviceWorker.register("/sw.js?v=38").catch(function () {',
' return null;', ' return null;',
' });', ' });',
' });', ' });',
@@ -109,6 +119,8 @@ function renderPlayerPage(slug, initialData) {
const template = getPlayerPageTemplate(); const template = getPlayerPageTemplate();
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>'; const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() }); const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
const bodyClass = [initialData && initialData.thumbnailPreview ? 'thumbnail-preview' : '', ''].join(' ').trim();
const script = getPlayerPageScript()({ const script = getPlayerPageScript()({
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)), SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)), INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
@@ -117,27 +129,33 @@ function renderPlayerPage(slug, initialData) {
return renderPage(template, { return renderPage(template, {
title: 'Screen ' + slug, title: 'Screen ' + slug,
bodyClass: bodyClass,
body: '<div id="app"><div class="empty">Loading screen...</div></div>', body: '<div id="app"><div class="empty">Loading screen...</div></div>',
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + onboardingScript + script stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + '<script>' + getAnnouncementIconsDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesScript() + '</script>' + onboardingScript + script + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
}); });
} }
function renderPlayerOnboardingLandingPage() { function renderPlayerOnboardingLandingPage() {
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding' }); const pageAuthToken = createPageAuthBundle({ scope: 'onboarding' });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
return renderPage(getPlayerPageTemplate(), { return renderPage(getPlayerPageTemplate(), {
title: 'Onboard player', title: 'Onboard player',
bodyClass: 'onboarding-page', bodyClass: 'onboarding-page',
body: renderOnboardingLandingBody(), body: renderOnboardingLandingBody(),
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript() script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript()
}); });
} }
function renderPlayerOnboardingFormPage(deviceId) { function renderPlayerOnboardingFormPage(deviceId) {
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding', deviceId: String(deviceId || '').trim() }); const pageAuthToken = createPageAuthBundle({ scope: 'onboarding', deviceId: String(deviceId || '').trim() });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
return renderPage(getPlayerPageTemplate(), { return renderPage(getPlayerPageTemplate(), {
title: 'Onboard screen', title: 'Onboard screen',
bodyClass: 'onboarding-page', bodyClass: 'onboarding-page',
body: renderOnboardingFormBody(deviceId), body: renderOnboardingFormBody(deviceId),
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingFormScript(deviceId) script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingFormScript(deviceId)
}); });
} }
+62 -5
View File
@@ -1,7 +1,9 @@
// Player route registration and filesystem-backed media helpers.
const fs = require('fs'); const fs = require('fs');
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth'); const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
const { buildThumbnailPreviewData } = require('./thumbnail-preview'); const { buildThumbnailPreviewData } = require('./thumbnail-preview');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED']; const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
@@ -18,6 +20,9 @@ function registerPlayerRoutes(app, options) {
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null; const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : 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) { if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) {
throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and 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', 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('/media', express.static(mediaDir));
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist'))); app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
@@ -114,7 +120,7 @@ function registerPlayerRoutes(app, options) {
}); });
}); });
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '100mb' }), requireRequestAuth, async function (req, res, next) { app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
try { try {
const filePath = resolveMediaFilePath(req.params.filename); const filePath = resolveMediaFilePath(req.params.filename);
if (!filePath) { if (!filePath) {
@@ -207,6 +213,13 @@ function registerPlayerRoutes(app, options) {
app.get('/screen/:slug', function (req, res) { app.get('/screen/:slug', function (req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache'); 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) { playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) {
res.send(common.renderPlayerPage(req.params.slug, data)); res.send(common.renderPlayerPage(req.params.slug, data));
}).catch(function (error) { }).catch(function (error) {
@@ -246,6 +259,11 @@ function registerPlayerRoutes(app, options) {
}); });
} }
if (typeof common.fetchTimetablesData === 'function') {
const timetableData = await common.fetchTimetablesData(pool);
data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data)); res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
} catch (error) { } catch (error) {
@@ -273,13 +291,52 @@ 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) { app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], requireRequestAuth, async function (req, res, next) {
try { try {
const connections = playerRuntime.snapshotConnections(req.params.slug); const connections = playerRuntime.snapshotConnections(req.params.slug);
let screen = null; let screen = null;
let screenLookupFailed = false; let screenLookupFailed = false;
try { try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]); const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null; screen = screenRows[0] || null;
} catch (error) { } catch (error) {
screenLookupFailed = isTransientDbError(error); screenLookupFailed = isTransientDbError(error);
@@ -309,7 +366,7 @@ function registerPlayerRoutes(app, options) {
if (!command) { if (!command) {
return res.status(400).json({ error: 'Command is required' }); return res.status(400).json({ error: 'Command is required' });
} }
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right', 'setclientname'].indexOf(command) === -1) { if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) === -1) {
return res.status(400).json({ error: 'Unsupported command' }); return res.status(400).json({ error: 'Unsupported command' });
} }
@@ -319,7 +376,7 @@ function registerPlayerRoutes(app, options) {
let screenLookupFailed = false; let screenLookupFailed = false;
if (!isRedirectCommand) { if (!isRedirectCommand) {
try { try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]); const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null; screen = screenRows[0] || null;
} catch (error) { } catch (error) {
screenLookupFailed = isTransientDbError(error); screenLookupFailed = isTransientDbError(error);
+141 -7
View File
@@ -1,7 +1,23 @@
// Player runtime state, websocket connections, and request-auth enforcement.
const crypto = require('crypto'); const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws'); const { WebSocketServer, WebSocket } = require('ws');
const { isClientNameAvailable } = require('../data/client-name-check'); const { isClientNameAvailable } = require('#src/data/client-name-check');
const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth'); const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_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) { function createPlayerRuntime(options) {
const pool = options && options.pool ? options.pool : null; const pool = options && options.pool ? options.pool : null;
@@ -12,6 +28,7 @@ function createPlayerRuntime(options) {
}; };
const connectionsBySlug = new Map(); const connectionsBySlug = new Map();
const dashboardListenersBySlug = new Map(); const dashboardListenersBySlug = new Map();
const announcementListenersBySlug = new Map();
const wss = new WebSocketServer({ noServer: true }); const wss = new WebSocketServer({ noServer: true });
function normalizeClientIp(value) { function normalizeClientIp(value) {
@@ -27,6 +44,34 @@ function createPlayerRuntime(options) {
return ip; return ip;
} }
function parseCookies(cookieHeader) {
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
if (!pair) {
return cookies;
}
const separatorIndex = pair.indexOf('=');
if (separatorIndex === -1) {
return cookies;
}
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
if (name) {
cookies[name] = value;
}
return cookies;
}, {});
}
function readPageAuthToken(request) {
const queryToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
if (queryToken) {
return queryToken;
}
const cookies = parseCookies(request.headers && request.headers.cookie || '');
return String(cookies[PAGE_AUTH_COOKIE_NAME] || '').trim();
}
function getConnectionBucket(slug) { function getConnectionBucket(slug) {
const key = String(slug || '').trim(); const key = String(slug || '').trim();
if (!key) { if (!key) {
@@ -72,6 +117,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) { function buildClientLabel(connection) {
const clientName = String(connection.clientName || '').trim(); const clientName = String(connection.clientName || '').trim();
const clientId = String(connection.clientId || '').trim(); const clientId = String(connection.clientId || '').trim();
@@ -123,6 +191,7 @@ function createPlayerRuntime(options) {
userAgent: connection.userAgent || null, userAgent: connection.userAgent || null,
viewport: connection.viewport || null, viewport: connection.viewport || null,
page: connection.page || null, page: connection.page || null,
playerPublicBaseUrl: connection.playerPublicBaseUrl || null,
currentSlide: connection.currentSlide || null, currentSlide: connection.currentSlide || null,
paused: Boolean(connection.paused), paused: Boolean(connection.paused),
blackout: Boolean(connection.blackout), blackout: Boolean(connection.blackout),
@@ -178,6 +247,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) { async function sendCommandToConnection(slug, connectionId, commandOrPayload) {
const bucket = connectionsBySlug.get(String(slug || '').trim()); const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket || !bucket.size) { if (!bucket || !bucket.size) {
@@ -235,7 +328,9 @@ function createPlayerRuntime(options) {
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/); const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/); const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
if (!dashboardMatch && !playerMatch) { const announcementMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/announcements$/);
if (!dashboardMatch && !playerMatch && !announcementMatch) {
socket.destroy(); socket.destroy();
return; return;
} }
@@ -248,7 +343,7 @@ function createPlayerRuntime(options) {
} }
if (playerMatch) { if (playerMatch) {
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim(); const authToken = readPageAuthToken(request);
const payload = verifyPageAuthToken(authToken); const payload = verifyPageAuthToken(authToken);
if (!payload || String(payload.scope || '').trim() !== 'player') { if (!payload || String(payload.scope || '').trim() !== 'player') {
socket.destroy(); socket.destroy();
@@ -256,9 +351,18 @@ function createPlayerRuntime(options) {
} }
} }
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]); if (announcementMatch) {
const authToken = readPageAuthToken(request);
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.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 +392,30 @@ function createPlayerRuntime(options) {
return; 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 remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]); const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]);
const normalizedRemoteAddress = normalizeClientIp(remoteAddress); const normalizedRemoteAddress = normalizeClientIp(remoteAddress);
@@ -304,6 +432,7 @@ function createPlayerRuntime(options) {
page: null, page: null,
paused: false, paused: false,
blackout: false, blackout: false,
playerPublicBaseUrl: null,
clientIp: forwardedFor || normalizedRemoteAddress, clientIp: forwardedFor || normalizedRemoteAddress,
remoteAddress: normalizedRemoteAddress, remoteAddress: normalizedRemoteAddress,
label: forwardedFor || normalizedRemoteAddress || 'connected client', label: forwardedFor || normalizedRemoteAddress || 'connected client',
@@ -328,7 +457,7 @@ function createPlayerRuntime(options) {
return; return;
} }
if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) { if (!payload || payload.type !== 'state') {
return; return;
} }
@@ -341,6 +470,10 @@ function createPlayerRuntime(options) {
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent; connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport; connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
connection.page = payload.page ? String(payload.page).trim() : connection.page; 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.paused = Boolean(payload.paused);
connection.blackout = Boolean(payload.blackout); connection.blackout = Boolean(payload.blackout);
connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp; connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp;
@@ -372,6 +505,7 @@ function createPlayerRuntime(options) {
return { return {
installWebsocket: installWebsocket, installWebsocket: installWebsocket,
broadcastAnnouncementRefresh: broadcastAnnouncementRefresh,
snapshotConnections: snapshotConnections, snapshotConnections: snapshotConnections,
snapshotAllConnections: snapshotAllConnections, snapshotAllConnections: snapshotAllConnections,
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen, isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
+3
View File
@@ -1,3 +1,5 @@
// Thumbnail preview data and bootstrap script helpers for slide thumbnails.
function buildThumbnailPreviewData(slide) { function buildThumbnailPreviewData(slide) {
return { return {
thumbnailPreview: true, thumbnailPreview: true,
@@ -13,6 +15,7 @@ function buildThumbnailPreviewData(slide) {
slides: [slide], slides: [slide],
rssFeeds: [], rssFeeds: [],
apiSources: [], apiSources: [],
timetableGroups: [],
revision: String(slide.modified_at || slide.id || Date.now()) revision: String(slide.modified_at || slide.id || Date.now())
}; };
} }
+119 -49
View File
@@ -1,10 +1,15 @@
// Permission section definitions and normalization helpers for RBAC.
const PERMISSION_SECTIONS = [ const PERMISSION_SECTIONS = [
{
sectionName: 'Main navigation',
order: 10,
permissions: [
{ {
key: 'dashboard', key: 'dashboard',
order: 10, order: 10,
name: 'Dashboard', name: 'Dashboard',
sectionName: 'Main navigation', permissions: [
actions: [
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' }, { key: 'read', name: 'Read', description: 'Access the dashboard overview.' },
{ key: 'allow', name: 'Allow', description: 'Send global player commands.' } { key: 'allow', name: 'Allow', description: 'Send global player commands.' }
] ]
@@ -13,18 +18,22 @@ const PERMISSION_SECTIONS = [
key: 'clients', key: 'clients',
order: 20, order: 20,
name: 'Connected clients', name: 'Connected clients',
sectionName: 'Main navigation', permissions: [
actions: [
{ key: 'read', name: 'Read', description: 'View connected player clients and live status.' }, { key: 'read', name: 'Read', description: 'View connected player clients and live status.' },
{ key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' } { key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' }
] ]
}
]
}, },
{ {
key: 'screens',
order: 30,
name: 'Screens',
sectionName: 'Content', sectionName: 'Content',
actions: [ order: 20,
permissions: [
{
key: 'screens',
order: 10,
name: 'Screens',
permissions: [
{ key: 'read', name: 'Read', description: 'View the screen list and open screen details.' }, { key: 'read', name: 'Read', description: 'View the screen list and open screen details.' },
{ key: 'create', name: 'Create', description: 'Create new screens.' }, { key: 'create', name: 'Create', description: 'Create new screens.' },
{ key: 'update', name: 'Update', description: 'Update screens.' }, { key: 'update', name: 'Update', description: 'Update screens.' },
@@ -33,10 +42,9 @@ const PERMISSION_SECTIONS = [
}, },
{ {
key: 'playlists', key: 'playlists',
order: 40, order: 20,
name: 'Playlists', name: 'Playlists',
sectionName: 'Content', permissions: [
actions: [
{ key: 'read', name: 'Read', description: 'View playlists and playlist contents.' }, { key: 'read', name: 'Read', description: 'View playlists and playlist contents.' },
{ key: 'create', name: 'Create', description: 'Create new playlists.' }, { key: 'create', name: 'Create', description: 'Create new playlists.' },
{ key: 'update', name: 'Update', description: 'Update playlists and playlist slides.' }, { key: 'update', name: 'Update', description: 'Update playlists and playlist slides.' },
@@ -45,10 +53,9 @@ const PERMISSION_SECTIONS = [
}, },
{ {
key: 'slides', key: 'slides',
order: 50, order: 30,
name: 'Slides', name: 'Slides',
sectionName: 'Content', permissions: [
actions: [
{ key: 'read', name: 'Read', description: 'View slides.' }, { key: 'read', name: 'Read', description: 'View slides.' },
{ key: 'create', name: 'Create', description: 'Create new slides.' }, { key: 'create', name: 'Create', description: 'Create new slides.' },
{ key: 'update', name: 'Update', description: 'Update slide content.' }, { key: 'update', name: 'Update', description: 'Update slide content.' },
@@ -57,10 +64,9 @@ const PERMISSION_SECTIONS = [
}, },
{ {
key: 'templates', key: 'templates',
order: 60, order: 40,
name: 'Slide templates', name: 'Slide templates',
sectionName: 'Content', permissions: [
actions: [
{ key: 'read', name: 'Read', description: 'View slide templates.' }, { key: 'read', name: 'Read', description: 'View slide templates.' },
{ key: 'create', name: 'Create', description: 'Create new slide templates.' }, { key: 'create', name: 'Create', description: 'Create new slide templates.' },
{ key: 'update', name: 'Update', description: 'Update slide templates.' }, { key: 'update', name: 'Update', description: 'Update slide templates.' },
@@ -69,10 +75,9 @@ const PERMISSION_SECTIONS = [
}, },
{ {
key: 'canvas-sizes', key: 'canvas-sizes',
order: 70, order: 50,
name: 'Canvas sizes', name: 'Canvas sizes',
sectionName: 'Content', permissions: [
actions: [
{ key: 'read', name: 'Read', description: 'View canvas sizes.' }, { key: 'read', name: 'Read', description: 'View canvas sizes.' },
{ key: 'create', name: 'Create', description: 'Create new canvas sizes.' }, { key: 'create', name: 'Create', description: 'Create new canvas sizes.' },
{ key: 'update', name: 'Update', description: 'Update canvas sizes.' }, { key: 'update', name: 'Update', description: 'Update canvas sizes.' },
@@ -80,11 +85,27 @@ const PERMISSION_SECTIONS = [
] ]
}, },
{ {
key: 'rss-feeds', key: 'announcements',
order: 75, order: 60,
name: 'RSS feeds', name: 'Announcements',
permissions: [
{ 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.' }
]
}
]
},
{
sectionName: 'Data Sources', sectionName: 'Data Sources',
actions: [ order: 30,
permissions: [
{
key: 'rss-feeds',
order: 10,
name: 'RSS feeds',
permissions: [
{ key: 'read', name: 'Read', description: 'View configured RSS feeds.' }, { key: 'read', name: 'Read', description: 'View configured RSS feeds.' },
{ key: 'create', name: 'Create', description: 'Create new RSS feeds.' }, { key: 'create', name: 'Create', description: 'Create new RSS feeds.' },
{ key: 'update', name: 'Update', description: 'Update RSS feeds.' }, { key: 'update', name: 'Update', description: 'Update RSS feeds.' },
@@ -93,10 +114,9 @@ const PERMISSION_SECTIONS = [
}, },
{ {
key: 'api-sources', key: 'api-sources',
order: 76, order: 20,
name: 'API sources', name: 'API sources',
sectionName: 'Data Sources', permissions: [
actions: [
{ key: 'read', name: 'Read', description: 'View configured API sources.' }, { key: 'read', name: 'Read', description: 'View configured API sources.' },
{ key: 'create', name: 'Create', description: 'Create new API sources.' }, { key: 'create', name: 'Create', description: 'Create new API sources.' },
{ key: 'update', name: 'Update', description: 'Update API sources.' }, { key: 'update', name: 'Update', description: 'Update API sources.' },
@@ -104,21 +124,27 @@ const PERMISSION_SECTIONS = [
] ]
}, },
{ {
key: 'background-tasks', key: 'timetables',
order: 100, order: 30,
name: 'Background tasks', name: 'Timetables',
sectionName: 'Settings', permissions: [
actions: [ { key: 'read', name: 'Read', description: 'View configured timetable groups.' },
{ key: 'read', name: 'Read', description: 'View background tasks and scheduled refreshes.' }, { key: 'create', name: 'Create', description: 'Create new timetable groups.' },
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' } { key: 'update', name: 'Update', description: 'Update timetable groups and entries.' },
{ key: 'delete', name: 'Delete', description: 'Delete timetable groups.' }
]
}
] ]
}, },
{ {
key: 'users',
order: 80,
name: 'Users',
sectionName: 'Settings', sectionName: 'Settings',
actions: [ order: 40,
permissions: [
{
key: 'users',
order: 10,
name: 'Users',
permissions: [
{ key: 'read', name: 'Read', description: 'View users and role assignments.' }, { key: 'read', name: 'Read', description: 'View users and role assignments.' },
{ key: 'create', name: 'Create', description: 'Create new users.' }, { key: 'create', name: 'Create', description: 'Create new users.' },
{ key: 'update', name: 'Update', description: 'Update users, passwords, and role assignments.' }, { key: 'update', name: 'Update', description: 'Update users, passwords, and role assignments.' },
@@ -127,32 +153,66 @@ const PERMISSION_SECTIONS = [
}, },
{ {
key: 'rbac', key: 'rbac',
order: 90, order: 20,
name: 'Roles and permissions', name: 'Roles and permissions',
sectionName: 'Settings', permissions: [
actions: [
{ key: 'read', name: 'Read', description: 'View roles and permissions.' }, { key: 'read', name: 'Read', description: 'View roles and permissions.' },
{ key: 'create', name: 'Create', description: 'Create new roles.' }, { key: 'create', name: 'Create', description: 'Create new roles.' },
{ key: 'update', name: 'Update', description: 'Update role details and permissions.' }, { key: 'update', name: 'Update', description: 'Update role details and permissions.' },
{ key: 'delete', name: 'Delete', description: 'Delete roles.' } { key: 'delete', name: 'Delete', description: 'Delete roles.' }
] ]
},
{
key: 'fonts',
order: 30,
name: 'Fonts',
permissions: [
{ 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: 40,
name: 'Task Queue',
permissions: [
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
]
},
{
key: 'scheduled-tasks',
order: 50,
name: 'Scheduled tasks',
permissions: [
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
]
}
]
} }
]; ];
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) { const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) {
return section.actions.map(function (action) { return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource) {
return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, index) {
return { return {
key: `${section.key}.${action.key}`, key: `${resource.key}.${action.key}`,
name: section.name, name: resource.name,
sectionOrder: section.order, sectionOrder: section.order,
actionName: action.name, actionName: action.name,
permissionOrder: index + 1,
sectionName: section.sectionName, sectionName: section.sectionName,
sectionKey: section.key, resourceKey: resource.key,
resourceOrder: resource.order,
resourceName: resource.name,
actionKey: action.key, actionKey: action.key,
description: action.description description: action.description
}; };
}); });
}); });
});
const DEFAULT_ROLE = { const DEFAULT_ROLE = {
key: 'administrators', key: 'administrators',
@@ -164,6 +224,8 @@ function normalizePermissionKey(permissionKey) {
return String(permissionKey || '').trim(); return String(permissionKey || '').trim();
} }
const PERMISSION_DENIED_MESSAGE = 'You do not have permissions to do this.';
function normalizePermissionKeys(permissionKeys) { function normalizePermissionKeys(permissionKeys) {
const normalized = []; const normalized = [];
@@ -226,22 +288,29 @@ function hasAnyPermission(currentUser, permissionKeys) {
}); });
} }
function requirePermission(permissionKey) { function requirePermission(permissionKey, options) {
const normalizedPermissionKey = normalizePermissionKey(permissionKey); const normalizedPermissionKey = normalizePermissionKey(permissionKey);
if (!normalizedPermissionKey) { if (!normalizedPermissionKey) {
throw new Error('requirePermission requires a permission key.'); throw new Error('requirePermission requires a permission key.');
} }
const setAuthMessageCookie = options && options.setAuthMessageCookie;
return function (req, res, next) { return function (req, res, next) {
if (!req.currentUser) { 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)) { if (hasPermission(req.currentUser, normalizedPermissionKey)) {
return next(); return next();
} }
const error = new Error('You do not have permission to access this area.'); const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403; error.statusCode = 403;
error.expose = true; error.expose = true;
next(error); next(error);
@@ -255,5 +324,6 @@ module.exports = {
hasPermission, hasPermission,
hasAnyPermission, hasAnyPermission,
requirePermission, requirePermission,
normalizePermissionKeys normalizePermissionKeys,
PERMISSION_DENIED_MESSAGE
}; };
+24 -1
View File
@@ -1,3 +1,5 @@
// Page and request authentication signing helpers.
const crypto = require('crypto'); const crypto = require('crypto');
const PAGE_TOKEN_HEADER = 'x-pulse-page-auth'; const PAGE_TOKEN_HEADER = 'x-pulse-page-auth';
@@ -244,12 +246,13 @@ function verifyRequestAuth(req) {
return timingSafeEqualHex(expectedSignature, signature); return timingSafeEqualHex(expectedSignature, signature);
} }
function createPageFetchAuthScript(token) { function createPageFetchAuthScript(token, cookiePath) {
const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim(); const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim();
if (!normalizedToken) { if (!normalizedToken) {
return ''; return '';
} }
const normalizedCookiePath = String(cookiePath || '').trim();
const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt)) const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt))
? Number(token.expiresAt) ? Number(token.expiresAt)
: null; : null;
@@ -260,6 +263,8 @@ function createPageFetchAuthScript(token) {
' (function () {', ' (function () {',
' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';', ' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';',
' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';', ' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';',
' var pageAuthCookiePath = ' + JSON.stringify(normalizedCookiePath) + ';',
' var pageAuthCookieName = "pulse_page_auth";',
' var pageAuthRenewalTimer = null;', ' var pageAuthRenewalTimer = null;',
' var pageAuthRenewalInFlight = null;', ' var pageAuthRenewalInFlight = null;',
' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';', ' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';',
@@ -285,11 +290,28 @@ function createPageFetchAuthScript(token) {
' });', ' });',
' }, delayMs);', ' }, delayMs);',
' }', ' }',
' function writePageAuthCookie(nextToken, nextExpiresAt) {',
' if (!pageAuthCookiePath) {',
' return;',
' }',
' var cookieParts = [pageAuthCookieName + "=" + encodeURIComponent(String(nextToken || "").trim()), "Path=" + pageAuthCookiePath, "SameSite=Lax"];',
' var expiresInMs = Number(nextExpiresAt || 0) - Date.now();',
' if (Number.isFinite(expiresInMs) && expiresInMs > 0) {',
' cookieParts.push("Max-Age=" + Math.max(1, Math.floor(expiresInMs / 1000)));',
' } else {',
' cookieParts.push("Max-Age=0");',
' }',
' if (window.location.protocol === "https:") {',
' cookieParts.push("Secure");',
' }',
' document.cookie = cookieParts.join("; ");',
' }',
' function setPageAuthToken(nextToken, nextExpiresAt) {', ' function setPageAuthToken(nextToken, nextExpiresAt) {',
' pageAuthToken = String(nextToken || "").trim();', ' pageAuthToken = String(nextToken || "").trim();',
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;', ' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
' window.__pulsePageAuthToken = pageAuthToken;', ' window.__pulsePageAuthToken = pageAuthToken;',
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;', ' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
' schedulePageAuthRenewal();', ' schedulePageAuthRenewal();',
' }', ' }',
' async function renewPageAuthToken() {', ' async function renewPageAuthToken() {',
@@ -324,6 +346,7 @@ function createPageFetchAuthScript(token) {
' window.__pulseRenewPageAuthToken = renewPageAuthToken;', ' window.__pulseRenewPageAuthToken = renewPageAuthToken;',
' window.__pulsePageAuthToken = pageAuthToken;', ' window.__pulsePageAuthToken = pageAuthToken;',
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;', ' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
' window.addEventListener("focus", function () {', ' window.addEventListener("focus", function () {',
' schedulePageAuthRenewal();', ' schedulePageAuthRenewal();',
' });', ' });',
+96 -358
View File
@@ -1,30 +1,30 @@
// Main web application bootstrap, route wiring, and lifecycle setup.
const express = require('express'); const express = require('express');
const http = require('http'); const http = require('http');
const multer = require('multer'); const multer = require('multer');
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const common = require('./common'); const common = require('./common');
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth'); const { verifyPassword, createSessionToken, hashSessionToken, hashPassword, validatePasswordStrength } = require('#src/auth');
const pages = require('./web/pages'); const pages = require('#src/web/pages');
const registerAuthRoutes = require('./web/routes/auth'); const registerMiddleware = require('#src/web/middleware');
const registerAdminPagesRoutes = require('./web/routes/admin/pages'); const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
const registerAdminAccountRoutes = require('./web/routes/admin/account'); const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks');
const registerAdminUsersRoutes = require('./web/routes/admin/users'); const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
const registerAdminManageRoutes = require('./web/routes/admin/manage'); const { captureSlideThumbnail } = require('#src/web/lib/media');
const registerAdminScreenCommandRoutes = require('./web/routes/admin/client-commands'); const { createWebBootstrap } = require('#src/web/bootstrap');
const registerAdminContentRoutes = require('./web/routes/admin/content'); const { registerRoutes } = require('#src/web/routes/register');
const registerAdminDataSourceRoutes = require('./web/routes/data-sources'); const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens');
const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks'); const { initializeWebServer } = require('#src/web/lib/web-startup');
const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue'); const { createWebConfig } = require('#src/web/lib/config');
const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails'); const { requirePermission: createRequirePermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh'); const { rbacData } = require('#src/web/lib/auth');
const { createWebBootstrap } = require('./web/bootstrap'); const { createPlayerActionService } = require('#src/web/lib/player-actions');
const { requirePermission } = require('./rbac'); const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const rbacData = require('./web/lib/rbac-data'); const { createSessionService } = require('#src/web/lib/auth');
const { createPlayerActionService } = require('./web/lib/player-actions'); const { hasAnyPermission } = require('#src/rbac');
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check'); const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
const { createSessionService } = require('./web/lib/session');
const { const {
formatDashboardDate, formatDashboardDate,
readArrayField, readArrayField,
@@ -33,305 +33,135 @@ const {
normalizeScheduleMode, normalizeScheduleMode,
getAuditUserId, getAuditUserId,
getCanvasSignature, getCanvasSignature,
fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature, fetchPlaylistCanvasSignature,
fetchScreensByPlaylistId, fetchScreensByPlaylistId,
fetchScreensBySlideId, fetchScreensBySlideId,
fetchScreensByTemplateId, fetchScreensByTemplateId,
fetchOrderedPlaylistSlides, fetchOrderedPlaylistSlides,
redirectAfterSave redirectAfterSave
} = require('./web/lib/helpers'); } = require('#src/web/lib/helpers');
const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, '');
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').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;
async function start() { async function start() {
// Core app and filesystem setup.
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const pool = common.createPool(); const pool = common.createPool();
const PORT = Number(process.env.WEB_PORT || 3000); const webConfig = createWebConfig();
const MEDIA_DIR = path.join(__dirname, '..', 'media');
const UPLOADS_DIR = path.join(MEDIA_DIR, 'uploads'); // Background services and request forwarding helpers.
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 backgroundTaskQueue = createBackgroundTaskQueue({ const backgroundTaskQueue = createBackgroundTaskQueue({
pool: pool, pool: pool,
maxConcurrent: 1 maxConcurrent: 1
}); });
const playerActionService = createPlayerActionService({ const dataSourceTasks = createDataSourceTaskService({
pool: pool,
common: common, common: common,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL backgroundTaskQueue: backgroundTaskQueue
}); });
const notifyPlayerScreens = function (slugs, commandOrPayload) { const playerActionService = createPlayerActionService({
const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) { pool: pool,
return String(slug || '').trim(); common: common,
}).filter(Boolean))); playerInternalBaseUrl: webConfig.playerInternalBaseUrl
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;
}); });
}; const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
// Centralized dashboard/player bootstrap.
const webBootstrap = createWebBootstrap({ const webBootstrap = createWebBootstrap({
pool: pool, pool: pool,
common: common, common: common,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, playerInternalBaseUrl: webConfig.playerInternalBaseUrl,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, playerPublicBaseUrl: webConfig.playerPublicBaseUrl,
uploadDir: UPLOADS_DIR, uploadDir: webConfig.uploadsDir,
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
formatDashboardDate: formatDashboardDate, formatDashboardDate: formatDashboardDate,
notifyPlayerScreens: notifyPlayerScreens, notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue backgroundTaskQueue: backgroundTaskQueue,
hasAnyPermission: hasAnyPermission,
}); });
const upload = webBootstrap.upload; const upload = webBootstrap.upload;
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide; const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate; const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload; const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles;
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange; const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
const runMediaSyncTask = webBootstrap.runMediaSyncTask; // Background task initialization.
// Session and auth helpers for route registration.
const broadcastDashboardState = webBootstrap.broadcastDashboardState; const broadcastDashboardState = webBootstrap.broadcastDashboardState;
const sessionService = createSessionService({ const sessionService = createSessionService({
sessionCookieName: SESSION_COOKIE_NAME, sessionCookieName: webConfig.sessionCookieName,
sessionMaxAgeMs: SESSION_MAX_AGE_MS, sessionMaxAgeMs: webConfig.sessionMaxAgeMs,
hashSessionToken: hashSessionToken, hashSessionToken: hashSessionToken,
createSessionToken: createSessionToken createSessionToken: createSessionToken
}); });
const parseCookies = sessionService.parseCookies; const parseCookies = sessionService.parseCookies;
const createUserSession = sessionService.createUserSession;
const clearSessionCookie = sessionService.clearSessionCookie; const clearSessionCookie = sessionService.clearSessionCookie;
const setSessionCookie = sessionService.setSessionCookie; const setSessionCookie = sessionService.setSessionCookie;
const setAuthMessageCookie = sessionService.setAuthMessageCookie;
const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie;
const loadCurrentUser = sessionService.loadCurrentUser; const loadCurrentUser = sessionService.loadCurrentUser;
const createUserSession = sessionService.createUserSession;
const requireAuth = sessionService.requireAuth; const requireAuth = sessionService.requireAuth;
// Middleware and route registration.
backgroundTaskQueue.setTaskHandler('media-sync', function (task) { registerMiddleware(app, {
return runMediaSyncTask(task && task.payload ? task.payload : task); pool: pool,
loadCurrentUser: loadCurrentUser,
requireAuth: requireAuth,
MEDIA_DIR: webConfig.mediaDir,
UPLOADS_DIR: webConfig.uploadsDir,
THUMBNAILS_DIR: webConfig.thumbnailsDir,
ASSET_DIR: webConfig.assetDir
}); });
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) { registerRoutes(app, {
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, pool: pool,
common: common, common: common,
mediaDir: MEDIA_DIR,
baseUrl: PLAYER_INTERNAL_BASE_URL,
slideId: slideId,
previousThumbnailPath: payload.previousThumbnailPath || null
});
});
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, {
pool: pool,
pages: pages, pages: pages,
upload: upload,
uploadDir: webConfig.uploadsDir,
createUserSession: createUserSession, createUserSession: createUserSession,
setSessionCookie: setSessionCookie, setSessionCookie: setSessionCookie,
clearSessionCookie: clearSessionCookie, clearSessionCookie: clearSessionCookie,
setAuthMessageCookie: setAuthMessageCookie,
consumeAuthMessageCookie: consumeAuthMessageCookie,
parseCookies: parseCookies, parseCookies: parseCookies,
hashSessionToken: hashSessionToken, hashSessionToken: hashSessionToken,
verifyPassword: verifyPassword, verifyPassword: verifyPassword,
sessionCookieName: SESSION_COOKIE_NAME sessionCookieName: webConfig.sessionCookieName,
});
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,
formatDashboardDate: formatDashboardDate, formatDashboardDate: formatDashboardDate,
getAuditUserId: getAuditUserId, getAuditUserId: getAuditUserId,
hashPassword: hashPassword, hashPassword: hashPassword,
validatePasswordStrength: validatePasswordStrength,
readArrayField: readArrayField, readArrayField: readArrayField,
rbacData: rbacData, rbacData: rbacData,
requirePermission: requirePermission
});
registerAdminManageRoutes(app, {
pool: pool,
common: common,
pages: pages,
fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides, fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides,
fetchScreensByPlaylistId: fetchScreensByPlaylistId, fetchScreensByPlaylistId: fetchScreensByPlaylistId,
fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId,
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature, fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
getCanvasSignature: getCanvasSignature, getCanvasSignature: getCanvasSignature,
normalizeScheduleMode: normalizeScheduleMode, normalizeScheduleMode: normalizeScheduleMode,
parseDateTimeLocal: parseDateTimeLocal, parseDateTimeLocal: parseDateTimeLocal,
parseTimeLocal: parseTimeLocal, parseTimeLocal: parseTimeLocal,
readArrayField: readArrayField,
getAuditUserId: getAuditUserId,
redirectAfterSave: redirectAfterSave, redirectAfterSave: redirectAfterSave,
notifyPlayerScreens: notifyPlayerScreens, notifyPlayerScreens: notifyPlayerScreens,
broadcastDashboardState: broadcastDashboardState, broadcastDashboardState: broadcastDashboardState,
getScreenDeleteBlockMessage: playerActionService.getScreenDeleteBlockMessage, dataSourceTasks: dataSourceTasks,
getScreenConnections: playerActionService.getScreenConnections, playerActionService: playerActionService,
getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage,
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
requirePermission: requirePermission
});
registerAdminScreenCommandRoutes(app, {
pool: pool,
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
getScreenConnections: playerActionService.getScreenConnections,
isClientNameAvailable: isClientNameAvailable, isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation, withClientNameReservation: withClientNameReservation,
requirePermission: requirePermission requirePermission: function (permissionKey, options) {
}); return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options));
},
const registerAdminRbacRoutes = require('./web/routes/admin/rbac'); hasAnyPermission: hasAnyPermission,
registerAdminRbacRoutes(app, { backgroundTaskQueue: backgroundTaskQueue,
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,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
removeUnusedUploadFiles: removeUnusedUploadFiles,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
getAuditUserId: getAuditUserId, buildDashboardState: webBootstrap.buildDashboardState
redirectAfterSave: redirectAfterSave,
notifyPlayerScreens: notifyPlayerScreens,
broadcastDashboardState: broadcastDashboardState,
backgroundTaskQueue: backgroundTaskQueue,
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));
}); });
app.use(function (error, req, res, _next) { app.use(function (error, req, res, _next) {
@@ -343,7 +173,7 @@ async function start() {
if (wantsHtml && pages.renderErrorPage) { if (wantsHtml && pages.renderErrorPage) {
const isPermissionError = statusCode === 403; const isPermissionError = statusCode === 403;
const message = isPermissionError const message = isPermissionError
? String(error && error.message ? error.message : 'You do not have permission to access this area.') ? String(error && error.message ? error.message : PERMISSION_DENIED_MESSAGE)
: String(error && error.message ? error.message : 'An unexpected error occurred.'); : String(error && error.message ? error.message : 'An unexpected error occurred.');
const title = isPermissionError const title = isPermissionError
? 'Access denied' ? 'Access denied'
@@ -365,116 +195,24 @@ async function start() {
res.status(statusCode).send(statusCode >= 500 ? 'Internal server error' : String(error && error.message ? error.message : 'Error')); 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. // Bootstrap and startup tasks.
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR }); fs.mkdirSync(webConfig.mediaDir, { recursive: true });
await backgroundTaskQueue.initialize(); await ensureFontLibrary(webConfig.mediaDir);
await initializeWebServer({
async function syncRecurringRefreshes() { common: common,
const apiSourcesData = await common.fetchApiSourcesData(pool); pool: pool,
(apiSourcesData.apiSources || []).forEach(function (apiSource) { mediaDir: webConfig.mediaDir,
backgroundTaskQueue.registerRecurringTask({ backgroundTaskQueue: backgroundTaskQueue,
key: 'api-source-refresh:' + Number(apiSource.id), webBootstrap: webBootstrap,
title: 'API source refresh', loadCurrentUser: loadCurrentUser,
category: 'data-source', initializeBackgroundTasks: initializeBackgroundTasks,
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit), captureSlideThumbnail: captureSlideThumbnail,
metadata: { server: server,
sourceType: 'api-source', dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs
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); server.listen(webConfig.port, function () {
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { console.log(`Pulse Signage app listening on port ${webConfig.port}`);
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 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 * DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS;
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);
});
}
await syncRecurringRefreshes();
scheduleInitialDataSourceRefreshes().catch(function (error) {
console.warn('Unable to schedule startup data source refreshes:', error);
});
fs.mkdirSync(MEDIA_DIR, { recursive: true });
await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) {
console.warn('Unable to sync existing media to player:', error);
});
webBootstrap.installDashboardWebsocket(server, loadCurrentUser);
server.listen(PORT, function () {
console.log(`Pulse Signage app listening on port ${PORT}`);
}); });
} }
+66 -10
View File
@@ -1,15 +1,17 @@
// Web-to-player websocket bridge and dashboard state synchronizer.
const { WebSocketServer, WebSocket } = require('ws'); const { WebSocketServer, WebSocket } = require('ws');
const { createDashboardStateService } = require('./lib/dashboard-state'); const { createDashboardStateService } = require('./lib/dashboard-state');
const { createUploadSyncService } = require('./lib/upload-sync'); const { createUploadSyncService } = require('./lib/media');
const { createRequestAuthHeaders } = require('../request-auth'); const { createRequestAuthHeaders } = require('#src/request-auth');
function createWebBootstrap(options) { function createWebBootstrap(options) {
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; 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 playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
const uploadDir = String(options && options.uploadDir || '').trim(); const uploadDir = String(options && options.uploadDir || '').trim();
const dashboardRefreshIntervalMs = Number(options && options.dashboardRefreshIntervalMs || 2000); const dashboardRefreshIntervalMs = 5000;
const formatDashboardDate = options && options.formatDashboardDate; const formatDashboardDate = options && options.formatDashboardDate;
const notifyPlayerScreens = options && options.notifyPlayerScreens; const notifyPlayerScreens = options && options.notifyPlayerScreens;
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
@@ -24,9 +26,54 @@ function createWebBootstrap(options) {
const playerSnapshotSockets = new Map(); const playerSnapshotSockets = new Map();
let dashboardRefreshInFlight = null; let dashboardRefreshInFlight = null;
let broadcastDashboardState = null; let broadcastDashboardState = null;
let playerInternalBaseUrl = null;
let playerInternalBaseUrlPromise = null;
function getPlayerSnapshotSocketUrl(slug) { async function getPlayerInternalBaseUrl() {
const url = new URL(playerInternalBaseUrl.replace(/^http/, 'ws')); 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.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
url.search = ''; url.search = '';
return url.toString(); return url.toString();
@@ -53,11 +100,13 @@ function createWebBootstrap(options) {
return; return;
} }
const socketUrl = getPlayerSnapshotSocketUrl(key); playerSnapshotSockets.set(key, null);
const socketUrlPromise = getPlayerSnapshotSocketUrl(key);
const authHeaders = createRequestAuthHeaders({ const authHeaders = createRequestAuthHeaders({
method: 'GET', method: 'GET',
pathname: `/ws/screens/${encodeURIComponent(key)}/events` pathname: `/ws/screens/${encodeURIComponent(key)}/events`
}); });
socketUrlPromise.then(function (socketUrl) {
const socket = new WebSocket(socketUrl, { const socket = new WebSocket(socketUrl, {
headers: authHeaders headers: authHeaders
}); });
@@ -94,6 +143,10 @@ function createWebBootstrap(options) {
// ignore close errors // ignore close errors
} }
}; };
}).catch(function (error) {
clearPlayerSnapshotSocket(key);
console.error(error);
});
} }
const dashboardStateService = createDashboardStateService({ const dashboardStateService = createDashboardStateService({
@@ -110,7 +163,7 @@ function createWebBootstrap(options) {
const uploadSyncService = createUploadSyncService({ const uploadSyncService = createUploadSyncService({
pool: pool, pool: pool,
common: common, common: common,
playerInternalBaseUrl: playerInternalBaseUrl, playerInternalBaseUrl: configuredPlayerInternalBaseUrl,
playerSnapshotCache: playerSnapshotCache, playerSnapshotCache: playerSnapshotCache,
notifyPlayerScreens: notifyPlayerScreens, notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue backgroundTaskQueue: backgroundTaskQueue
@@ -120,8 +173,9 @@ function createWebBootstrap(options) {
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide; const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate; const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload; const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange; const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
const runMediaSyncTask = uploadSyncService.runMediaSyncTask; const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
async function sendDashboardStateToSocket(socket) { async function sendDashboardStateToSocket(socket) {
@@ -206,12 +260,14 @@ function createWebBootstrap(options) {
return { return {
upload: upload, upload: upload,
uploadSyncService: uploadSyncService,
buildDashboardState: buildDashboardState, buildDashboardState: buildDashboardState,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
removeUnusedUploadFiles: removeUnusedUploadFiles,
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
runMediaSyncTask: runMediaSyncTask, runMediaSyncTask: runMediaSyncTask,
broadcastDashboardState: broadcastDashboardState, broadcastDashboardState: broadcastDashboardState,
installDashboardWebsocket: installDashboardWebsocket installDashboardWebsocket: installDashboardWebsocket
+200
View File
@@ -0,0 +1,200 @@
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(/</g, '\\u003c'));
});
Handlebars.registerHelper('truncateText', function (value, maxLength) {
const text = String(value || '').trim();
const limit = Number(maxLength);
if (!text || !Number.isFinite(limit) || limit <= 0 || text.length <= limit) {
return text;
}
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}`;
});
Handlebars.registerHelper('textLengthAttrs', function (maxLength) {
const limit = Number(maxLength);
if (!Number.isFinite(limit) || limit < 1) {
return '';
}
return new Handlebars.SafeString(`maxlength="${Handlebars.escapeExpression(String(Math.floor(limit)))}" data-limit-text-length`);
});
}
// URL helpers for generating links and external URL checks.
function registerUrlHelpers(Handlebars) {
Handlebars.registerHelper('isExternalUrl', function (value) {
return /^https?:\/\//i.test(String(value || ''));
});
Handlebars.registerHelper('assetHref', function (value) {
const href = String(value || '').trim();
if (!href) {
return '';
}
if (/^https?:\/\//i.test(href) || href.startsWith('/')) {
return href;
}
return `/assets/${href}`;
});
Handlebars.registerHelper('playerUrl', function (slug) {
const base = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
return `${base}/screen/${encodeURIComponent(slug)}`;
});
}
// Permission helpers delegate to the RBAC layer.
function registerPermissionHelpers(Handlebars, deps) {
const hasPermission = deps && deps.hasPermission;
const hasAnyPermission = deps && deps.hasAnyPermission;
Handlebars.registerHelper('hasPermission', function (currentUser, permissionKey) {
return hasPermission(currentUser, permissionKey);
});
Handlebars.registerHelper('anyPermission', function (currentUser) {
const args = Array.prototype.slice.call(arguments, 1);
args.pop();
return hasAnyPermission(currentUser, args);
});
}
function registerUserHelpers(Handlebars) {
Handlebars.registerHelper('userInitial', function (name) {
const text = String(name || '').trim();
return text ? text.charAt(0).toUpperCase() : 'A';
});
}
// Action button helpers keep form submit controls consistent.
function registerActionHelpers(Handlebars) {
Handlebars.registerHelper('saveActionButtons', function (options) {
const hash = options && options.hash ? options.hash : {};
const formId = String(hash.formId || '').trim();
const save = {
label: String(hash.saveLabel || 'Save'),
className: String(hash.saveButtonClass || 'btn btn-success'),
actionName: String(hash.saveActionName || 'save_action'),
actionValue: String(hash.saveActionValue || 'save'),
closeLabel: String(hash.saveAndCloseLabel || 'Save and Close'),
closeValue: String(hash.saveAndCloseValue || 'close'),
newLabel: String(hash.saveAndNewLabel || 'Save and New'),
newValue: String(hash.saveAndNewValue || 'new')
};
const showSaveAndClose = hash.showSaveAndClose === undefined ? true : String(hash.showSaveAndClose).toLowerCase() !== 'false';
const showSaveAndNew = hash.showSaveAndNew === undefined ? true : String(hash.showSaveAndNew).toLowerCase() !== 'false';
const showDelete = hash.showDelete === undefined ? true : String(hash.showDelete).toLowerCase() !== 'false';
const hasSecondaryActions = showSaveAndClose || showSaveAndNew;
const ariaLabel = String(hash.ariaLabel || 'Save actions');
const saveUrl = String(hash.saveUrl || '').trim();
const cancel = {
label: String(hash.cancelLabel || 'Cancel'),
className: String(hash.cancelClass || 'btn btn-warning'),
url: String(hash.cancelUrl || '').trim(),
confirmMessage: String(hash.cancelConfirmMessage || hash.cancelConfirm || 'You have unsaved changes. Leave this page?')
};
const deleteAction = {
label: String(hash.deleteLabel || 'Delete'),
className: String(hash.deleteClass || 'btn btn-danger'),
url: String(hash.deleteUrl || '').trim(),
formId: String(hash.deleteFormId || '').trim(),
confirmMessage: String(hash.deleteConfirmMessage || '').trim(),
disabled: hash.deleteDisabled === undefined ? false : String(hash.deleteDisabled).toLowerCase() !== 'false',
title: String(hash.deleteTitle || '')
};
const resolvedDeleteUrl = deleteAction.url || (!deleteAction.disabled && saveUrl ? saveUrl.replace(/\/+$/, '') + '/delete' : '');
const resolvedDeleteFormId = deleteAction.formId || '';
if (!formId) {
return '';
}
const escape = Handlebars.escapeExpression;
const formAttr = ` form="${escape(formId)}"`;
const parts = [
'<div class="btn-group save-action-group" role="group">',
`<button type="submit" class="${escape(save.className)}"${formAttr} name="${escape(save.actionName)}" value="${escape(save.actionValue)}">${escape(save.label)}</button>`,
hasSecondaryActions ? `<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split rounded-0" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">${escape(ariaLabel)}</span></button>` : '',
hasSecondaryActions ? '<div class="dropdown-menu dropdown-menu-end">' : '',
hasSecondaryActions && showSaveAndClose ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(save.actionName)}" value="${escape(save.closeValue)}">${escape(save.closeLabel)}</button>` : '',
hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(save.actionName)}" value="${escape(save.newValue)}">${escape(save.newLabel)}</button>` : '',
hasSecondaryActions ? '</div>' : '',
];
if (cancel.url) {
parts.push(`<a class="${escape(cancel.className)}" href="${escape(cancel.url)}" data-confirm-unsaved="${escape(cancel.confirmMessage)}">${escape(cancel.label)}</a>`);
}
if (showDelete && (deleteAction.disabled || resolvedDeleteUrl)) {
const deleteActionAttr = resolvedDeleteUrl && !resolvedDeleteFormId ? ` data-delete-action-url="${escape(resolvedDeleteUrl)}"` : '';
const deleteFormAttr = resolvedDeleteFormId ? ` form="${escape(resolvedDeleteFormId)}"` : '';
const confirmAttr = deleteAction.confirmMessage ? ` data-confirm-message="${escape(deleteAction.confirmMessage)}"` : '';
const titleAttr = deleteAction.disabled && deleteAction.title ? ` title="${escape(deleteAction.title)}"` : '';
parts.push(`<button type="${resolvedDeleteFormId ? 'submit' : 'button'}" class="${escape(deleteAction.className)}"${deleteFormAttr}${deleteActionAttr}${confirmAttr}${deleteAction.disabled ? ' disabled' : ''}${titleAttr}>${escape(deleteAction.label)}</button>`);
}
parts.push('</div>');
return new Handlebars.SafeString(parts.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'));
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'));
Handlebars.registerPartial('signage/templates/animation-advanced-modal', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-modal.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.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
};
+6
View File
@@ -0,0 +1,6 @@
// Entry point for the web auth helper modules.
module.exports = {
createSessionService: require('./session').createSessionService,
rbacData: require('./rbac-data')
};
@@ -1,5 +1,7 @@
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac'); // RBAC data access helpers for roles, permissions, and user-role links.
const { fetchPagedRows } = require('../../data/utils');
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
const { fetchPagedRows } = require('#src/data/utils');
function parseCsvIds(value) { function parseCsvIds(value) {
return String(value || '') return String(value || '')
@@ -13,29 +15,41 @@ function parseCsvIds(value) {
} }
async function fetchPermissions(pool) { async function fetchPermissions(pool) {
const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM permissions ORDER BY section_name ASC, name ASC'); const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM a_permissions ORDER BY section_name ASC, name ASC');
return rows || []; return rows || [];
} }
async function fetchRoles(pool) { async function fetchRoles(pool) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at, `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count, (SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count (SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM roles r FROM a_roles r
ORDER BY r.name ASC` ORDER BY r.name ASC`
); );
return rows || []; return rows || [];
} }
async function fetchRolesPage(pool, page, pageSize) { async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at, selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count, (SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count (SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM roles r FROM a_roles r
ORDER BY r.name ASC`, ORDER BY r.name ASC`,
countSql: 'SELECT COUNT(*) AS count FROM roles', countSql: 'SELECT COUNT(*) AS count FROM a_roles',
searchColumns: ['r.role_key', 'r.name', 'r.description'],
searchTerm: searchTerm,
sortColumns: {
role: 'r.name',
description: 'r.description',
users: 'user_count',
permissions: 'permission_count',
created: 'r.created_at',
modified: 'r.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -46,9 +60,9 @@ async function fetchRolesPage(pool, page, pageSize) {
async function fetchRoleById(pool, roleId) { async function fetchRoleById(pool, roleId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at, `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count, (SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count (SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM roles r FROM a_roles r
WHERE r.id = ? WHERE r.id = ?
LIMIT 1`, LIMIT 1`,
[roleId] [roleId]
@@ -59,8 +73,8 @@ async function fetchRoleById(pool, roleId) {
async function fetchRolePermissionKeys(pool, roleId) { async function fetchRolePermissionKeys(pool, roleId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT p.permission_key `SELECT p.permission_key
FROM role_permissions rp FROM a_role_permissions rp
JOIN permissions p ON p.id = rp.permission_id JOIN a_permissions p ON p.id = rp.permission_id
WHERE rp.role_id = ? WHERE rp.role_id = ?
ORDER BY p.section_name ASC, p.name ASC`, ORDER BY p.section_name ASC, p.name ASC`,
[roleId] [roleId]
@@ -73,7 +87,7 @@ async function fetchRolePermissionKeys(pool, roleId) {
async function fetchRoleUserIds(pool, roleId) { async function fetchRoleUserIds(pool, roleId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT ur.user_id `SELECT ur.user_id
FROM user_roles ur FROM a_user_roles ur
WHERE ur.role_id = ? WHERE ur.role_id = ?
ORDER BY ur.user_id ASC`, ORDER BY ur.user_id ASC`,
[roleId] [roleId]
@@ -88,8 +102,8 @@ async function fetchRoleUserIds(pool, roleId) {
async function fetchRolesForUser(pool, userId) { async function fetchRolesForUser(pool, userId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description `SELECT r.id, r.role_key, r.name, r.description
FROM user_roles ur FROM a_user_roles ur
JOIN roles r ON r.id = ur.role_id JOIN a_roles r ON r.id = ur.role_id
WHERE ur.user_id = ? WHERE ur.user_id = ?
ORDER BY r.name ASC`, ORDER BY r.name ASC`,
[userId] [userId]
@@ -102,13 +116,13 @@ async function fetchUsersWithRoles(pool) {
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at, `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names, COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM users u FROM a_users u
LEFT JOIN ( LEFT JOIN (
SELECT ur.user_id, SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names, GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
FROM user_roles ur FROM a_user_roles ur
JOIN roles r ON r.id = ur.role_id JOIN a_roles r ON r.id = ur.role_id
GROUP BY ur.user_id GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id ) role_data ON role_data.user_id = u.id
ORDER BY u.id ASC` ORDER BY u.id ASC`
@@ -122,22 +136,39 @@ async function fetchUsersWithRoles(pool) {
}); });
} }
async function fetchUsersWithRolesPage(pool, page, pageSize) { async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection, options) {
const excludedUserId = Number(options && options.excludeUserId);
const hasExcludedUserId = Number.isInteger(excludedUserId) && excludedUserId > 0;
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at, selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names, COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM users u FROM a_users u
LEFT JOIN ( LEFT JOIN (
SELECT ur.user_id, SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names, GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
FROM user_roles ur FROM a_user_roles ur
JOIN roles r ON r.id = ur.role_id JOIN a_roles r ON r.id = ur.role_id
GROUP BY ur.user_id GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id ) role_data ON role_data.user_id = u.id
${whereSql}
ORDER BY u.id ASC`, ORDER BY u.id ASC`,
countSql: 'SELECT COUNT(*) AS count FROM users', countSql: `SELECT COUNT(*) AS count FROM a_users u ${whereSql}`,
params: queryArgs,
searchColumns: ['u.name', 'u.username', 'role_data.role_names'],
searchTerm: searchTerm,
sortColumns: {
user: 'u.username',
name: 'u.name',
roles: 'role_names',
created: 'u.created_at',
modified: 'u.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -161,13 +192,13 @@ async function fetchUserWithRoles(pool, userId) {
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at, `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names, COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM users u FROM a_users u
LEFT JOIN ( LEFT JOIN (
SELECT ur.user_id, SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names, GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
FROM user_roles ur FROM a_user_roles ur
JOIN roles r ON r.id = ur.role_id JOIN a_roles r ON r.id = ur.role_id
GROUP BY ur.user_id GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id ) role_data ON role_data.user_id = u.id
WHERE u.id = ? WHERE u.id = ?
@@ -192,9 +223,9 @@ async function syncUserRoles(pool, userId, roleIds) {
return Number.isInteger(roleId) && roleId > 0; return Number.isInteger(roleId) && roleId > 0;
}))); })));
await pool.query('DELETE FROM user_roles WHERE user_id = ?', [userId]); await pool.query('DELETE FROM a_user_roles WHERE user_id = ?', [userId]);
for (const roleId of uniqueRoleIds) { for (const roleId of uniqueRoleIds) {
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]); await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
} }
} }
@@ -205,9 +236,9 @@ async function syncRoleUsers(pool, roleId, userIds) {
return Number.isInteger(userId) && userId > 0; return Number.isInteger(userId) && userId > 0;
}))); })));
await pool.query('DELETE FROM user_roles WHERE role_id = ?', [roleId]); await pool.query('DELETE FROM a_user_roles WHERE role_id = ?', [roleId]);
for (const userId of uniqueUserIds) { for (const userId of uniqueUserIds) {
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]); await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
} }
} }
@@ -215,18 +246,18 @@ async function syncRolePermissions(pool, roleId, permissionKeys) {
const uniquePermissionKeys = normalizePermissionKeys(permissionKeys); const uniquePermissionKeys = normalizePermissionKeys(permissionKeys);
if (!uniquePermissionKeys.length) { if (!uniquePermissionKeys.length) {
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]); await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
return; return;
} }
const [permissionRows] = await pool.query('SELECT id, permission_key FROM permissions WHERE permission_key IN (?)', [uniquePermissionKeys]); const [permissionRows] = await pool.query('SELECT id, permission_key FROM a_permissions WHERE permission_key IN (?)', [uniquePermissionKeys]);
if (permissionRows.length !== uniquePermissionKeys.length) { if (permissionRows.length !== uniquePermissionKeys.length) {
throw new Error('One or more selected permissions are invalid.'); throw new Error('One or more selected permissions are invalid.');
} }
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]); await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
for (const permissionRow of permissionRows) { for (const permissionRow of permissionRows) {
await pool.query('INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]); await pool.query('INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
} }
} }
@@ -1,10 +1,42 @@
const { normalizePermissionKeys } = require('../../rbac'); // Session cookie and current-user helpers for the web auth flow.
const { normalizePermissionKeys } = require('#src/rbac');
function getRequestOrigin(req) {
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
const protocol = forwardedProto || (req && req.socket && req.socket.encrypted ? 'https' : 'http');
const forwardedHost = String(req && req.headers && req.headers['x-forwarded-host'] || '').trim().split(',')[0];
const host = forwardedHost || String(req && req.headers && req.headers.host || '').trim();
return host ? `${protocol}://${host}`.replace(/\/$/, '') : 'http://localhost';
}
function normalizeReturnToPath(value, baseUrl) {
const rawValue = String(value || '').trim();
if (!rawValue) {
return '';
}
const normalizedBaseUrl = String(baseUrl || '').trim().replace(/\/$/, '') || 'http://localhost';
try {
const parsed = new URL(rawValue, normalizedBaseUrl);
const baseOrigin = new URL(normalizedBaseUrl).origin;
if (parsed.origin !== baseOrigin) {
return '';
}
return parsed.pathname + parsed.search + parsed.hash;
} catch (_error) {
return rawValue.charAt(0) === '/' ? rawValue : '';
}
}
function createSessionService(options) { function createSessionService(options) {
const sessionCookieName = String(options && options.sessionCookieName || '').trim(); const sessionCookieName = String(options && options.sessionCookieName || '').trim();
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs); const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
const hashSessionToken = options && options.hashSessionToken; const hashSessionToken = options && options.hashSessionToken;
const createSessionToken = options && options.createSessionToken; const createSessionToken = options && options.createSessionToken;
const authMessageCookieName = 'pulse_auth_message';
if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') { if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') {
throw new Error('createSessionService requires the session dependencies.'); throw new Error('createSessionService requires the session dependencies.');
@@ -39,12 +71,37 @@ function createSessionService(options) {
return parts.join('; '); 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) { function clearSessionCookie(res) {
res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, '', { maxAge: 0 })); appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 }));
} }
function setSessionCookie(res, token) { 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) { async function loadCurrentUser(pool, req) {
@@ -57,8 +114,8 @@ function createSessionService(options) {
const tokenHash = hashSessionToken(token); const tokenHash = hashSessionToken(token);
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT s.user_id, u.id, u.name, u.username `SELECT s.user_id, u.id, u.name, u.username
FROM auth_sessions s FROM a_sessions s
JOIN users u ON u.id = s.user_id JOIN a_users u ON u.id = s.user_id
WHERE s.session_hash = ? WHERE s.session_hash = ?
AND s.expires_at > NOW() AND s.expires_at > NOW()
LIMIT 1`, LIMIT 1`,
@@ -71,23 +128,23 @@ function createSessionService(options) {
const userId = Number(rows[0].id); const userId = Number(rows[0].id);
const [roleRows] = await pool.query( const [roleRows] = await pool.query(
`SELECT r.role_key `SELECT r.role_key
FROM user_roles ur FROM a_user_roles ur
JOIN roles r ON r.id = ur.role_id JOIN a_roles r ON r.id = ur.role_id
WHERE ur.user_id = ? WHERE ur.user_id = ?
ORDER BY r.name ASC`, ORDER BY r.name ASC`,
[userId] [userId]
); );
const [permissionRows] = await pool.query( const [permissionRows] = await pool.query(
`SELECT p.permission_key `SELECT p.permission_key
FROM user_roles ur FROM a_user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id JOIN a_role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id JOIN a_permissions p ON p.id = rp.permission_id
WHERE ur.user_id = ? WHERE ur.user_id = ?
ORDER BY p.section_name ASC, p.name ASC`, ORDER BY p.section_name ASC, p.name ASC`,
[userId] [userId]
); );
await pool.query('UPDATE auth_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]); await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
return Object.assign({}, rows[0], { return Object.assign({}, rows[0], {
roleKeys: roleRows.map(function (row) { roleKeys: roleRows.map(function (row) {
return String(row.role_key || '').trim(); return String(row.role_key || '').trim();
@@ -103,7 +160,7 @@ function createSessionService(options) {
const tokenHash = hashSessionToken(token); const tokenHash = hashSessionToken(token);
const expiresAt = new Date(Date.now() + sessionMaxAgeMs); const expiresAt = new Date(Date.now() + sessionMaxAgeMs);
await pool.query( await pool.query(
'INSERT INTO auth_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO a_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[tokenHash, userId, expiresAt, userId, userId] [tokenHash, userId, expiresAt, userId, userId]
); );
return token; return token;
@@ -113,7 +170,9 @@ function createSessionService(options) {
if (req.currentUser) { if (req.currentUser) {
return next(); return next();
} }
res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.')); setAuthMessageCookie(res, 'Please sign in to continue.');
const returnTo = normalizeReturnToPath(req && (req.originalUrl || req.url || req.path), getRequestOrigin(req));
res.redirect(returnTo ? '/login?returnTo=' + encodeURIComponent(returnTo) : '/login');
} }
return { return {
@@ -121,10 +180,13 @@ function createSessionService(options) {
serializeCookie: serializeCookie, serializeCookie: serializeCookie,
clearSessionCookie: clearSessionCookie, clearSessionCookie: clearSessionCookie,
setSessionCookie: setSessionCookie, setSessionCookie: setSessionCookie,
setAuthMessageCookie: setAuthMessageCookie,
consumeAuthMessageCookie: consumeAuthMessageCookie,
loadCurrentUser: loadCurrentUser, loadCurrentUser: loadCurrentUser,
createUserSession: createUserSession, createUserSession: createUserSession,
requireAuth: requireAuth requireAuth: requireAuth,
getRequestOrigin: getRequestOrigin
}; };
} }
module.exports = { createSessionService }; module.exports = { createSessionService, normalizeReturnToPath, getRequestOrigin };
-753
View File
@@ -1,753 +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 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 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 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 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 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 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 leftQueuedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
const rightQueuedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
if (Number.isFinite(leftQueuedTime) && Number.isFinite(rightQueuedTime) && leftQueuedTime !== rightQueuedTime) {
return rightQueuedTime - leftQueuedTime;
}
if (Number.isFinite(leftQueuedTime) !== Number.isFinite(rightQueuedTime)) {
return Number.isFinite(leftQueuedTime) ? -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,
listTasks: listTasks,
listRecurringTasks: listRecurringTasks,
getTaskById: getTaskById,
getSummary: getSummary,
cancelTask: cancelTask,
retryTask: retryTask,
clearFinishedTasks: clearFinishedTasks
};
}
module.exports = {
createBackgroundTaskQueue: createBackgroundTaskQueue,
normalizeIntervalMs: normalizeIntervalMs
};
+99
View File
@@ -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
};
+719
View File
@@ -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
};
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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
};
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
+30
View File
@@ -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 };
+30 -11
View File
@@ -1,21 +1,24 @@
// Helpers for normalizing dashboard state and date values.
const { WebSocket } = require('ws'); const { WebSocket } = require('ws');
function normalizeClientName(value) { function normalizeClientName(value) {
return String(value || '').trim(); return String(value || '').trim();
} }
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug) { function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) {
return (screens || []).map(function (screen) { return (screens || []).map(function (screen) {
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
return Object.assign({}, screen, { return Object.assign({}, screen, {
client_name: onboardingNameBySlug[screen.slug] || null, client_name: onboardingNameBySlug[screen.slug] || null,
player_connection_count: connectionState.count || 0, 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) { return (screens || []).flatMap(function (screen) {
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] }; const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
return (connectionState.connections || []).map(function (connection) { return (connectionState.connections || []).map(function (connection) {
@@ -27,19 +30,30 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
playlist_name: screen.playlist_name || null, playlist_name: screen.playlist_name || null,
connectedAtLabel: formatDashboardDate(connection.connectedAt), connectedAtLabel: formatDashboardDate(connection.connectedAt),
lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt), lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt),
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}` player_url: screen.player_url || String(connection.page || '').trim() || null
}); });
}); });
}); });
} }
function compareScreenNames(left, right) {
const leftName = String(left && left.name || '').trim();
const rightName = String(right && right.name || '').trim();
const nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
if (nameCompare !== 0) {
return nameCompare;
}
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
}
function createDashboardStateService(options) { function createDashboardStateService(options) {
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; const common = options && options.common;
const playerSnapshotCache = options && options.playerSnapshotCache; const playerSnapshotCache = options && options.playerSnapshotCache;
const playerSnapshotSockets = options && options.playerSnapshotSockets; const playerSnapshotSockets = options && options.playerSnapshotSockets;
const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription; const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription;
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
const formatDashboardDate = options && options.formatDashboardDate; const formatDashboardDate = options && options.formatDashboardDate;
if (!pool || !common || !playerSnapshotCache || !playerSnapshotSockets || typeof ensurePlayerSnapshotSubscription !== 'function' || typeof formatDashboardDate !== 'function') { if (!pool || !common || !playerSnapshotCache || !playerSnapshotSockets || typeof ensurePlayerSnapshotSubscription !== 'function' || typeof formatDashboardDate !== 'function') {
@@ -49,14 +63,17 @@ function createDashboardStateService(options) {
async function buildDashboardState() { async function buildDashboardState() {
const data = await common.fetchAdminData(pool); const data = await common.fetchAdminData(pool);
const screensData = data.screens || []; const screensData = data.screens || [];
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
? await common.fetchScreenPlayerUrls(pool)
: {};
screensData.forEach(function (screen) { screensData.forEach(function (screen) {
ensurePlayerSnapshotSubscription(screen.slug); ensurePlayerSnapshotSubscription(screen.slug);
}); });
const [onboardingRows] = await pool.query( const [onboardingRows] = await pool.query(
`SELECT s.slug, pod.device_id, pod.client_name `SELECT s.slug, pod.device_id, pod.client_name
FROM player_onboarding_devices pod FROM d_onboarding_devices pod
JOIN screens s ON s.id = pod.screen_id JOIN d_screens s ON s.id = pod.screen_id
WHERE pod.client_name IS NOT NULL WHERE pod.client_name IS NOT NULL
AND TRIM(pod.client_name) <> ''` AND TRIM(pod.client_name) <> ''`
); );
@@ -82,12 +99,14 @@ function createDashboardStateService(options) {
} }
}); });
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) { const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug)
.map(function (screen) {
return Object.assign({}, screen, { return Object.assign({}, screen, {
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}` player_url: screen.player_url || null
}); });
}); })
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate); .sort(compareScreenNames);
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate);
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) { const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
return socket && socket.readyState === WebSocket.OPEN; return socket && socket.readyState === WebSocket.OPEN;
}); });
+13 -4
View File
@@ -1,19 +1,28 @@
async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) { async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { 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 responseDetails = null;
let pullError = ''; let pullError = '';
try { try {
responseDetails = await common.fetchApiSourceResponse(apiUrl); responseDetails = await common.fetchApiSourceResponse(apiSource);
} catch (error) { } catch (error) {
pullError = String(error && error.message ? error.message : 'Unable to load API response.'); pullError = String(error && error.message ? error.message : 'Unable to load API response.');
} }
await connection.beginTransaction(); await connection.beginTransaction();
await connection.query( await connection.query(
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?', '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(); await connection.commit();
} catch (error) { } catch (error) {
+45 -30
View File
@@ -1,3 +1,5 @@
// Shared helpers for dashboard date formatting, uploads, and query parsing.
const dashboardDateFormatter = new Intl.DateTimeFormat('en-US', { const dashboardDateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short', month: 'short',
day: '2-digit', day: '2-digit',
@@ -88,30 +90,36 @@ function getCanvasSignature(width, height) {
return normalizedWidth + 'x' + normalizedHeight; return normalizedWidth + 'x' + normalizedHeight;
} }
async function fetchPlaylistCanvasSignature(pool, playlistId) { async function fetchPlaylistCanvasId(pool, playlistId) {
const [playlistRows] = await pool.query('SELECT canvas_id FROM c_playlists WHERE id = ?', [playlistId]);
const storedCanvasId = Number(playlistRows && playlistRows[0] && playlistRows[0].canvas_id);
if (Number.isInteger(storedCanvasId) && storedCanvasId > 0) {
return storedCanvasId;
}
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height `SELECT DISTINCT st.canvas_size_id AS canvas_id
FROM playlist_slides ps FROM c_playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ? WHERE ps.playlist_id = ?
AND cs.width IS NOT NULL AND st.canvas_size_id IS NOT NULL`,
AND cs.height IS NOT NULL`,
[playlistId] [playlistId]
); );
const signatures = Array.from(new Set(rows.map(function (row) { const canvasIds = Array.from(new Set(rows.map(function (row) {
return getCanvasSignature(row.canvas_width, row.canvas_height); return Number(row.canvas_id);
}).filter(Boolean))); }).filter(function (value) {
if (!signatures.length) { return Number.isInteger(value) && value > 0;
})));
if (!canvasIds.length) {
return null; return null;
} }
return signatures.length === 1 ? signatures[0] : 'mismatch'; return canvasIds.length === 1 ? canvasIds[0] : 'mismatch';
} }
async function fetchScreensByPlaylistId(connection, playlistId) { async function fetchScreensByPlaylistId(connection, playlistId) {
const [rows] = await connection.query( const [rows] = await connection.query(
'SELECT slug FROM screens WHERE playlist_id = ? AND slug IS NOT NULL', 'SELECT slug FROM d_screens WHERE playlist_id = ? AND slug IS NOT NULL',
[playlistId] [playlistId]
); );
return rows.map(function (row) { return rows.map(function (row) {
@@ -122,8 +130,8 @@ async function fetchScreensByPlaylistId(connection, playlistId) {
async function fetchScreensBySlideId(connection, slideId) { async function fetchScreensBySlideId(connection, slideId) {
const [rows] = await connection.query( const [rows] = await connection.query(
`SELECT DISTINCT s.slug `SELECT DISTINCT s.slug
FROM screens s FROM d_screens s
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
WHERE ps.slide_id = ? WHERE ps.slide_id = ?
AND s.slug IS NOT NULL`, AND s.slug IS NOT NULL`,
[slideId] [slideId]
@@ -136,9 +144,9 @@ async function fetchScreensBySlideId(connection, slideId) {
async function fetchScreensByTemplateId(connection, templateId) { async function fetchScreensByTemplateId(connection, templateId) {
const [rows] = await connection.query( const [rows] = await connection.query(
`SELECT DISTINCT s.slug `SELECT DISTINCT s.slug
FROM screens s FROM d_screens s
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
JOIN slides sl ON sl.id = ps.slide_id JOIN c_slides sl ON sl.id = ps.slide_id
WHERE sl.template_id = ? WHERE sl.template_id = ?
AND s.slug IS NOT NULL`, AND s.slug IS NOT NULL`,
[templateId] [templateId]
@@ -150,7 +158,7 @@ async function fetchScreensByTemplateId(connection, templateId) {
async function fetchOrderedPlaylistSlides(connection, playlistId) { async function fetchOrderedPlaylistSlides(connection, playlistId) {
const [rows] = await connection.query( const [rows] = await connection.query(
'SELECT id, position FROM playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC', 'SELECT id, position FROM c_playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC',
[playlistId] [playlistId]
); );
return rows; return rows;
@@ -158,19 +166,25 @@ async function fetchOrderedPlaylistSlides(connection, playlistId) {
function redirectAfterSave(req, res, defaultUrl, options) { function redirectAfterSave(req, res, defaultUrl, options) {
const safeOptions = options || {}; const safeOptions = options || {};
const action = String((req && req.body && req.body.action) || req.query.action || '').toLowerCase(); const action = String((req && req.body && (req.body.action || req.body.save_action)) || req.query.action || '').toLowerCase();
const message = safeOptions.message || '';
function withMessage(url) {
if (!message) {
return url;
}
const joiner = String(url || '').indexOf('?') === -1 ? '?' : '&';
return url + joiner + 'message=' + encodeURIComponent(message);
}
if (action === 'close') { if (action === 'close') {
return res.redirect(safeOptions.closeUrl || defaultUrl); return res.redirect(withMessage(safeOptions.closeUrl || defaultUrl));
} }
if (action === 'new') { if (action === 'new') {
return res.redirect(safeOptions.newUrl || defaultUrl); return res.redirect(withMessage(safeOptions.newUrl || defaultUrl));
} }
const message = safeOptions.message || ''; return res.redirect(withMessage(defaultUrl));
if (message) {
const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&';
return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message));
}
return res.redirect(defaultUrl);
} }
module.exports = { module.exports = {
@@ -183,7 +197,8 @@ module.exports = {
normalizeScheduleMode: normalizeScheduleMode, normalizeScheduleMode: normalizeScheduleMode,
getAuditUserId: getAuditUserId, getAuditUserId: getAuditUserId,
getCanvasSignature: getCanvasSignature, getCanvasSignature: getCanvasSignature,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature, fetchPlaylistCanvasId: fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasId,
fetchScreensByPlaylistId: fetchScreensByPlaylistId, fetchScreensByPlaylistId: fetchScreensByPlaylistId,
fetchScreensBySlideId: fetchScreensBySlideId, fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId, fetchScreensByTemplateId: fetchScreensByTemplateId,
+106
View File
@@ -0,0 +1,106 @@
// 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';
}
function getComparableSortValue(rawValue) {
const value = String(rawValue || '').trim();
if (!value) {
return { type: 'empty', value: '' };
}
const numericValue = Number(value.replace(/,/g, ''));
if (!Number.isNaN(numericValue)) {
return { type: 'number', value: numericValue };
}
const dateValue = Date.parse(value);
if (!Number.isNaN(dateValue)) {
return { type: 'date', value: dateValue };
}
return { type: 'string', value: value.toLowerCase() };
}
function compareSortValues(leftValue, rightValue) {
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
return 0;
}
if (leftValue.type === 'empty') {
return 1;
}
if (rightValue.type === 'empty') {
return -1;
}
if (leftValue.type === rightValue.type) {
if (leftValue.value < rightValue.value) {
return -1;
}
if (leftValue.value > rightValue.value) {
return 1;
}
return 0;
}
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
}
function sortRows(rows, accessor, sortDirection) {
const multiplier = normalizeSortDirection(sortDirection) === 'desc' ? -1 : 1;
return (Array.isArray(rows) ? rows.slice() : []).sort(function (leftRow, rightRow) {
return compareSortValues(getComparableSortValue(accessor(leftRow)), getComparableSortValue(accessor(rightRow))) * multiplier;
});
}
function createSearchMatcher(searchTerm, fields) {
const query = String(searchTerm || '').trim().toLowerCase();
if (!query) {
return function () {
return true;
};
}
const normalizedFields = Array.isArray(fields) ? fields : [];
return function (item) {
const haystack = normalizedFields.map(function (field) {
if (typeof field === 'function') {
return field(item);
}
return item && field ? item[field] : '';
}).map(function (value) {
return String(value || '').trim().toLowerCase();
}).join(' ');
return haystack.indexOf(query) !== -1;
};
}
module.exports = {
parsePageNumber: parsePageNumber,
getSearchQuery: getSearchQuery,
getSortQuery: getSortQuery,
normalizeSortDirection: normalizeSortDirection,
getSortDirectionQuery: getSortDirectionQuery,
getComparableSortValue: getComparableSortValue,
compareSortValues: compareSortValues,
sortRows: sortRows,
createSearchMatcher: createSearchMatcher
};

Some files were not shown because too many files have changed in this diff Show More