Compare commits

..
121 Commits
Author SHA1 Message Date
lzstealth f252562103 Fix weather creation route error handling
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s
2026-08-28 20:12:34 +01:00
lzstealth 3960931ebe Add onboarding weather and template gradients
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s
2026-08-28 20:05:23 +01:00
lzstealth aa07a78912 Fix timetable timezone tests 2026-08-28 02:56:48 +01:00
lzstealth 3de0e89e94 Release 2.9.0 2026-08-28 02:53:59 +01:00
lzstealth 9097d45d6a Improve announcement rendering and theme assets 2026-08-26 01:08:59 +01:00
lzstealth a7d867dd6f Adjust API source response header spacing
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-22 01:39:35 +01:00
lzstealth 196c640f9b Release 2.8.7
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m47s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s
2026-08-22 01:33:35 +01:00
lzstealth 7bd4a792ee Merge remote-tracking branch 'Gitea_SSH/main'
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m50s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
# Conflicts:
#	CHANGELOG.md
#	build/package.player.json
#	build/package.web.json
#	package-lock.json
#	package.json
2026-08-18 02:25:15 +01:00
lzstealth e1e759f64e Release 2.8.6 2026-08-18 02:23:42 +01:00
lzstealth f071c21219 Release 2.8.5
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-17 02:15:18 +01:00
lzstealth e984f36875 Release 2.8.5
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-17 02:07:48 +01:00
lzstealth bbd517abbb Fix scheduled task run notification
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-17 00:54:13 +01:00
lzstealth 403a928b9e Release 2.8.4
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 35s
2026-08-17 00:46:07 +01:00
lzstealth 7bb34f40fe Release 2.8.3
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-17 00:08:05 +01:00
lzstealth ea72747822 Release 2.8.2
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m12s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s
2026-08-16 22:41:39 +01:00
lzstealth a5bf8e6f7f Release 2.8.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m16s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s
2026-08-16 22:08:32 +01:00
lzstealth 203d0bfc01 Release 2.8.0
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m49s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-16 17:15:31 +01:00
lzstealth 1bdc122995 Target Node 26
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m17s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s
2026-08-15 15:02:28 +01:00
lzstealth 2d748458d0 Remove GHCR publish path 2026-08-15 14:21:54 +01:00
lzstealth bb8cd98e61 Publish Docker images to both registries
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m25s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
Publish Docker Image / build-and-push-ghcr (./build/Dockerfile, web, pulse-signage-web) (push) Failing after 1m8s
Publish Docker Image / build-and-push-ghcr (./build/Dockerfile.player, player, pulse-signage-player) (push) Failing after 31s
2026-08-15 14:13:49 +01:00
lzstealth aafe112c36 Release 2.7.5
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m18s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 32s
2026-08-15 13:06:46 +01:00
lzstealth 1f1ad5d61f Release 2.7.4
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m15s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 32s
2026-08-15 12:30:31 +01:00
lzstealth f0177e6628 Release 2.7.3
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m15s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 32s
2026-08-15 11:43:07 +01:00
lzstealth 15dc6eb7f2 Release 2.7.2
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m16s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 32s
2026-08-14 23:51:38 +01:00
lzstealth 75b5cb5a6b Add player keyboard controls and release 2.7.1
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m17s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 32s
2026-08-14 13:50:40 +01:00
lzstealth e7ec276317 Release v2.7.0
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m18s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 33s
2026-08-14 13:36:47 +01:00
lzstealth 30f5ed11b8 Format scheduled task intervals
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m25s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 32s
2026-08-14 00:20:48 +01:00
lzstealth d6a8b45357 Fresh initial commit
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m12s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 30s
2026-08-12 02:41:31 +01:00
lzstealth f9425fc640 Release 2.6.25
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m12s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 30s
2026-08-10 01:04:49 +01:00
lzstealth 4491c15215 Release 2.6.24 2026-08-10 00:32:28 +01:00
lzstealth 08b06941a4 Adjust dashboard card spacing 2026-08-09 13:53:38 +01:00
lzstealth c0c05f76e3 Make API and RSS refresh notifications change-only 2026-08-09 13:44:17 +01:00
lzstealth 78a34e4105 Release 2.6.23 2026-08-09 13:38:30 +01:00
lzstealth 3c3864e6ac Sync build package versions 2026-08-09 13:06:55 +01:00
lzstealth 6d8bf0f5f0 Release v2.6.22 2026-08-09 13:06:02 +01:00
lzstealth c36b9122c9 Release v2.6.21 2026-08-09 12:30:22 +01:00
lzstealth 39f2098ffe Release v2.6.20 2026-08-09 12:18:44 +01:00
lzstealth 459f84ed94 Release v2.6.19 2026-08-09 11:57:40 +01:00
lzstealth 49c72923b4 Release 2.6.15 2026-08-08 23:15:04 +01:00
lzstealth 6c28a1c028 Release 2.6.14 2026-08-08 21:49:22 +01:00
lzstealth c5172af62d Release 2.6.13 2026-08-08 21:11:34 +01:00
lzstealth cd8e333afd Restore Docker tag fan-out 2026-08-08 20:48:36 +01:00
lzstealth c3b5a0053c Split web and player build artifacts 2026-08-08 20:38:44 +01:00
lzstealth 38565a533d Wire remote player reconnect delay 2026-08-08 16:29:38 +01:00
lzstealth 5a08ccddbb Release v2.6.11 2026-08-08 15:11:11 +01:00
lzstealth 2c97fe81d1 Release v2.6.10 2026-08-08 14:14:52 +01:00
lzstealth ee3b1b51bf Release v2.6.9 2026-08-08 14:08:16 +01:00
lzstealth 4e5a1bc393 Release 2.6.8 2026-08-08 13:18:52 +01:00
lzstealth 7e46fffc34 Release 2.6.7 2026-08-08 13:02:31 +01:00
lzstealth b20beb250b update env files 2026-08-08 00:13:23 +01:00
lzstealth 9d93634382 Readme, addition of changelog. 2026-08-08 00:09:56 +01:00
lzstealth 3cba68e256 Fix compose network service placement 2026-08-08 00:05:09 +01:00
lzstealth bcae4bb318 Add timetable end-time validation 2026-08-08 00:00:51 +01:00
lzstealth 0b300d6ddb Update gitignore 2026-08-07 22:52:03 +01:00
lzstealth 1101ffac34 Ignore leaked compose env files 2026-08-07 22:47:43 +01:00
lzstealth 7e758ecca8 Merge branch 'feature/player-agent-control-plane' 2026-08-07 22:42:03 +01:00
lzstealth 2bd47fb96a Add player control-plane and dashboard updates 2026-08-07 22:35:27 +01:00
lzstealth 0801c119ae Add player control-plane and dashboard updates 2026-08-07 22:23:46 +01:00
lzstealth 433be06bc7 Refine player control-plane flow 2026-08-07 13:10:16 +01:00
lzstealth 74318eb34e Add multi-player and remote bridge support 2026-08-07 02:58:18 +01:00
lzstealth a4f8a807ff Add kiosk launcher downloads and player URL resolution 2026-08-06 18:51:33 +01:00
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
lzstealth f4de31269d Commit all changes 2026-07-26 01:28:38 +01:00
lzstealth 235d2da6aa Release 1.5.9 2026-07-26 01:28:21 +01:00
lzstealth d24c9c4035 Release 1.5.8 2026-07-25 18:24:12 +01:00
lzstealth 04ddec50ad Release 1.5.7 2026-07-25 17:58:00 +01:00
lzstealth 47613e61a2 Tune playlist slide thumbnail placeholder 2026-07-25 17:49:00 +01:00
lzstealth bd130a1070 Bump version to 1.5.6 2026-07-25 17:43:41 +01:00
lzstealth d48f0e779f Backfill slide thumbnail migration 2026-07-25 17:27:25 +01:00
lzstealth 90ec3bb2df Bump version to 1.5.5 2026-07-25 17:27:12 +01:00
lzstealth 0ca20035cd Updated changelog 2026-07-25 17:24:26 +01:00
lzstealth c18f597068 Release 1.5.4 2026-07-25 17:22:21 +01:00
lzstealth 0e89892c94 Release 1.5.3 2026-07-25 15:11:41 +01:00
lzstealth 9ee938fd2f Release 1.5.2 2026-07-25 03:16:49 +01:00
lzstealth ad514102e7 Fix account page save wiring 2026-07-25 02:47:03 +01:00
lzstealth cceb5d8fde Update changelog for 1.5.1 2026-07-25 02:46:33 +01:00
lzstealth b0e72e40d4 Bump version to 1.5.1 2026-07-25 02:43:43 +01:00
lzstealth db9d718cd8 Save worktree changes 2026-07-25 02:29:19 +01:00
lzstealth 8d3b7d557b Release v1.5.0 2026-07-25 02:27:06 +01:00
lzstealth 9f08ccfea1 Bump version to 1.4.6 2026-07-22 01:26:04 +01:00
lzstealth 370ec81c33 Bump version to 1.4.5 2026-07-21 21:53:55 +01:00
lzstealth 5e7df5e55c Render 404 pages for unmatched routes 2026-07-21 21:41:35 +01:00
lzstealth cfca5bfe3b Fix RBAC client action visibility 2026-07-21 21:39:56 +01:00
lzstealth 5eff70755b Fix duplicate permissions cleanup 2026-07-21 21:24:08 +01:00
lzstealth 0666d5d07c Bump version to 1.4.1 2026-07-21 21:14:47 +01:00
lzstealth 8b479283e1 Fix schema migration audit columns 2026-07-21 21:14:41 +01:00
lzstealth 13e13d0d68 Bump version to 1.4.0 2026-07-21 21:07:32 +01:00
lzstealth e051958bea Implement RBAC roles system 2026-07-21 21:05:02 +01:00
lzstealth 7973ee0ea4 Fix duplicate onboarding client names 2026-07-21 02:03:09 +01:00
938 changed files with 220616 additions and 14177 deletions
+5
View File
@@ -1,4 +1,9 @@
*
!package.json
!package-lock.json
!build/
!build/**
!src/
!src/**
!scripts/
!scripts/**
-20
View File
@@ -1,20 +0,0 @@
NODE_ENV=production
WEB_PORT=3000
PLAYER_PORT=3001
DB_HOST=mysql
DB_PORT=3306
DB_NAME=signage
DB_USER=signage_user
DB_PASSWORD=signage_password
MYSQL_ROOT_PASSWORD=root_password
PLAYER_INTERNAL_BASE_URL=http://player:3001
PLAYER_PUBLIC_BASE_URL=http://localhost:3001
SESSION_MAX_AGE_DAYS=14
DASHBOARD_REFRESH_INTERVAL_MS=2000
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_NAME=Admin
DEFAULT_ADMIN_PASSWORD=admin
PASSWORD_HASH_ITERATIONS=310000
+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.
+17
View File
@@ -0,0 +1,17 @@
# Repository Instructions
## Versioning and releases
- Treat `package.json` as the source of truth for the application version.
- Keep `package.json`, `build/package.player.json`, and `build/package.web.json` on the same version number.
- Keep dependency versions and package metadata in `package.json`, `package-lock.json`, `build/package.player.json`, and `build/package.web.json` aligned unless a dependency is intentionally omitted from a specific bundle.
- When changing bundled library versions, update the About page source data from the same package metadata or vendored asset banner rather than hardcoding a fresh literal.
- 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.
@@ -7,9 +7,20 @@ on:
workflow_dispatch:
jobs:
build-and-push:
build-and-push-existing-registry:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: web
repository: pulse-signage-web
dockerfile: ./build/Dockerfile
- name: player
repository: pulse-signage-player
dockerfile: ./build/Dockerfile.player
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -17,7 +28,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to container registry
- name: Log in to existing package registry
uses: docker/login-action@v3
with:
registry: git.lzstealth.com
@@ -28,18 +39,19 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: git.lzstealth.com/LZStealth/pulse-signage
images: |
git.lzstealth.com/lzstealth/${{ matrix.repository }}
tags: |
type=raw,value=latest
type=ref,event=tag
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=semver,pattern=v{{major}}.{{minor}}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
file: ${{ matrix.dockerfile }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+7 -1
View File
@@ -1,9 +1,15 @@
node_modules/
uploads/
media/
!src/web/lib/media/
!src/web/lib/media/**
docker-compose.dev.yml
/docker-compose/*.dev.yml
/docker-compose/*.env
/dev-demo-seed.js
.vscode/
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
TODO.md
+1121
View File
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
RUN mkdir -p /app/uploads
EXPOSE 3000
CMD ["npm", "run", "start:web"]
+26 -69
View File
@@ -1,84 +1,41 @@
# Pulse Signage
Pulse Signage is a digital signage system that lets you manage screens, playlists, slides, templates, and uploaded media from one admin interface.
[![Publish Docker Image](https://git.lzstealth.com/lzstealth/pulse-signage/actions/workflows/docker-publish.yml/badge.svg?branch=main)](https://git.lzstealth.com/lzstealth/pulse-signage/actions?workflow=docker-publish.yml)
It runs as two connected services:
Pulse Signage is a self-hosted digital signage platform for teams that want clear, reliable control over the content on every screen.
- the web admin app for managing content and screens
- the player app that shows the current signage on each screen and listens for live updates
It gives you one place to publish playlists, slides, announcements, and live updates without handing the workflow to a third-party service.
## What You Can Do
## What It Can Do
- Sign in to the admin dashboard
- Create and organize playlists and slides
- Design reusable templates and canvas sizes
- Register screens and assign playlists to them
- Upload images and other media for use in slides and templates
- View live screen connections and send player commands
- Run polished screen experiences with playlists, slides, and reusable templates.
- Keep announcements, schedules, RSS feeds, API content, and other live data in sync.
- Manage many screens from a single dashboard.
- Support everyday signage, event messages, lobbies, dashboards, and other always-on displays.
- Keep the player, dashboard, and bridge connected so updates move quickly and consistently.
## Documentation
## Why It Fits
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.
- It keeps signage under your own control.
- It is built for teams that need screens to stay current without extra manual work.
- It works well for offices, venues, campuses, and operations teams.
- It stays focused on display management instead of trying to be a general-purpose CMS.
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.
## Deploying
## What You Need
Docker Compose is the recommended way to deploy Pulse Signage. It keeps the web app, player, bridge, and database together in a predictable setup.
- Docker and Docker Compose
- A MySQL database
If you want the details, start with the [Compose guide](docker-compose/README.md).
## First-Time Setup
## Docs
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.
- [Documentation home](docs/README.md) - the technical reference index.
- [API reference](docs/api.md) - the player HTTP surface and onboarding endpoints.
- [Database schema](docs/schema.md) - the tables and data model the app maintains.
- [WebSocket reference](docs/websocket.md) - the live player and snapshot channels.
- [Compose guide](docker-compose/README.md) - deployment options and service layout.
- [Changelog](CHANGELOG.md) - release history and notable changes.
- Username: `admin`
- Password: `admin`
## Explore The Docs
You can change the initial admin credentials with these optional environment variables:
- `DEFAULT_ADMIN_USERNAME`
- `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`
### 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 `uploads` volume for media and a `mysql_data` volume for database persistence.
In Docker, upload storage is controlled by the `uploads` volume mount at `/app/uploads`.
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`.
- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes.
The project includes a dashboard for managing content, a player runtime for rendering screens, an onboarding flow for connecting devices, and a bridge layer for remote screens. If you want to understand how the pieces fit together, the docs above cover the technical details without repeating the project overview.
+18
View File
@@ -0,0 +1,18 @@
FROM node:26-alpine
WORKDIR /app
RUN apk add --no-cache chromium nss freetype harfbuzz ttf-freefont
COPY build/package.web.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund
COPY package-lock.json ./package-lock.json
COPY src ./src
COPY scripts ./scripts
RUN mkdir -p /app/media
EXPOSE 3000
CMD ["npm", "run", "start:web"]
+18
View File
@@ -0,0 +1,18 @@
FROM node:26-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg
COPY build/package.player.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund
COPY package-lock.json ./package-lock.json
COPY src ./src
COPY scripts ./scripts
RUN mkdir -p /app/media
EXPOSE 3000
CMD ["npm", "run", "start:player"]
+22
View File
@@ -0,0 +1,22 @@
{
"name": "pulse-signage-player",
"version": "2.10.1",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
"node": ">=26.0.0"
},
"main": "src/common.js",
"scripts": {
"start": "node -r dotenv/config src/player.js",
"start:player": "node -r dotenv/config src/player.js"
},
"dependencies": {
"dotenv": "^17.4.2",
"express": "^5.2.1",
"handlebars": "^4.7.8",
"hls.js": "^1.7.0",
"mysql2": "^3.23.3",
"ws": "^8.21.3"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "pulse-signage-web",
"version": "2.10.1",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
"node": ">=26.0.0"
},
"main": "src/common.js",
"scripts": {
"start": "node -r dotenv/config src/web.js",
"start:web": "node -r dotenv/config src/web.js"
},
"dependencies": {
"@sparticuz/chromium": "^149.0.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"handlebars": "^4.7.8",
"multer": "^2.2.0",
"mysql2": "^3.23.3",
"puppeteer-core": "^25.7.0",
"sharp": "^0.35.3",
"jsqr": "^1.4.0",
"ws": "^8.21.3"
}
}
-88
View File
@@ -1,88 +0,0 @@
services:
web:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
container_name: signage-web
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: ${NODE_ENV:-production}
WEB_PORT: ${WEB_PORT:-3000}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage}
DB_USER: ${DB_USER:-signage_user}
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:3001}
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000}
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
volumes:
- uploads:/app/uploads
command: ["npm", "run", "start:web"]
depends_on:
mysql:
condition: service_healthy
networks:
- pulse_signage
player:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
container_name: signage-player
restart: unless-stopped
ports:
- "3001:3001"
environment:
NODE_ENV: ${NODE_ENV:-production}
PLAYER_PORT: ${PLAYER_PORT:-3001}
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage}
DB_USER: ${DB_USER:-signage_user}
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
volumes:
- uploads:/app/uploads
command: ["npm", "run", "start:player"]
depends_on:
mysql:
condition: service_healthy
networks:
- pulse_signage
mysql:
image: mysql:8.4
container_name: signage-mysql
restart: unless-stopped
ports:
- "3306:3306"
environment:
MYSQL_DATABASE: ${DB_NAME:-signage}
MYSQL_USER: ${DB_USER:-signage_user}
MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD"]
interval: 10s
timeout: 5s
retries: 10
networks:
- pulse_signage
volumes:
mysql_data:
uploads:
networks:
pulse_signage:
name: pulse_signage
external: false
+27
View File
@@ -0,0 +1,27 @@
# Shared application settings
PULSE_SIGNAGE_WEB_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-web:latest"
PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:latest"
PULSE_SIGNAGE_SHARED_SECRET=""
# Database settings for the web, player, and bridge services
DB_HOST="mysql"
DB_PORT=3306
DB_NAME="pulse-signage"
DB_USER="pulse-signage"
DB_PASSWORD="signage_password"
MYSQL_ROOT_PASSWORD="root_password"
# Player settings
PLAYER_IDENTIFIER="player-local"
PLAYER_PUBLIC_URL="http://localhost:8081"
PLAYER_INTERNAL_URL="http://player:8081"
WEB_PUBLIC_URL="http://localhost:8080"
# Web app bootstrap settings
DEFAULT_ADMIN_USERNAME="admin"
DEFAULT_ADMIN_NAME="Admin"
DEFAULT_ADMIN_PASSWORD="password123!"
# Bridge settings for the player-bridge service
WEB_INTERNAL_URL="http://web:8080"
BRIDGE_INTERNAL_URL="http://player-bridge:8090"
+11
View File
@@ -0,0 +1,11 @@
# Shared application settings
PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:latest"
PULSE_SIGNAGE_SHARED_SECRET=""
# Player settings
PLAYER_IDENTIFIER="player-remote"
PLAYER_PUBLIC_URL="http://localhost:8081"
PLAYER_AGENT_RECONNECT_DELAY_MS=5000
# Remote player connectivity settings
BRIDGE_PUBLIC_URL="http://player-bridge.example.com:8090"
+228
View File
@@ -0,0 +1,228 @@
# Docker Compose Setup
This folder contains the Docker Compose definitions for Pulse Signage, including the public stack and the remote player stack.
## Files
- [docker-compose.yml](docker-compose.yml) - full public stack with web, player, player bridge, and MySQL.
- [.env.example](.env.example) - sample environment values for the public stack.
- [docker-compose.remote.yml](docker-compose.remote.yml) - remote player-only stack for machines that sit behind the player bridge.
- [.env.remote.example](.env.remote.example) - sample environment values for the remote stack.
## Stack Overview
### Public stack
The main compose stack is the most complete setup. It runs:
- `web` - the dashboard and admin app.
- `player` - the local screen player.
- `player-bridge` - the bridge service that proxies dashboard commands and player communication.
- `mysql` - the database used by the web, player, and bridge services.
This is the stack to use when you want the full app running on one machine.
### Remote player
The remote stack runs only the `player` service.
Use it when the player is installed on a remote device and connects back through the player bridge instead of running the full app separately.
## Services
### `web`
The dashboard and admin application.
Responsibilities:
- serves the web UI on port `8080`
- reads and writes application data from MySQL
- forwards player actions through the configured bridge base URL
- renders connected clients, dashboard pages, and admin workflows
Key configuration:
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
- `PULSE_SIGNAGE_SHARED_SECRET`
- `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_NAME`
- `DEFAULT_ADMIN_PASSWORD`
- `PASSWORD_HASH_ITERATIONS`
### `player`
The screen runtime that renders playlists and receives commands.
Responsibilities:
- serves the player UI on port `8081`
- connects to MySQL in local mode
- connects to the bridge in remote mode through `BRIDGE_PUBLIC_URL`
- registers live connections and accepts control commands
Key configuration:
- `PLAYER_PUBLIC_URL`
- `PLAYER_INTERNAL_URL`
- `BRIDGE_INTERNAL_URL`
- `PLAYER_IDENTIFIER`
- `BRIDGE_PUBLIC_URL` in remote mode
- `PULSE_SIGNAGE_SHARED_SECRET`
- database settings in local mode
### `player-bridge`
The bridge layer that connects the dashboard to the player network.
Responsibilities:
- serves the bridge API on port `8090`
- forwards authenticated dashboard commands to registered players
- exposes screen connection snapshots and player registration data
- proxies command traffic between the web app and remote players
Key configuration:
- `PULSE_SIGNAGE_SHARED_SECRET`
- `WEB_INTERNAL_URL` for the bridge when it should call the web app directly instead of inferring from request headers
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
### `mysql`
The MySQL 8.4 database used by the public stack.
Responsibilities:
- stores application data, screen state, onboarding state, and registry records
- provides persistent storage through `mysql_data`
Key configuration:
- `MYSQL_DATABASE`
- `MYSQL_USER`
- `MYSQL_PASSWORD`
- `MYSQL_ROOT_PASSWORD`
## Environment Files
### `.env.example`
Use this file as a starting point for the public compose stack.
Important values:
- `PULSE_SIGNAGE_WEB_IMAGE` - image to run for the web app and bridge services, typically `.../pulse-signage-web:latest`
- `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run for the player services, typically `.../pulse-signage-player:latest`
- `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests
- `PLAYER_IDENTIFIER` - unique local player identifier
- `DB_*` - MySQL credentials and database name for the stack
- `PLAYER_PUBLIC_URL` - public URL the player advertises
- `PLAYER_INTERNAL_URL` - internal URL the web app uses for local player calls
- `BRIDGE_INTERNAL_URL` - bridge URL the web app uses for player snapshot and command forwarding
- `WEB_INTERNAL_URL` - internal URL the bridge uses to call the web app directly
- `DEFAULT_ADMIN_*` - bootstrap admin account values
- `PASSWORD_HASH_ITERATIONS` - password hashing cost
- `MYSQL_ROOT_PASSWORD` - root password for the local MySQL container
### `.env.remote.example`
Use this file on a remote player device.
Important values:
- `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run on the device, typically `.../pulse-signage-player:latest`
- `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment
- `PLAYER_IDENTIFIER` - unique remote player identifier
- `PLAYER_PUBLIC_URL` - public URL for the remote player
- `BRIDGE_PUBLIC_URL` - bridge URL the player connects back to
- `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent
### `PULSE_SIGNAGE_SHARED_SECRET`
This secret is the shared signing key for requests between the services. Use a single value for every service that needs to talk to the same stack, including the web app, player, bridge, and any remote player that connects back to that bridge.
Recommended shape:
- at least 32 random bytes
- ideally 64 hex characters, or another equally long cryptographically random string
- not a password, phrase, or anything human-readable
If you want a quick local value, generate one with a password manager or a command such as `openssl rand -hex 32`.
Leave it blank only if you intentionally want to run without request signing in a throwaway local setup.
## Main Configuration Variables
| Variable | Used By | Purpose |
| --- | --- | --- |
| `PULSE_SIGNAGE_WEB_IMAGE` | web, bridge | Docker image to run for the web app and bridge services. |
| `PULSE_SIGNAGE_PLAYER_IMAGE` | player, remote player | Docker image to run for the player services. |
| `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge, remote player | Shared secret for authenticated requests between services. |
| `DB_HOST` | web, player, bridge | Database host name. |
| `DB_PORT` | web, player, bridge | Database port. |
| `DB_NAME` | web, player, bridge, mysql | Database name. |
| `DB_USER` | web, player, bridge, mysql | Database user. |
| `DB_PASSWORD` | web, player, bridge, mysql | Database password. |
| `MYSQL_ROOT_PASSWORD` | mysql | Root password for the local MySQL container. |
| `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. |
| `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. |
| `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. |
| `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. |
| `PLAYER_INTERNAL_URL` | web, player | Internal player URL used by the dashboard and player runtime. |
| `BRIDGE_INTERNAL_URL` | web | Bridge URL used by the web app for player snapshot and command forwarding. |
| `WEB_INTERNAL_URL` | player-bridge | Internal web URL used by the bridge to call the dashboard app directly. |
| `PLAYER_PUBLIC_URL` | player, remote player | Public URL advertised by the player. |
| `BRIDGE_PUBLIC_URL` | player, remote player | URL of the bridge service. |
| `PLAYER_IDENTIFIER` | player | Stable player identifier. |
| `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. |
| `MYSQL_DATABASE` | mysql | Database name used by the local MySQL container. |
| `MYSQL_USER` | mysql | Database user used by the local MySQL container. |
| `MYSQL_PASSWORD` | mysql | Database password used by the local MySQL container. |
## Ports
Public stack ports:
- `8080` - web dashboard
- `8081` - player
- `8090` - player bridge
- `3306` - MySQL
Remote stack ports:
- `8081` - player only
## Volumes
### Public stack
- `mysql_data` - persistent MySQL data.
- `pulse-signage` - shared media and cache volume for the app services.
### Remote stack
- `pulse-signage` - shared media and cache volume for the remote player.
## Networks
Each compose file creates its own named network:
- `pulse-signage` for the public stack
- `pulse-signage-remote` for remote player deployment.
## Notes
- The public stack expects the app services and MySQL to share the same `PULSE_SIGNAGE_SHARED_SECRET`.
- A remote player must use the same `PULSE_SIGNAGE_SHARED_SECRET` as the bridge it connects to.
- The bridge service is the dashboard-facing command path for connected remote players.
- The remote player should point `BRIDGE_PUBLIC_URL` at the bridge, not at the public web endpoint.
- The `PULSE_SIGNAGE_WEB_IMAGE` and `PULSE_SIGNAGE_PLAYER_IMAGE` tags default to the published `pulse-signage-web` and `pulse-signage-player` repositories with `latest` and `v1.2.3` style tags, but they can be overridden for local builds or custom releases.
## Recommended Setup
1. Copy `.env.example` to a local `.env` file for the public stack.
2. Copy `.env.remote.example` to a device-specific `.env` file for the remote player.
3. Make sure `PULSE_SIGNAGE_SHARED_SECRET` matches everywhere.
4. Start the public stack first, then start the remote player after the bridge is reachable.
5. Verify that the player appears in Connected clients before testing screen commands.
+28
View File
@@ -0,0 +1,28 @@
name: pulse-signage-remote
services:
player:
image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest}
restart: unless-stopped
networks:
- pulse_signage
ports:
- "8081:8081"
environment:
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-remote}
PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:-}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
PLAYER_AGENT_RECONNECT_DELAY_MS: ${PLAYER_AGENT_RECONNECT_DELAY_MS:-5000}
volumes:
- pulse-signage:/app/media
command: ["node", "src/player.js"]
volumes:
pulse-signage:
networks:
pulse_signage:
name: pulse-signage-remote
external: false
+106
View File
@@ -0,0 +1,106 @@
name: pulse-signage
services:
web:
image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest}
restart: unless-stopped
networks:
- pulse_signage
ports:
- "8080:8080"
environment:
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
WEB_PUBLIC_URL: ${WEB_PUBLIC_URL:-http://localhost:8080}
BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090}
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-password123}
volumes:
- pulse-signage:/app/media
command: ["node", "src/web.js"]
depends_on:
mysql:
condition: service_healthy
player:
image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest}
restart: unless-stopped
networks:
- pulse_signage
ports:
- "8081:8081"
environment:
PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
PLAYER_INTERNAL_URL: ${PLAYER_INTERNAL_URL:-http://player:8081}
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
WEB_PUBLIC_URL: ${WEB_PUBLIC_URL:-http://localhost:8080}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
volumes:
- pulse-signage:/app/media
command: ["node", "src/player.js"]
depends_on:
mysql:
condition: service_healthy
player-bridge:
image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest}
restart: unless-stopped
networks:
- pulse_signage
ports:
- "8090:8090"
environment:
WEB_INTERNAL_URL: ${WEB_INTERNAL_URL:-http://web:8080}
WEB_PUBLIC_URL: ${WEB_PUBLIC_URL:-http://localhost:8080}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-pulse-signage}
DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
command: ["node", "src/player-bridge/index.js"]
depends_on:
mysql:
condition: service_healthy
mysql:
image: mysql:8.4
restart: unless-stopped
ports:
- "3306:3306"
environment:
MYSQL_DATABASE: ${DB_NAME:-pulse-signage}
MYSQL_USER: ${DB_USER:-pulse-signage}
MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD"]
interval: 10s
timeout: 5s
retries: 10
networks:
- pulse_signage
volumes:
mysql_data:
pulse-signage:
networks:
pulse_signage:
name: pulse-signage
external: false
+13
View File
@@ -0,0 +1,13 @@
# Documentation
This folder contains the technical reference material for Pulse Signage.
## Whats Here
- [API reference](api.md) - the player HTTP surface and onboarding endpoints.
- [Database schema](schema.md) - the tables and data model used by the app.
- [WebSocket reference](websocket.md) - the live player and snapshot channels.
## How To Read It
If you want the big picture first, start with the main [project README](../README.md). It gives a plain overview of what Pulse Signage does, while the pages in this folder explain how the pieces work.
+210 -10
View File
@@ -2,27 +2,150 @@
## Overview
Player service base URL: `http://localhost:3001`
Player service base URL: `http://localhost:8081`
This document uses OpenAPI-style sections, but stays in plain markdown.
This document covers the player HTTP surface only. The admin dashboard exposes its own routes for screen commands and onboarding management.
Access note: most player endpoints are unauthenticated because they are meant to run inside a trusted deployment network. Anything that mutates state or writes files should be treated as internal-only unless you add your own auth layer in front of it. Pairing uses a short-lived random PIN displayed by the kiosk; the PIN is accepted only through the authenticated Web UI pairing flow.
When `PULSE_SIGNAGE_SHARED_SECRET` is set, the player pages sign same-origin API fetches with `x-pulse-page-auth`, and the web app signs server-to-player requests with `x-pulse-request-timestamp` plus `x-pulse-request-signature`. Page tokens auto-renew before expiry while the page stays active, and signed server requests are only accepted when their timestamp is fresh. If the secret is unset, those checks stay disabled for compatibility.
## Endpoints
### `GET /`
Returns a plain-text landing response for the player service.
Returns the player onboarding landing page.
Access: public within the trusted player deployment.
### `GET /onboard`
Redirects to the authenticated Web UI pairing page for compatibility with older QR codes.
Access: the Web UI pairing page requires a logged-in Web UI session.
### `GET /screen/{slug}`
Returns the rendered player page for a screen.
Access: the configured player may load only its persisted paired screen. An unpaired player is redirected to `/`; a different screen slug is rejected. The route is public within the trusted player deployment, but it no longer changes the player's binding.
### `GET /api/onboarding/status`
Returns the persisted onboarding status for a device.
Access: public within the trusted player deployment.
Query fields:
- `deviceId` required
### `GET /api/onboarding/screens`
Returns the list of screens available for onboarding.
Access: public within the trusted player deployment.
### `GET /api/onboarding/qr`
Returns an SVG QR code that points to the onboarding form.
Access: public within the trusted player deployment.
Query fields:
- `deviceId` required
### `POST /api/auth/page`
Renews the current page-auth token before it expires.
Access: internal to the player page and onboarding page. The request must include a valid `x-pulse-page-auth` header.
Response fields:
- `token`
- `issuedAt`
- `expiresAt`
### `POST /api/onboarding`
Binds a device to a screen and client name.
Access: internal-only. Browser submissions must go through the authenticated Web UI pairing page. The Web UI resolves the short-lived kiosk PIN to a device ID before forwarding the signed request. Protect this endpoint if the player service is reachable outside your trusted network.
Accepted request fields:
- `deviceId` required
- `clientName` required
- `screenSlug` required
Response fields:
- `deviceId`
- `clientName`
- `screenId`
- `screenSlug`
- `screenName`
- `playerUrl`
- `queued`
### `GET /api/media/config`
Returns the upload directory configured for the player service.
Access: internal-only.
Response fields:
- `mediaDir`
- `uploadDir`
### `PUT /api/media/{filename}`
Writes an uploaded file into the player upload directory.
Access: internal-only and write-protected behind your deployment boundary.
### `DELETE /api/media/{filename}`
Deletes a file from the player upload directory.
Access: internal-only and write-protected behind your deployment boundary.
### `GET /api/rtmp/session`
Creates or reuses an RTMP-to-HLS session for a source URL.
Access: internal-only.
Query fields:
- `source` required
- `disableAudio` optional
Response fields:
- `key`
- `playlistUrl`
- `disableAudio`
- `ready`
- `live`
### `GET /api/rtmp/streams/{key}/index.m3u8`
Returns the RTMP session HLS manifest.
Access: internal-only.
### `GET /api/rtmp/streams/{key}/{fileName}`
Returns an RTMP HLS segment or related stream file.
Access: internal-only.
### `GET /api/screens/{slug}/playlist`
Returns the current playlist payload for a screen.
Access: public within the trusted player deployment.
Response fields:
- `screen`
- `playlist`
- `slides`
- `rssFeeds`
- `apiSources`
- `timetableGroups`
- `revision`
### `GET /api/screens/{slug}/connections`
### `GET /api/screens/{slug}/clients`
Returns the live player connection snapshot for a screen.
Access: public within the trusted player deployment, but it exposes live connection state.
Response fields:
- `screen`
- `screenSlug`
- `count`
- `connections`
- `degraded`
### `POST /api/screens/{slug}/commands`
Sends a command to the player connections for a screen.
Access: internal-only. The admin dashboard should remain the protected control surface for commands.
Accepted request fields:
@@ -31,21 +154,89 @@ Accepted request fields:
- `clientId` optional
- `blackout` optional when `command=blackout`
Command-specific fields:
- `url` for `redirect`
- `clientName` for `setclientname`
- `deviceId` for `setclientname`
Supported commands:
- `refresh`
- `reload`
- `redirect`
- `pause`
- `blackout`
- `previous`
- `next`
- `left`
- `right`
- `setclientname`
If `connectionId` or `clientId` is provided, the command targets a single player connection. Otherwise it is broadcast to all connections for that screen.
Response fields:
- `screen`
- `screenSlug`
- `command`
- `connectionId`
- `sent`
- `degraded`
## Response Shapes
### Onboarding Status Response
`GET /api/onboarding/status` returns an object with:
- `deviceId`
- `onboarded`
- `clientName`
- `screenId`
- `screenSlug`
- `screenName`
- `playerUrl`
### Onboarding Screens Response
`GET /api/onboarding/screens` returns an object with:
- `screens`
### QR Response
`GET /api/onboarding/qr` returns SVG markup.
### Upload Config Response
`GET /api/media/config` returns an object with:
- `mediaDir`
- `uploadDir`
### RTMP Session Response
`GET /api/rtmp/session` returns an object with:
- `key`
- `playlistUrl`
- `disableAudio`
- `ready`
- `live`
### Onboarding Write Response
`POST /api/onboarding` returns an object with:
- `deviceId`
- `clientName`
- `screenId`
- `screenSlug`
- `screenName`
- `playerUrl`
- `queued`
### Playlist Response
`GET /api/screens/{slug}/playlist` returns an object with:
@@ -53,6 +244,10 @@ If `connectionId` or `clientId` is provided, the command targets a single player
- `screen`
- `playlist`
- `slides`
- `rssFeeds`
- `apiSources`
- `timetableGroups`
- `revision`
### Connections Response
@@ -62,6 +257,7 @@ If `connectionId` or `clientId` is provided, the command targets a single player
- `screenSlug`
- `count`
- `connections`
- `degraded`
### Command Response
@@ -72,6 +268,7 @@ If `connectionId` or `clientId` is provided, the command targets a single player
- `command`
- `connectionId`
- `sent`
- `degraded`
## Data Models
@@ -89,6 +286,8 @@ If `connectionId` or `clientId` is provided, the command targets a single player
- `id`
- `name`
- `fade_between_slides`
- `skip_unavailable_rtmp`
- `canvas_id`
### Slide
@@ -96,12 +295,9 @@ If `connectionId` or `clientId` is provided, the command targets a single player
- `title`
- `body`
- `duration_seconds`
- `schedule_mode`
- `schedule_start_datetime`
- `schedule_end_datetime`
- `schedule_start_time`
- `schedule_end_time`
- `schedule_days_json`
- `use_video_duration`
- `disable_audio`
- `scheduleRules`
- `media_url`
- `media_type`
- `kind`
@@ -113,11 +309,15 @@ If `connectionId` or `clientId` is provided, the command targets a single player
- `id`
- `clientId`
- `clientName`
- `deviceId`
- `label`
- `userAgent`
- `viewport`
- `page`
- `currentSlide`
- `currentSlideId`
- `currentSlideTitle`
- `paused`
- `blackout`
- `clientIp`
@@ -128,4 +328,4 @@ If `connectionId` or `clientId` is provided, the command targets a single player
## Notes
- The player API is the only surface documented here.
- Web UI/admin routes are intentionally omitted.
- Web UI/admin routes are intentionally omitted, except where the player service itself exposes onboarding and upload endpoints.
+314
View File
@@ -0,0 +1,314 @@
# 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.
Tables generally use a numeric auto-increment `id` primary key. The relationship table `d_announcement_screens` intentionally uses the composite `(announcement_id, screen_id)` primary key instead. Natural and relationship keys remain as unique constraints where needed. 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`, `must_change_password`, `account_locked`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `username` is unique.
### `a_roles`
- `id`, `role_key`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `role_key` is unique.
- `name` is unique.
### `a_permissions`
- `id`, `permission_key`, `name`, `section_name`, `description`, `created_at`, `modified_at`
- `permission_key` is unique.
### `a_role_permissions`
- `id`, `role_id`, `permission_id`, `created_at`, `modified_at`
- Foreign keys:
- `role_id` -> `a_roles.id`
- `permission_id` -> `a_permissions.id`
- Unique key: `(role_id, permission_id)`
### `a_user_roles`
- `id`, `user_id`, `role_id`, `created_at`, `modified_at`
- Foreign keys:
- `user_id` -> `a_users.id`
- `role_id` -> `a_roles.id`
- Unique key: `(user_id, role_id)`
### `a_sessions`
- `id`, `session_hash`, `user_id`, `ip_address`, `user_agent`, `expires_at`, `created_at`, `created_by`, `last_used_at`, `modified_by`
- `session_hash` is unique.
- 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`, `disable_audio`, `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 assignment.
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
### `d_players`
- `id`, `identifier`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at`
- `id` is the primary key.
- `identifier` is unique and is the stable player identity used by the app.
### `d_screens`
- `id`, `name`, `slug`, `playlist_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `slug` is unique.
- Foreign keys:
- `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL`
- Screens are no longer tied to a player foreign key directly; player registration and live connection metadata are tracked separately in `d_players`.
### `d_onboarding_devices`
- `id`, `device_id`, `client_name`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `device_id` is unique.
- Foreign key:
- `screen_id` -> `d_screens.id` with `ON DELETE SET NULL`
## Onboarding
- The onboarding flow uses `d_onboarding_devices` to bind a device to a screen and persist the client name.
## Announcements
- `d_announcements` - announcement content and display metadata.
- `d_announcement_screens` - announcement-to-screen assignments.
### `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`
- Unique key: `(announcement_id, screen_id)`
## 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_timetable_groups` - grouped timetable definitions used by the timetable region.
- `i_timetable_entries` - dated entries that belong to a timetable 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_timetable_groups`
- `id`, `name`, `short_description`, `timezone`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `timezone` defaults to `Europe/London`.
### `i_timetable_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_timetable_groups.id` with `ON DELETE CASCADE`
- Index:
- `(schedule_group_id, start_datetime)`
## Operations
- `o_background_tasks` - queue and history for background jobs.
- `o_app_state` - generic app state and version markers stored as key/value pairs.
- `o_app_settings` - administrator-configurable application settings stored by key.
- `o_audit_events` - retained audit events for administrator activity and system changes.
### `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`.
### `o_app_state`
- `id`, `state_key`, `state_value`, `created_at`, `modified_at`
- `state_key` is unique.
- `schema_version` is stored here so startup can detect the previously recorded schema version before deciding whether migrations need to run.
### `o_app_settings`
- `id`, `setting_key`, `setting_value`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `setting_key` is unique.
- Values are stored as JSON and validated against the application setting definitions in `src/data/app-settings.js`.
### `o_audit_events`
- `id`, `occurred_at`, `category`, `event_type`, `actor_user_id`, `target_type`, `target_id`, `target_label`, `ip_address`, `user_agent`, `details_json`
- Indexed by occurrence time, category and event type, actor, and target.
## 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.
- `src/db/migrations.js` records the current schema version in `o_app_state` during startup so later launches can tell whether an update is happening.
```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_TIMETABLE_GROUPS {
}
I_TIMETABLE_ENTRIES {
}
O_APP_STATE {
}
O_APP_SETTINGS {
}
O_BACKGROUND_TASKS {
}
O_AUDIT_EVENTS {
}
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
A_USERS ||--o{ O_AUDIT_EVENTS : acts
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
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_TIMETABLE_GROUPS ||--o{ I_TIMETABLE_ENTRIES : contains
```
+63 -36
View File
@@ -2,10 +2,12 @@
## 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.
The `PULSE_SIGNAGE_SHARED_SECRET` setting does not change the websocket message format here. It is used to sign the player control socket URL and the server-side snapshot subscription; the message payloads themselves remain the same.
## Channels
### `GET /ws/screens/{slug}`
@@ -13,36 +15,27 @@ Player control channel.
This is the bidirectional socket used by the player page. The player sends status messages to the server, and the server sends commands back to the player.
Access: the player page must include a valid `auth` query parameter signed with the shared secret. The browser page refreshes this token automatically while it is active.
### `GET /ws/screens/{slug}/events`
Player snapshot channel.
This is the server-to-dashboard snapshot stream for live player connection state.
Access: internal-only. The web backend subscribes with signed request headers; browsers should not connect directly.
### `GET /ws/screens/{slug}/announcements`
Player announcement channel.
This is the server-to-player push channel used to wake the player when an announcement changes. The browser player reconnects automatically and refreshes its announcement state when it receives an `announcement-refresh` message.
Access: the player page must include a valid `auth` query parameter signed with the shared secret.
## Player Control Channel
### Messages from player to server
The player sends two message types:
- `hello`
- `state`
#### `hello`
Example payload:
```json
{
"type": "hello",
"clientId": "client-id",
"userAgent": "browser ua",
"page": "http://.../screen/demo",
"viewport": { "width": 1920, "height": 1080 },
"paused": false,
"blackout": false,
"currentSlide": null
}
```
The player sends a state snapshot message.
#### `state`
@@ -52,20 +45,21 @@ Example payload:
{
"type": "state",
"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": {
"id": 12,
"title": "Main Slide",
"kind": "image",
"playlistSignature": "..."
}
"currentSlide": null
}
```
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
The server sends command messages with:
@@ -75,17 +69,19 @@ The server sends command messages with:
- optional `sentAt`
- optional `targetConnectionId`
- optional `blackout` for blackout commands
- optional `url` for redirect commands
- optional `clientName` and `deviceId` for client-name updates
Supported commands:
- `blackout`
- `next`
- `pause`
- `previous`
- `refresh`
- `reload`
- `pause`
- `blackout`
- `previous`
- `next`
- `left`
- `right`
- `redirect`
- `setclientname`
#### `refresh`
Asks the player to refetch the current playlist.
@@ -111,12 +107,39 @@ Example payload:
Use `false` to restore and `true` to blackout.
#### `previous` / `left`
#### `redirect`
Requests the player page to navigate to a new location. The destination is supplied as `url` by the caller that forwarded the command.
#### `previous`
Moves to the previous slide.
#### `next` / `right`
#### `next`
Moves to the next slide.
#### `setclientname`
Updates the client name associated with the player session and onboarding record.
The command payload should include `clientName` and may include `deviceId` when the caller is updating a specific onboarding binding.
## Announcement Channel
### Messages from server to player
The server sends a lightweight refresh notification.
#### `announcement-refresh`
Example payload:
```json
{
"type": "announcement-refresh",
"slug": "demo",
"sentAt": "2026-08-01T12:00:00.000Z"
}
```
When the player receives this message, it refetches the current announcement state over HTTP.
## Snapshot Channel
### Messages from server to client
@@ -142,11 +165,15 @@ The snapshot channel is server-to-client only.
- `id`
- `clientId`
- `clientName`
- `deviceId`
- `label`
- `userAgent`
- `viewport`
- `page`
- `currentSlide`
- `currentSlideId`
- `currentSlideTitle`
- `paused`
- `blackout`
- `clientIp`
+1500 -567
View File
File diff suppressed because it is too large Load Diff
+19 -9
View File
@@ -1,8 +1,11 @@
{
"name": "pulse-signage",
"version": "1.3.3",
"version": "2.10.1",
"private": false,
"description": "Pulse Signage application with MySQL and media uploads",
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
"node": ">=26.0.0"
},
"repository": {
"type": "git",
"url": "https://git.lzstealth.com/LZStealth/pulse-signage.git"
@@ -13,17 +16,24 @@
"start:web": "node -r dotenv/config src/web.js",
"start:player": "node -r dotenv/config src/player.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": {
"bootstrap-icons": "1.11.3",
"@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1",
"bootstrap-icons": "1.13.1",
"cropperjs": "^1.6.2",
"dotenv": "^17.4.2",
"express": "^4.21.2",
"express": "^5.2.1",
"handlebars": "^4.7.8",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.14.3",
"qrcode": "^1.5.4",
"ws": "^8.21.0"
"hls.js": "^1.7.0",
"jsqr": "^1.4.0",
"multer": "^2.2.0",
"mysql2": "^3.23.3",
"puppeteer-core": "^25.7.0",
"sharp": "^0.35.3",
"ws": "^8.21.3"
},
"devDependencies": {
"nodemon": "^3.1.10"
+64
View File
@@ -0,0 +1,64 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "TARGET_URL=%~1"
if not defined TARGET_URL set "TARGET_URL=http://localhost:8081"
set "BROWSER_PATH="
set "BROWSER_KIND="
for %%B in (chrome.exe msedge.exe firefox.exe) do if not defined BROWSER_PATH (
for /f "delims=" %%I in ('where %%B 2^>nul') do if not defined BROWSER_PATH (
set "BROWSER_PATH=%%I"
set "BROWSER_KIND=%%~nB"
)
)
if not defined BROWSER_PATH if not defined BROWSER_KIND (
for %%P in (
"%ProgramFiles%\Google\Chrome\Application\chrome.exe"
"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"
"%LocalAppData%\Google\Chrome\Application\chrome.exe"
) do if not defined BROWSER_PATH if exist "%%~fP" (
set "BROWSER_PATH=%%~fP"
set "BROWSER_KIND=chrome"
)
)
if not defined BROWSER_PATH if not defined BROWSER_KIND (
for %%P in (
"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"
"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"
"%LocalAppData%\Microsoft\Edge\Application\msedge.exe"
) do if not defined BROWSER_PATH if exist "%%~fP" (
set "BROWSER_PATH=%%~fP"
set "BROWSER_KIND=edge"
)
)
if not defined BROWSER_PATH if not defined BROWSER_KIND (
for %%P in (
"%ProgramFiles%\Mozilla Firefox\firefox.exe"
"%ProgramFiles(x86)%\Mozilla Firefox\firefox.exe"
"%LocalAppData%\Mozilla Firefox\firefox.exe"
) do if not defined BROWSER_PATH if exist "%%~fP" (
set "BROWSER_PATH=%%~fP"
set "BROWSER_KIND=firefox"
)
)
if not defined BROWSER_PATH (
echo No supported browser was found.
echo Tried Chrome, Edge, and Firefox in PATH and common install locations.
exit /b 1
)
set "KIOSK_PROFILE=%LocalAppData%\PulseSignage\kiosk-browser-profile"
if not exist "!KIOSK_PROFILE!" mkdir "!KIOSK_PROFILE!"
echo Launching !BROWSER_KIND! in kiosk mode: !TARGET_URL!
if /I "!BROWSER_KIND!"=="firefox" (
start "" "!BROWSER_PATH!" -no-remote -profile "!KIOSK_PROFILE!" -new-window -kiosk "!TARGET_URL!"
) else (
start "" "!BROWSER_PATH!" --disable-notifications --no-first-run --no-default-browser-check --new-window --kiosk --user-data-dir="!KIOSK_PROFILE!" "!TARGET_URL!"
)
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
target_url="${1:-http://localhost:8081}"
find_browser() {
local candidate
for candidate in "$@"; do
if command -v "$candidate" >/dev/null 2>&1; then
printf '%s' "$candidate"
return 0
fi
done
return 1
}
browser=""
browser_kind=""
if browser="$(find_browser google-chrome-stable google-chrome chromium chromium-browser msedge microsoft-edge firefox)"; then
case "$browser" in
firefox)
browser_kind="firefox"
;;
msedge|microsoft-edge)
browser_kind="edge"
;;
*)
browser_kind="chrome"
;;
esac
else
echo "No supported browser was found."
echo "Tried Chrome, Chromium, Edge, and Firefox in PATH."
exit 1
fi
kiosk_profile="${XDG_DATA_HOME:-$HOME/.local/share}/pulse-signage/kiosk-browser-profile"
mkdir -p "$kiosk_profile"
echo "Launching ${browser_kind} in kiosk mode: ${target_url}"
case "$browser_kind" in
firefox)
exec "$browser" -no-remote -profile "$kiosk_profile" -new-window -kiosk "$target_url"
;;
edge|chrome)
exec "$browser" --disable-notifications --no-first-run --no-default-browser-check --new-window --kiosk --user-data-dir="$kiosk_profile" "$target_url"
;;
esac
+52
View File
@@ -1,3 +1,5 @@
// Password hashing and session token helpers.
const crypto = require('crypto');
const PASSWORD_ITERATIONS = Number(process.env.PASSWORD_HASH_ITERATIONS || 310000);
@@ -5,6 +7,55 @@ const PASSWORD_KEY_LENGTH = 32;
const PASSWORD_DIGEST = 'sha256';
const SESSION_BYTES = 32;
function validatePasswordStrength(password, options) {
const value = String(password || '');
const requirements = options && options.policy
? getPasswordPolicyPreset(options.policy)
: {
minimumLength: Number(options && options.minimumLength) || 10,
minimumCategories: Number(options && options.minimumCategories) || 3,
requireLowercase: Boolean(options && options.requireLowercase),
requireUppercase: Boolean(options && options.requireUppercase),
requireNumber: Boolean(options && options.requireNumber),
requireSymbol: Boolean(options && options.requireSymbol)
};
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;
const missingRequiredCategory = requirements.requireLowercase && !hasLowercase
|| requirements.requireUppercase && !hasUppercase
|| requirements.requireNumber && !hasNumber
|| requirements.requireSymbol && !hasSymbol;
if (value.length < requirements.minimumLength || categoryCount < requirements.minimumCategories || missingRequiredCategory) {
if (missingRequiredCategory) {
const requiredCategories = [];
if (requirements.requireLowercase) requiredCategories.push('lowercase');
if (requirements.requireUppercase) requiredCategories.push('uppercase');
if (requirements.requireNumber) requiredCategories.push('number');
if (requirements.requireSymbol) requiredCategories.push('symbol');
return `Password must be at least ${requirements.minimumLength} characters and include ${requiredCategories.join(', ')}.`;
}
if (requirements.minimumCategories === 4) {
return `Password must be at least ${requirements.minimumLength} characters and include uppercase, lowercase, number, and symbol.`;
}
return `Password must be at least ${requirements.minimumLength} characters and include ${requirements.minimumCategories} of: uppercase, lowercase, number, and symbol.`;
}
return '';
}
function getPasswordPolicyPreset(policy) {
const normalizedPolicy = String(policy || 'standard').trim().toLowerCase();
return normalizedPolicy === 'strict'
? { minimumLength: 14, minimumCategories: 4 }
: normalizedPolicy === 'strong'
? { minimumLength: 12, minimumCategories: 3 }
: { minimumLength: 10, minimumCategories: 3 };
}
function hashPassword(password, salt) {
const safePassword = String(password || '');
const safeSalt = salt || crypto.randomBytes(16).toString('hex');
@@ -38,6 +89,7 @@ function hashSessionToken(token) {
module.exports = {
hashPassword,
verifyPassword,
validatePasswordStrength,
createSessionToken,
hashSessionToken
};
+88 -4
View File
@@ -1,26 +1,110 @@
const db = require('./db');
const data = require('./data');
const player = require('./player/render');
// Module-resolution shims for the app's `#root` and `#src` aliases.
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 } = require('#src/web/lib/helpers');
module.exports = {
createPool: db.createPool,
createPool: dbCommon.createPool,
pruneStaleOnboardingDevices: dbCommon.pruneStaleOnboardingDevices,
ensureSchema: db.ensureSchema,
bootstrapDatabase: dbBootstrap.bootstrapDatabase,
slugify: data.slugify,
uniqueScreenSlug: data.uniqueScreenSlug,
parseJsonSafe: data.parseJsonSafe,
validateMaxLength: data.validateMaxLength,
truncateToMaxLength: data.truncateToMaxLength,
fetchAdminData: data.fetchAdminData,
fetchPlaylistsPage: data.fetchPlaylistsPage,
fetchSlidesPage: data.fetchSlidesPage,
fetchTemplatesPage: data.fetchTemplatesPage,
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
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,
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
normalizeDisplayMode: data.normalizeDisplayMode,
fetchTimetablesData: data.fetchTimetablesData,
fetchTimetableGroupsPage: data.fetchTimetableGroupsPage,
fetchTimetableGroupById: data.fetchTimetableGroupById,
fetchTimetableEntriesByGroupId: data.fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload: data.buildTimetableGroupPayload,
fetchApiSourcesData: data.fetchApiSourcesData,
fetchApiSourcesPage: data.fetchApiSourcesPage,
fetchApiSourceById: data.fetchApiSourceById,
fetchApiSourceResponse: data.fetchApiSourceResponse,
buildApiSourcePayload: data.buildApiSourcePayload,
fetchRssFeedsData: data.fetchRssFeedsData,
fetchRssFeedsPage: data.fetchRssFeedsPage,
fetchRssFeedById: data.fetchRssFeedById,
fetchRssFeedItemsByFeedId: data.fetchRssFeedItemsByFeedId,
normalizeRssFeedItem: data.normalizeRssFeedItem,
buildRssFeedPayload: data.buildRssFeedPayload,
fetchRssFeedItems: data.fetchRssFeedItems,
replaceRssFeedItems: data.replaceRssFeedItems,
fetchWeatherLocationsData: data.fetchWeatherLocationsData,
fetchWeatherLocationsPage: data.fetchWeatherLocationsPage,
fetchWeatherLocationById: data.fetchWeatherLocationById,
fetchWeatherLocationSuggestions: data.fetchWeatherLocationSuggestions,
fetchWeatherLocationForecast: data.fetchWeatherLocationForecast,
buildWeatherLocationPayload: data.buildWeatherLocationPayload,
fetchScreenById: data.fetchScreenById,
fetchScreenEditData: data.fetchScreenEditData,
fetchScreenPlayerUrls: data.fetchScreenPlayerUrls,
fetchPlayerPublicBaseUrl: data.fetchPlayerPublicBaseUrl,
fetchScreenPlayerRecord: data.fetchScreenPlayerRecord,
fetchTemplateById: data.fetchTemplateById,
fetchPlayerRegistrations: data.fetchPlayerRegistrations,
fetchSlideById: data.fetchSlideById,
fetchTemplatesData: data.fetchTemplatesData,
fetchCanvasSizesData: data.fetchCanvasSizesData,
fetchCanvasSizeById: data.fetchCanvasSizeById,
buildCanvasSizePayload: data.buildCanvasSizePayload,
MAX_CANVAS_SIZE_DIMENSION: data.MAX_CANVAS_SIZE_DIMENSION,
buildSlidePayload: data.buildSlidePayload,
extractTemplateRegions: data.extractTemplateRegions,
buildTemplatePayload: data.buildTemplatePayload,
fetchDuplicateName: data.fetchDuplicateName,
mediaKind: player.mediaKind,
renderPlayerPage: player.renderPlayerPage,
renderPlayerOnboardingLandingPage: player.renderPlayerOnboardingLandingPage,
+180 -19
View File
@@ -1,38 +1,199 @@
// Admin data aggregation helpers for dashboards, playlists, slides, templates, and screens.
const { fetchPagedRows } = require('./utils');
async function fetchAdminData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM 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 [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 c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
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.background_gradient, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st
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
`);
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, 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(`
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, 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
LEFT JOIN slide_templates st ON st.id = s.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
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,
(SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
FROM c_slides s
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
`);
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
FROM screens s
LEFT JOIN playlists p ON p.id = s.playlist_id
FROM d_screens s
LEFT JOIN c_playlists p ON p.id = s.playlist_id
ORDER BY s.id DESC
`);
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, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
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 c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_templates st ON st.id = sl.template_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
`);
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, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by,
(SELECT COUNT(*) FROM c_playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count,
(SELECT COUNT(*) FROM d_screens s WHERE s.playlist_id = p.id) AS screen_count
FROM c_playlists p
ORDER BY p.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM c_playlists',
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,
pageSize: pageSize
});
return Object.assign({ playlists: paged.rows }, paged);
}
async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
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,
(SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
FROM c_slides s
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`,
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,
pageSize: pageSize
});
return Object.assign({ slides: paged.rows }, paged);
}
async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
(SELECT COUNT(*) FROM c_template_regions str WHERE str.template_id = st.id) AS region_count,
(SELECT COUNT(*) FROM c_slides s WHERE s.template_id = st.id) AS slide_count
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC`,
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,
pageSize: pageSize
});
return Object.assign({ templates: paged.rows }, paged);
}
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
(SELECT COUNT(*) FROM c_templates st WHERE st.canvas_size_id = c_canvas_sizes.id) AS template_count
FROM c_canvas_sizes
ORDER BY width ASC, height ASC, name ASC`,
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,
pageSize: pageSize
});
return Object.assign({ canvasSizes: paged.rows }, paged);
}
async function fetchScreensPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
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
FROM d_screens s
LEFT JOIN c_playlists p ON p.id = s.playlist_id
ORDER BY s.id DESC`,
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,
pageSize: pageSize
});
return Object.assign({ screens: paged.rows }, paged);
}
module.exports = {
fetchAdminData
fetchAdminData,
fetchPlaylistsPage,
fetchSlidesPage,
fetchTemplatesPage,
fetchCanvasSizesPage,
fetchScreensPage
};
+98
View File
@@ -0,0 +1,98 @@
const fs = require('fs');
const path = require('path');
const BOOTSTRAP_ICON_CSS_PATH = path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons', 'css', 'bootstrap-icons.min.css');
const DEFAULT_ANNOUNCEMENT_ICON_KEYS = [
'megaphone-fill', 'megaphone', 'bell-fill', 'bell',
'exclamation-triangle-fill', 'exclamation-triangle', 'info-circle-fill', 'info-circle',
'check-circle-fill', 'check-circle', 'lightbulb-fill', 'lightbulb',
'calendar-event-fill', 'calendar-event', 'clock-fill', 'clock',
'wifi-off', 'wifi', 'hdd-network', 'hdd-network-fill',
'speaker-fill', 'speaker', 'shield-fill', 'shield',
'collection-play-fill', 'collection-play', 'broadcast', 'broadcast-pin',
'plug-fill', 'plug', 'lightning-charge-fill', 'lightning-charge',
'car-front-fill', 'car-front', 'lamp-fill', 'lamp',
'envelope-fill', 'envelope', 'people-fill', 'people',
'browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari',
'cone', 'cone-striped', 'cup-straw', 'fire'
];
function humanizeBootstrapIconLabel(iconKey) {
return String(iconKey || '')
.trim()
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, function (character) {
return character.toUpperCase();
});
}
function loadBootstrapIconCatalog() {
try {
const css = fs.readFileSync(BOOTSTRAP_ICON_CSS_PATH, 'utf8');
const keys = Array.from(new Set((css.match(/\.bi-([a-z0-9-]+)::?before/g) || []).map(function (match) {
return String(match || '')
.replace(/^\.bi-/, '')
.replace(/::?before$/, '')
.trim()
.toLowerCase();
}).filter(Boolean)));
return keys.map(function (value) {
return {
value: value,
label: humanizeBootstrapIconLabel(value)
};
}).sort(function (left, right) {
return left.value.localeCompare(right.value);
});
} catch (_error) {
return DEFAULT_ANNOUNCEMENT_ICON_KEYS.map(function (value) {
return { value: value, label: humanizeBootstrapIconLabel(value) };
});
}
}
const ANNOUNCEMENT_ICON_CATALOG = loadBootstrapIconCatalog();
const ANNOUNCEMENT_ICON_OPTIONS = DEFAULT_ANNOUNCEMENT_ICON_KEYS.map(function (value) {
const catalogOption = ANNOUNCEMENT_ICON_CATALOG.find(function (option) {
return option.value === value;
});
return catalogOption || { value: value, label: humanizeBootstrapIconLabel(value) };
});
const ANNOUNCEMENT_ICON_KEYS = DEFAULT_ANNOUNCEMENT_ICON_KEYS.slice();
const ANNOUNCEMENT_ICON_CATALOG_KEYS = ANNOUNCEMENT_ICON_CATALOG.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));
ANNOUNCEMENT_ICON_CATALOG.forEach(function (option) {
if (!Object.prototype.hasOwnProperty.call(ANNOUNCEMENT_ICON_LABELS, option.value)) {
ANNOUNCEMENT_ICON_LABELS[option.value] = option.label;
}
});
const DEFAULT_ANNOUNCEMENT_ICON = 'megaphone-fill';
function normalizeAnnouncementIcon(value) {
const normalized = String(value || '').trim().toLowerCase();
return ANNOUNCEMENT_ICON_CATALOG_KEYS.includes(normalized) ? normalized : DEFAULT_ANNOUNCEMENT_ICON;
}
module.exports = {
DEFAULT_ANNOUNCEMENT_ICON_KEYS,
ANNOUNCEMENT_ICON_OPTIONS,
ANNOUNCEMENT_ICON_KEYS,
ANNOUNCEMENT_ICON_CATALOG,
ANNOUNCEMENT_ICON_CATALOG_KEYS,
ANNOUNCEMENT_ICON_LABELS,
DEFAULT_ANNOUNCEMENT_ICON,
humanizeBootstrapIconLabel,
normalizeAnnouncementIcon
};
+196
View File
@@ -0,0 +1,196 @@
// 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',
'orange', 'amber', 'olive', 'teal', 'sky', 'indigo', 'violet', 'fuchsia', 'pink',
'navy', 'steel', 'slate', 'graphite', 'midnight'
];
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
};
+424
View File
@@ -0,0 +1,424 @@
// API source data access, pagination, and remote fetch helpers.
const http = require('http');
const https = require('https');
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;
const REQUEST_BODY_MAX_LENGTH = 1000000;
const TOKEN_URL_MAX_LENGTH = 1024;
const TOKEN_RESPONSE_PATH_MAX_LENGTH = 255;
const TOKEN_HEADER_PREFIX_MAX_LENGTH = 64;
const tokenCache = new Map();
function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase();
if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') {
return unit;
}
return 'minutes';
}
function normalizeAuthMethod(value) {
const method = String(value || '').trim().toLowerCase();
return ['basic', 'bearer', 'api_key_header', 'token_login'].includes(method) ? method : 'none';
}
function normalizeRequestMethod(value) {
return String(value || '').trim().toUpperCase() === 'POST' ? 'POST' : 'GET';
}
function buildAuthHeaders(source) {
const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod));
const headers = {};
if (method === 'basic') {
const username = String(source && (source.auth_username || source.authUsername) || '').trim();
const password = String(source && (source.auth_password || source.authPassword) || '');
if (username || password) {
headers.Authorization = 'Basic ' + Buffer.from(username + ':' + password, 'utf8').toString('base64');
}
} else if (method === 'bearer') {
const token = String(source && (source.auth_bearer_token || source.authBearerToken) || '').trim();
if (token) {
headers.Authorization = 'Bearer ' + token;
}
} else if (method === 'api_key_header') {
const headerName = String(source && (source.auth_header_name || source.authHeaderName) || 'X-API-Key').trim() || 'X-API-Key';
const headerValue = String(source && (source.auth_header_value || source.authHeaderValue) || '').trim();
if (headerValue) {
headers[headerName] = headerValue;
}
}
return headers;
}
async function fetchApiSourcesData(pool) {
const [apiSources] = await pool.query(
'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, enabled, 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 };
}
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_api_sources',
searchColumns: ['name', 'api_url', 'last_pull_error'],
searchTerm: searchTerm,
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,
pageSize: pageSize
});
return Object.assign({ apiSources: paged.rows }, paged);
}
async function fetchApiSourceById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?',
[id]
);
return rows[0] || null;
}
async function loadUrlText(urlValue, requestOptions) {
const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {};
const method = String(requestOptions && requestOptions.method || 'GET').toUpperCase();
const body = requestOptions && requestOptions.body !== undefined ? requestOptions.body : undefined;
const headers = Object.assign({
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage API Reader'
}, extraHeaders);
if (typeof fetch === 'function') {
const fetchOptions = { method: method, headers: headers };
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
fetchOptions.body = body;
}
const response = await fetch(urlValue, fetchOptions);
return {
statusCode: response.status,
contentType: response.headers.get('content-type') || '',
bodyText: await response.text(),
ok: response.ok
};
}
return await new Promise(function (resolve, reject) {
const url = new URL(urlValue);
const transport = url.protocol === 'https:' ? https : http;
const request = transport.request(url, Object.assign({}, requestOptions || {}, {
method: method,
headers: headers
}), function (response) {
response.setEncoding('utf8');
let body = '';
response.on('data', function (chunk) {
body += chunk;
});
response.on('end', function () {
resolve({
statusCode: response.statusCode || 0,
contentType: String(response.headers['content-type'] || ''),
bodyText: body,
ok: !response.statusCode || response.statusCode < 400
});
});
response.on('error', reject);
});
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
request.write(body);
}
request.end();
request.on('error', reject);
});
}
function parseJsonRequestBody(value, fieldName) {
const text = String(value || '').trim();
if (!text) {
return undefined;
}
try {
return JSON.parse(text);
} catch (_error) {
const error = new Error(fieldName + ' must contain valid JSON.');
error.statusCode = 400;
throw error;
}
}
function resolveResponsePath(value, responsePath) {
let current = value;
String(responsePath || '').split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = undefined;
return;
}
current = current[segment];
});
return current;
}
function getTokenCacheKey(source) {
return JSON.stringify([
source && source.id || '',
source && (source.token_url || source.tokenUrl) || '',
source && (source.token_request_body_json || source.tokenRequestBodyJson) || '',
source && (source.token_response_path || source.tokenResponsePath) || 'access_token',
source && (source.token_header_name || source.tokenHeaderName) || 'Authorization',
source && (source.token_header_prefix || source.tokenHeaderPrefix) || 'Bearer'
]);
}
function clearCachedToken(source) {
tokenCache.delete(getTokenCacheKey(source));
}
async function fetchLoginToken(source) {
const cacheKey = getTokenCacheKey(source);
const cached = tokenCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const tokenUrl = source.token_url || source.tokenUrl;
const tokenBody = parseJsonRequestBody(source.token_request_body_json || source.tokenRequestBodyJson, 'Login request body');
const tokenHeaders = { 'Content-Type': 'application/json' };
const response = await loadUrlText(tokenUrl, {
method: 'POST',
headers: tokenHeaders,
body: tokenBody === undefined ? undefined : JSON.stringify(tokenBody)
});
if (!response.ok) {
throw new Error(`Unable to obtain API token (${response.statusCode}).`);
}
let parsed;
try {
parsed = JSON.parse(String(response.bodyText || '').trim());
} catch (_error) {
throw new Error('Token response was not valid JSON.');
}
const tokenPath = source.token_response_path || source.tokenResponsePath || 'access_token';
const token = resolveResponsePath(parsed, tokenPath);
if (token === undefined || token === null || String(token).trim() === '') {
throw new Error('Token response did not contain a token at the configured path.');
}
const expiresIn = Number(parsed && (parsed.expires_in || parsed.expiresIn));
const lifetimeMs = Number.isFinite(expiresIn) && expiresIn > 0
? Math.max(30000, expiresIn * 1000 - 60000)
: 300000;
tokenCache.set(cacheKey, { value: String(token), expiresAt: Date.now() + lifetimeMs });
return String(token);
}
async function buildRequestHeaders(source) {
const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod));
if (method !== 'token_login') {
return buildAuthHeaders(source);
}
const token = await fetchLoginToken(source);
const headerName = String(source.token_header_name || source.tokenHeaderName || 'Authorization').trim() || 'Authorization';
const prefix = String(source.token_header_prefix || source.tokenHeaderPrefix || 'Bearer').trim();
return { [headerName]: prefix ? prefix + ' ' + token : token };
}
async function fetchApiSourceResponse(apiSource) {
const source = apiSource && typeof apiSource === 'object' ? apiSource : { api_url: apiSource };
const requestMethod = normalizeRequestMethod(source.request_method || source.requestMethod);
const requestBody = parseJsonRequestBody(source.request_body_json || source.requestBodyJson, 'API request body');
let response;
let tokenRetry = false;
do {
response = await loadUrlText(source.api_url || source.apiUrl, {
method: requestMethod,
headers: Object.assign({}, await buildRequestHeaders(source), requestBody === undefined ? {} : { 'Content-Type': 'application/json' }),
body: requestBody === undefined ? undefined : JSON.stringify(requestBody)
});
if (response.statusCode === 401 && normalizeAuthMethod(source.auth_method || source.authMethod) === 'token_login' && !tokenRetry) {
clearCachedToken(source);
tokenRetry = true;
} else {
break;
}
} while (true);
if (!response.ok) {
throw new Error(`Unable to load API response (${response.statusCode}).`);
}
const text = String(response.bodyText || '').trim();
if (!text) {
throw new Error('API response did not return JSON.');
}
let parsed;
try {
parsed = JSON.parse(text);
} catch (_error) {
throw new Error('API response was not valid JSON.');
}
return {
responseJson: JSON.stringify(parsed, null, 2),
responseStatus: response.statusCode,
responseContentType: response.contentType
};
}
function buildApiSourcePayload(req, existingApiSource) {
const fallback = existingApiSource || {};
const body = req && req.body ? req.body : {};
function readBodyValue(fieldName, fallbackValue) {
if (Object.prototype.hasOwnProperty.call(body, fieldName)) {
return body[fieldName];
}
return fallbackValue;
}
const name = 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 requestMethod = normalizeRequestMethod(readBodyValue('request_method', readBodyValue('requestMethod', fallback.request_method || 'GET')));
const requestBodyJson = validateMaxLength(readBodyValue('request_body_json', readBodyValue('requestBodyJson', fallback.request_body_json || '')) || '', REQUEST_BODY_MAX_LENGTH, 'API request body');
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 tokenUrl = validateMaxLength(readBodyValue('token_url', readBodyValue('tokenUrl', fallback.token_url || '')) || '', TOKEN_URL_MAX_LENGTH, 'API token URL');
const tokenRequestBodyJson = validateMaxLength(readBodyValue('token_request_body_json', readBodyValue('tokenRequestBodyJson', fallback.token_request_body_json || '')) || '', REQUEST_BODY_MAX_LENGTH, 'Login request body');
const tokenResponsePath = validateMaxLength(readBodyValue('token_response_path', readBodyValue('tokenResponsePath', fallback.token_response_path || 'access_token')) || 'access_token', TOKEN_RESPONSE_PATH_MAX_LENGTH, 'Token response path');
const tokenHeaderName = validateMaxLength(readBodyValue('token_header_name', readBodyValue('tokenHeaderName', fallback.token_header_name || 'Authorization')) || 'Authorization', AUTH_MAX_LENGTH, 'Token header name');
const tokenHeaderPrefix = validateMaxLength(readBodyValue('token_header_prefix', readBodyValue('tokenHeaderPrefix', fallback.token_header_prefix || 'Bearer')) || '', TOKEN_HEADER_PREFIX_MAX_LENGTH, 'Token prefix');
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 updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
if (!name) {
const error = new Error('API source name is required.');
error.statusCode = 400;
throw error;
}
if (!apiUrl) {
const error = new Error('API source URL is required.');
error.statusCode = 400;
throw error;
}
let parsedUrl;
try {
parsedUrl = new URL(apiUrl);
} catch (_error) {
const error = new Error('Enter a valid API URL.');
error.statusCode = 400;
throw error;
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
const error = new Error('API URL must start with http or https.');
error.statusCode = 400;
throw error;
}
if (!Number.isFinite(updateIntervalValue)) {
const error = new Error('Update interval must be a number.');
error.statusCode = 400;
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;
}
parseJsonRequestBody(requestBodyJson, 'API request body');
parseJsonRequestBody(tokenRequestBodyJson, 'Login request body');
if (authMethod === 'token_login') {
if (!tokenUrl) {
const error = new Error('Token login requires a login URL.');
error.statusCode = 400;
throw error;
}
try {
const tokenParsedUrl = new URL(tokenUrl);
if (tokenParsedUrl.protocol !== 'http:' && tokenParsedUrl.protocol !== 'https:') {
throw new Error('invalid protocol');
}
} catch (_error) {
const error = new Error('Enter a valid API token URL.');
error.statusCode = 400;
throw error;
}
if (!tokenRequestBodyJson) {
const error = new Error('Token login requires a JSON request body.');
error.statusCode = 400;
throw error;
}
}
return {
name: name,
apiUrl: parsedUrl.toString(),
requestMethod: requestMethod,
requestBodyJson: requestBodyJson,
authMethod: authMethod,
authUsername: authUsername,
authPassword: authPassword,
authBearerToken: authBearerToken,
authHeaderName: authHeaderName,
authHeaderValue: authHeaderValue,
tokenUrl: tokenUrl,
tokenRequestBodyJson: tokenRequestBodyJson,
tokenResponsePath: tokenResponsePath,
tokenHeaderName: tokenHeaderName,
tokenHeaderPrefix: tokenHeaderPrefix,
itemsPath: itemsPath,
updateIntervalValue: Math.floor(updateIntervalValue),
updateIntervalUnit: updateIntervalUnit
};
}
module.exports = {
fetchApiSourcesData: fetchApiSourcesData,
fetchApiSourcesPage: fetchApiSourcesPage,
fetchApiSourceById: fetchApiSourceById,
fetchApiSourceResponse: fetchApiSourceResponse,
buildApiSourcePayload: buildApiSourcePayload
};
+209
View File
@@ -0,0 +1,209 @@
const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('./announcement-icons');
const SETTING_DEFINITIONS = [
{ key: 'app.name', type: 'string', defaultValue: 'Pulse Signage' },
{ key: 'locale.timezone', type: 'string', defaultValue: 'Europe/London' },
{ key: 'locale.language', type: 'string', defaultValue: 'en' },
{ key: 'ui.theme', type: 'enum', values: ['dark', 'light', 'auto'], defaultValue: 'dark' },
{ key: 'security.session_lifetime_days', type: 'integer', min: 1, defaultValue: 14 },
{ key: 'security.allow_user_session_revocation', type: 'boolean', defaultValue: true },
{ key: 'security.max_active_sessions', type: 'integer', min: 0, defaultValue: 0 },
{ key: 'security.password_min_length', type: 'integer', min: 8, defaultValue: 10 },
{ key: 'security.password_min_categories', type: 'integer', min: 1, defaultValue: 3 },
{ key: 'security.password_require_lowercase', type: 'boolean', defaultValue: false },
{ key: 'security.password_require_uppercase', type: 'boolean', defaultValue: false },
{ key: 'security.password_require_number', type: 'boolean', defaultValue: false },
{ key: 'security.password_require_symbol', type: 'boolean', defaultValue: false },
{ key: 'security.require_password_change_for_new_users', type: 'boolean', defaultValue: true },
{ key: 'security.require_password_change_after_admin_reset', type: 'boolean', defaultValue: true },
{ key: 'security.login_max_attempts', type: 'integer', min: 1, defaultValue: 5 },
{ key: 'security.login_lockout_minutes', type: 'integer', min: 1, defaultValue: 15 },
{ key: 'security.login_rate_limit_scope', type: 'enum', values: ['both', 'username', 'ip'], defaultValue: 'both' },
{ key: 'audit.enabled', type: 'boolean', defaultValue: true },
{ key: 'audit.categories', type: 'string_array', defaultValue: ['authentication', 'security', 'sessions', 'users', 'roles', 'system-settings'] },
{ key: 'audit.include_request_metadata', type: 'boolean', defaultValue: true },
{ key: 'audit.retention_days', type: 'integer', min: 0, defaultValue: 180 },
{ key: 'uploads.image_max_bytes', type: 'integer', min: 1, defaultValue: 100 * 1024 * 1024 },
{ key: 'uploads.video_max_bytes', type: 'integer', min: 1, defaultValue: 1024 * 1024 * 1024 },
{ key: 'uploads.wysiwyg_image_max_bytes', type: 'integer', min: 1, defaultValue: 2 * 1024 * 1024 },
{ key: 'uploads.allowed_mime_types', type: 'string_array', defaultValue: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'video/mp4', 'video/webm', 'video/ogg'] },
{ key: 'uploads.cleanup_days', type: 'integer', min: 0, defaultValue: 30 },
{ key: 'uploads.optimize_images', type: 'boolean', defaultValue: true },
{ key: 'announcements.default_icon', type: 'string', defaultValue: 'megaphone-fill' },
{ key: 'announcements.default_duration_value', type: 'integer', min: 1, defaultValue: 10 },
{ key: 'announcements.default_duration_unit', type: 'enum', values: ['seconds', 'minutes'], defaultValue: 'seconds' },
{ key: 'announcements.suggested_icons', type: 'string_array', defaultValue: DEFAULT_ANNOUNCEMENT_ICON_KEYS.slice() },
{ key: 'player.default_slide_duration_seconds', type: 'integer', min: 1, defaultValue: 10 },
{ key: 'player.default_fade_between_slides', type: 'boolean', defaultValue: true },
{ key: 'player.skip_unavailable_rtmp', type: 'boolean', defaultValue: true }
,{ key: 'data-sources.rss_default_interval_value', type: 'integer', min: 1, defaultValue: 60 }
,{ key: 'data-sources.rss_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' }
,{ key: 'data-sources.api_default_interval_value', type: 'integer', min: 1, defaultValue: 60 }
,{ key: 'data-sources.api_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' }
,{ key: 'weather.open_meteo_api_key', type: 'string', defaultValue: '' }
,{ key: 'weather.pirate_weather_api_key', type: 'string', defaultValue: '' }
];
const DEFINITIONS_BY_KEY = new Map(SETTING_DEFINITIONS.map(function (definition) {
return [definition.key, definition];
}));
function cloneValue(value) {
if (Array.isArray(value)) {
return value.slice();
}
return value;
}
function getAppSettingDefinitions() {
return SETTING_DEFINITIONS.map(function (definition) {
return Object.assign({}, definition, {
values: definition.values ? definition.values.slice() : undefined,
defaultValue: cloneValue(definition.defaultValue)
});
});
}
function getDefaultAppSettings() {
return SETTING_DEFINITIONS.reduce(function (settings, definition) {
settings[definition.key] = cloneValue(definition.defaultValue);
return settings;
}, {});
}
function normalizeSettingValue(key, value) {
const definition = DEFINITIONS_BY_KEY.get(String(key || '').trim());
if (!definition) {
throw new Error('Unknown application setting: ' + key);
}
if (definition.type === 'string') {
return String(value == null ? '' : value).trim();
}
if (definition.type === 'integer') {
const normalized = Number(value);
if (!Number.isInteger(normalized) || normalized < definition.min) {
throw new Error('Invalid value for application setting: ' + definition.key);
}
return normalized;
}
if (definition.type === 'boolean') {
if (value === true || value === 1 || value === '1' || value === 'true') {
return true;
}
if (value === false || value === 0 || value === '0' || value === 'false') {
return false;
}
throw new Error('Invalid value for application setting: ' + definition.key);
}
if (definition.type === 'enum') {
const normalized = String(value == null ? '' : value).trim();
if (!definition.values.includes(normalized)) {
throw new Error('Invalid value for application setting: ' + definition.key);
}
return normalized;
}
if (definition.type === 'string_array') {
if (!Array.isArray(value)) {
throw new Error('Invalid value for application setting: ' + definition.key);
}
return Array.from(new Set(value.map(function (item) {
return String(item || '').trim();
}).filter(Boolean)));
}
throw new Error('Unsupported application setting type: ' + definition.type);
}
function normalizeAppSettings(settings) {
const input = settings && typeof settings === 'object' ? settings : {};
return SETTING_DEFINITIONS.reduce(function (normalized, definition) {
const value = Object.prototype.hasOwnProperty.call(input, definition.key)
? input[definition.key]
: definition.defaultValue;
normalized[definition.key] = normalizeSettingValue(definition.key, value);
return normalized;
}, {});
}
function parseStoredValue(value) {
if (typeof value !== 'string') {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return value;
}
}
async function fetchAppSettings(pool) {
const [rows] = await pool.query('SELECT id, setting_key, setting_value FROM o_app_settings ORDER BY setting_key');
const storedSettings = {};
(rows || []).forEach(function (row) {
const key = String(row && row.setting_key || '').trim();
if (DEFINITIONS_BY_KEY.has(key)) {
storedSettings[key] = parseStoredValue(row.setting_value);
}
});
return normalizeAppSettings(Object.assign({}, getDefaultAppSettings(), storedSettings));
}
async function saveAppSettings(pool, settings, modifiedBy) {
const inputSettings = settings && typeof settings === 'object' ? settings : {};
const normalizedSettings = normalizeAppSettings(inputSettings);
const connection = typeof pool.getConnection === 'function' ? await pool.getConnection() : pool;
const shouldRelease = connection !== pool;
try {
if (typeof connection.beginTransaction === 'function') {
await connection.beginTransaction();
}
for (const definition of SETTING_DEFINITIONS) {
if (!Object.prototype.hasOwnProperty.call(inputSettings, definition.key)) {
continue;
}
await connection.query(
`UPDATE o_app_settings
SET setting_value = ?, modified_by = ?
WHERE setting_key = ?`,
[JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, definition.key]
);
await connection.query(
`INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by)
SELECT ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM o_app_settings WHERE setting_key = ?
)`,
[definition.key, JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, modifiedBy || null, definition.key]
);
}
if (typeof connection.commit === 'function') {
await connection.commit();
}
} catch (error) {
if (typeof connection.rollback === 'function') {
await connection.rollback();
}
throw error;
} finally {
if (shouldRelease && typeof connection.release === 'function') {
connection.release();
}
}
return normalizedSettings;
}
module.exports = {
getAppSettingDefinitions,
getDefaultAppSettings,
normalizeSettingValue,
normalizeAppSettings,
fetchAppSettings,
saveAppSettings
};
+118
View File
@@ -0,0 +1,118 @@
const AUDIT_EVENT_CATEGORIES = Object.freeze({
AUTHENTICATION: 'authentication',
SECURITY: 'security',
SESSIONS: 'sessions',
USERS: 'users',
ROLES: 'roles',
SETTINGS: 'system-settings',
SLIDES: 'slides',
TEMPLATES: 'templates',
PLAYLISTS: 'playlists',
SCREENS: 'screens',
ANNOUNCEMENTS: 'announcements',
CANVAS_SIZES: 'canvas-sizes',
API_SOURCES: 'api-sources',
RSS_FEEDS: 'rss-feeds',
TIMETABLES: 'timetables'
});
const AUDIT_CATEGORY_KEYS = Object.freeze(Object.values(AUDIT_EVENT_CATEGORIES));
const AUDIT_CATEGORY_LABELS = Object.freeze({
authentication: 'Authentication',
security: 'Security',
sessions: 'Sessions',
users: 'Users',
roles: 'Roles',
'system-settings': 'System Settings',
slides: 'Slides',
templates: 'Templates',
playlists: 'Playlists',
screens: 'Screens',
announcements: 'Announcements',
'canvas-sizes': 'Canvas Sizes',
'api-sources': 'API Sources',
'rss-feeds': 'RSS Feeds',
timetables: 'Timetables'
});
const { fetchAppSettings } = require('./app-settings');
function normalizeDetails(details) {
if (details === undefined || details === null) {
return null;
}
return JSON.stringify(details);
}
function buildAuditChanges(previousValues, nextValues) {
const previous = previousValues && typeof previousValues === 'object' ? previousValues : {};
const next = nextValues && typeof nextValues === 'object' ? nextValues : {};
const changes = {};
const keys = new Set(Object.keys(previous).concat(Object.keys(next)));
keys.forEach(function (key) {
if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) {
changes[key] = { from: previous[key], to: next[key] };
}
});
return changes;
}
function getRequestMetadata(req) {
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
return {
ipAddress: forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || '').trim() || null,
userAgent: String(req && req.headers && req.headers['user-agent'] || '').trim() || null
};
}
async function recordAuditEvent(pool, event) {
const input = event && typeof event === 'object' ? event : {};
const category = String(input.category || '').trim().toLowerCase();
const eventType = String(input.eventType || '').trim().toLowerCase();
if (!category || !eventType) {
throw new Error('Audit events require a category and event type.');
}
await pool.query(
`INSERT INTO o_audit_events
(category, event_type, actor_user_id, target_type, target_id, target_label, ip_address, user_agent, details_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
category,
eventType,
Number.isInteger(Number(input.actorUserId)) && Number(input.actorUserId) > 0 ? Number(input.actorUserId) : null,
String(input.targetType || '').trim() || null,
String(input.targetId || '').trim() || null,
String(input.targetLabel || '').trim() || null,
String(input.ipAddress || '').trim() || null,
String(input.userAgent || '').trim() || null,
normalizeDetails(input.details)
]
);
}
async function recordRequestAuditEvent(pool, req, event) {
try {
const settings = await fetchAppSettings(pool);
const category = String(event && event.category || '').trim().toLowerCase();
const enabledCategories = Array.isArray(settings['audit.categories']) ? settings['audit.categories'] : AUDIT_CATEGORY_KEYS;
if (!settings['audit.enabled'] || !enabledCategories.includes(category)) {
return;
}
const metadata = settings['audit.include_request_metadata'] ? getRequestMetadata(req) : {};
await recordAuditEvent(pool, Object.assign({}, event, metadata));
} catch (error) {
// Auditing must not turn a successful login or administration action into a failed request.
console.error('Unable to record audit event:', error.message);
}
}
module.exports = {
AUDIT_EVENT_CATEGORIES,
AUDIT_CATEGORY_KEYS,
AUDIT_CATEGORY_LABELS,
getRequestMetadata,
buildAuditChanges,
recordAuditEvent,
recordRequestAuditEvent
};
+54 -5
View File
@@ -1,17 +1,64 @@
// Canvas size data access and pagination helpers.
const { fetchPagedRows } = require('./utils');
const MAX_CANVAS_SIZE_DIMENSION = 16384;
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 };
}
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
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 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,
pageSize: pageSize
});
return Object.assign({ canvasSizes: paged.rows }, paged);
}
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;
}
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) {
const name = String(req.body.name || '').trim();
const width = Math.max(1, Number(req.body.width || (existingCanvasSize && existingCanvasSize.width) || 0));
const height = Math.max(1, Number(req.body.height || (existingCanvasSize && existingCanvasSize.height) || 0));
const width = readCanvasDimension(req.body.width, existingCanvasSize && existingCanvasSize.width, 'width');
const height = readCanvasDimension(req.body.height, existingCanvasSize && existingCanvasSize.height, 'height');
if (!name) {
const error = new Error('Canvas size name is required.');
@@ -28,6 +75,8 @@ function buildCanvasSizePayload(req, existingCanvasSize) {
module.exports = {
fetchCanvasSizesData,
fetchCanvasSizesPage,
fetchCanvasSizeById,
buildCanvasSizePayload
buildCanvasSizePayload,
MAX_CANVAS_SIZE_DIMENSION
};
@@ -1,3 +1,7 @@
// Client name reservation helpers for onboarding and live connection checks.
const crypto = require('crypto');
function normalizeClientName(value) {
return String(value || '').trim();
}
@@ -24,7 +28,7 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
if (pool) {
const [deviceRows] = await pool.query(
`SELECT device_id
FROM player_onboarding_devices
FROM d_onboarding_devices
WHERE client_name IS NOT NULL
AND TRIM(client_name) <> ''
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
@@ -68,9 +72,49 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
}
}
function buildClientNameLockName(clientName) {
return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`;
}
async function withClientNameReservation(pool, clientName, handler) {
if (!pool || typeof pool.getConnection !== 'function') {
return handler();
}
const normalizedName = normalizeClientName(clientName);
if (!normalizedName) {
return handler();
}
const connection = await pool.getConnection();
const lockName = buildClientNameLockName(normalizedName);
let lockAcquired = false;
try {
const [lockRows] = await connection.query('SELECT GET_LOCK(?, 5) AS lock_result', [lockName]);
const lockResult = lockRows && lockRows[0] ? Number(lockRows[0].lock_result) : 0;
if (lockResult !== 1) {
const error = new Error('Client name is busy. Please try again.');
error.statusCode = 409;
throw error;
}
lockAcquired = true;
return await handler();
} finally {
if (lockAcquired) {
try {
await connection.query('SELECT RELEASE_LOCK(?)', [lockName]);
} catch (_error) {}
}
connection.release();
}
}
module.exports = {
normalizeClientName: normalizeClientName,
normalizeDeviceId: normalizeDeviceId,
collectLiveConnections: collectLiveConnections,
isClientNameAvailable: isClientNameAvailable
isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation
};
+66 -5
View File
@@ -1,26 +1,87 @@
const { fetchAdminData } = require('./admin');
// Aggregated exports for the shared data access layer.
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin');
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements');
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
const { fetchPlaylistById } = require('./playlists');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./timetables');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload } = require('./weather');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens');
const { fetchPlayerRegistrations } = require('./player-registry');
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 { parseJsonSafe } = require('./utils');
const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('./utils');
module.exports = {
slugify,
uniqueScreenSlug,
parseJsonSafe,
validateMaxLength,
truncateToMaxLength,
fetchAdminData,
fetchPlaylistsPage,
fetchSlidesPage,
fetchTemplatesPage,
fetchCanvasSizesPage,
fetchScreensPage,
ANNOUNCEMENT_TYPES,
ANNOUNCEMENT_COLORS,
ANNOUNCEMENT_ICONS,
ANNOUNCEMENT_ICON_OPTIONS,
ANNOUNCEMENT_ICON_LABELS,
DEFAULT_ANNOUNCEMENT_ICON,
normalizeAnnouncementType,
normalizeAnnouncementColor,
normalizeAnnouncementIcon,
fetchAnnouncementsPage,
fetchAnnouncementById,
fetchActiveAnnouncement,
buildAnnouncementPayload,
fetchPlaylistById,
normalizeDisplayMode,
fetchTimetablesData,
fetchTimetableGroupsPage,
fetchTimetableGroupById,
fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload,
fetchApiSourcesData,
fetchApiSourcesPage,
fetchApiSourceById,
fetchApiSourceResponse,
buildApiSourcePayload,
fetchRssFeedsData,
fetchRssFeedsPage,
fetchRssFeedById,
fetchRssFeedItemsByFeedId,
normalizeRssFeedItem,
buildRssFeedPayload,
fetchRssFeedItems,
replaceRssFeedItems,
fetchWeatherLocationsData,
fetchWeatherLocationsPage,
fetchWeatherLocationById,
fetchWeatherLocationSuggestions,
fetchWeatherLocationForecast,
buildWeatherLocationPayload,
fetchScreenById,
fetchScreenEditData,
fetchScreenPlayerUrls,
fetchPlayerPublicBaseUrl,
fetchScreenPlayerRecord,
fetchPlayerRecordByIdentifier,
fetchPlayerRegistrations,
fetchTemplateById,
fetchSlideById,
fetchTemplatesData,
fetchCanvasSizesData,
fetchCanvasSizeById,
buildCanvasSizePayload,
MAX_CANVAS_SIZE_DIMENSION,
buildSlidePayload,
extractTemplateRegions,
buildTemplatePayload
buildTemplatePayload,
fetchDuplicateName
};
+151
View File
@@ -0,0 +1,151 @@
function normalizeDeviceId(value) {
return String(value || '')
.trim()
.replace(/[^a-zA-Z0-9_-]/g, '')
.slice(0, 128);
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function normalizeIdentifier(value) {
return String(value || '').trim().slice(0, 255);
}
async function columnExists(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?`,
[tableName, columnName]
);
return Number(rows && rows[0] && rows[0].column_count) > 0;
}
async function fetchPlayerRegistrations(pool) {
if (!pool) {
return [];
}
const [rows] = await pool.query(
`SELECT id, identifier, public_base_url, internal_base_url, last_seen_at, modified_at
FROM d_players
ORDER BY modified_at DESC, identifier ASC`
);
return rows;
}
function getConfiguredPlayerIdentifier() {
return normalizeDeviceId(process.env.PLAYER_IDENTIFIER || process.env.PLAYER_DEVICE_ID || '');
}
async function resolvePlayerRegistration(pool, identifier) {
const normalizedIdentifier = normalizeDeviceId(identifier);
if (!pool || !normalizedIdentifier) {
return null;
}
const hasIdentifierColumn = await columnExists(pool, 'd_players', 'identifier');
const hasDeviceIdColumn = await columnExists(pool, 'd_players', 'device_id');
const identifierColumn = hasIdentifierColumn ? 'identifier' : (hasDeviceIdColumn ? 'device_id' : '');
if (!identifierColumn) {
return null;
}
const selectIdExpression = hasIdentifierColumn ? 'id' : 'NULL AS id';
const selectIdentifierExpression = hasIdentifierColumn ? 'identifier' : 'device_id AS identifier';
const orderByExpression = hasIdentifierColumn ? 'modified_at DESC, id DESC' : 'modified_at DESC';
const [rows] = await pool.query(
`SELECT ${selectIdExpression}, ${selectIdentifierExpression}, public_base_url, internal_base_url, last_seen_at
FROM d_players
WHERE ${identifierColumn} = ?
LIMIT 1`,
[normalizedIdentifier]
);
if (rows[0]) {
return rows[0];
}
const [fallbackRows] = await pool.query(
`SELECT ${selectIdExpression}, ${selectIdentifierExpression}, public_base_url, internal_base_url, last_seen_at
FROM d_players
ORDER BY ${orderByExpression}
LIMIT 1`
);
return fallbackRows[0] || null;
}
async function upsertPlayerRegistration(pool, options) {
const identifier = normalizeDeviceId(options && (options.identifier || options.deviceId));
const publicBaseUrl = normalizeBaseUrl(options && options.publicBaseUrl);
const internalBaseUrl = normalizeBaseUrl(options && options.internalBaseUrl);
if (!pool || !identifier) {
return null;
}
await pool.query(
`UPDATE d_players
SET public_base_url = ?, internal_base_url = ?, last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP
WHERE identifier = ?`,
[publicBaseUrl || null, internalBaseUrl || null, identifier]
);
await pool.query(
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
SELECT ?, ?, ?, CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM d_players WHERE identifier = ?
)`,
[identifier, publicBaseUrl || null, internalBaseUrl || null, identifier]
);
return resolvePlayerRegistration(pool, identifier);
}
async function recordPlayerHeartbeat(pool, options) {
const identifier = normalizeDeviceId(options && (options.identifier || options.deviceId));
const publicBaseUrl = normalizeBaseUrl(options && options.publicBaseUrl);
const internalBaseUrl = normalizeBaseUrl(options && options.internalBaseUrl);
if (!pool || !identifier) {
return null;
}
await pool.query(
`UPDATE d_players
SET public_base_url = COALESCE(?, public_base_url),
internal_base_url = COALESCE(?, internal_base_url),
last_seen_at = CURRENT_TIMESTAMP,
modified_at = CURRENT_TIMESTAMP
WHERE identifier = ?`,
[publicBaseUrl || null, internalBaseUrl || null, identifier]
);
await pool.query(
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
SELECT ?, ?, ?, CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM d_players WHERE identifier = ?
)`,
[identifier, publicBaseUrl || null, internalBaseUrl || null, identifier]
);
return resolvePlayerRegistration(pool, identifier);
}
module.exports = {
normalizeDeviceId: normalizeDeviceId,
normalizeIdentifier: normalizeIdentifier,
getConfiguredPlayerIdentifier: getConfiguredPlayerIdentifier,
fetchPlayerRegistrations: fetchPlayerRegistrations,
resolvePlayerRegistration: resolvePlayerRegistration,
upsertPlayerRegistration: upsertPlayerRegistration,
recordPlayerHeartbeat: recordPlayerHeartbeat
};
+3 -1
View File
@@ -1,5 +1,7 @@
// Playlist data access helpers.
async function fetchPlaylistById(pool, id) {
const [rows] = await pool.query('SELECT id, name, fade_between_slides, 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;
}
+472
View File
@@ -0,0 +1,472 @@
const path = require('path');
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
const QR_PNG_WIDTH = 2048;
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)
}
};
}
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 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
};
+327
View File
@@ -0,0 +1,327 @@
// RSS feed data access, pagination, and item normalization helpers.
const http = require('http');
const https = require('https');
const { fetchPagedRows, validateMaxLength } = require('./utils');
const NAME_MAX_LENGTH = 255;
const URL_MAX_LENGTH = 1024;
function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase();
if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') {
return unit;
}
return 'minutes';
}
async function fetchRssFeedsData(pool) {
const [rssFeeds] = await pool.query(
'SELECT id, name, feed_url, enabled, update_interval_value, update_interval_unit, item_limit, last_pulled_at, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC'
);
return { rssFeeds: rssFeeds };
}
async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, feed_url, enabled, update_interval_value, update_interval_unit, item_limit, last_pulled_at, 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 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,
pageSize: pageSize
});
return Object.assign({ rssFeeds: paged.rows }, paged);
}
async function fetchRssFeedById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, feed_url, enabled, update_interval_value, update_interval_unit, item_limit, last_pulled_at, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?',
[id]
);
return rows[0] || null;
}
async function fetchRssFeedItemsByFeedId(pool, rssFeedId) {
const [rows] = await pool.query(
`SELECT id, rss_feed_id, position, item_json, created_at, modified_at
FROM i_rss_feed_items
WHERE rss_feed_id = ?
ORDER BY position ASC, id ASC`,
[rssFeedId]
);
return rows.map(function (row) {
return normalizeRssFeedItem(row);
});
}
function normalizeRssFeedItem(row) {
let itemJson = null;
if (row && row.item_json) {
try {
itemJson = JSON.parse(row.item_json);
} catch (_error) {
itemJson = null;
}
}
return Object.assign({}, row || {}, itemJson || {}, {
itemJson: itemJson
});
}
async function loadUrlText(urlValue) {
if (typeof fetch === 'function') {
const response = await fetch(urlValue, {
headers: {
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage RSS Reader'
}
});
if (!response.ok) {
throw new Error(`Unable to load RSS feed (${response.status}).`);
}
return await response.text();
}
return await new Promise(function (resolve, reject) {
const url = new URL(urlValue);
const transport = url.protocol === 'https:' ? https : http;
const request = transport.get(url, {
headers: {
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage RSS Reader'
}
}, function (response) {
if (response.statusCode && response.statusCode >= 400) {
reject(new Error(`Unable to load RSS feed (${response.statusCode}).`));
response.resume();
return;
}
response.setEncoding('utf8');
let body = '';
response.on('data', function (chunk) {
body += chunk;
});
response.on('end', function () {
resolve(body);
});
});
request.on('error', reject);
});
}
function decodeXmlEntities(value) {
return String(value || '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'");
}
function stripCdata(value) {
return String(value || '').replace(/^<!\[CDATA\[|\]\]>$/g, '');
}
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractXmlTag(content, tagName) {
const safeTagName = escapeRegExp(tagName);
const pattern = new RegExp(`<${safeTagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\/${safeTagName}>`, 'i');
const match = pattern.exec(content || '');
if (!match) {
return '';
}
return decodeXmlEntities(stripCdata(match[1]).trim());
}
function extractAtomLink(content) {
const linkMatch = /<link\b[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["'][^>]*\/>/i.exec(content || '')
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*\/>/i.exec(content || '')
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*>/i.exec(content || '');
if (!linkMatch) {
return '';
}
return decodeXmlEntities(String(linkMatch[1] || '').trim());
}
function parseRssItems(xmlText, itemLimit) {
const normalizedXml = String(xmlText || '');
const itemMatches = Array.from(normalizedXml.matchAll(/<item\b[\s\S]*?<\/item>|<entry\b[\s\S]*?<\/entry>/gi)).map(function (match) {
return String(match[0] || '');
});
if (!itemMatches.length) {
return [];
}
return itemMatches.slice(0, Math.max(1, Number(itemLimit) || 1)).map(function (itemXml, index) {
const title = extractXmlTag(itemXml, 'title') || `Item ${index + 1}`;
const link = extractXmlTag(itemXml, 'link') || extractAtomLink(itemXml);
const pubDate = extractXmlTag(itemXml, 'pubDate') || extractXmlTag(itemXml, 'updated') || extractXmlTag(itemXml, 'dc:date');
const description = extractXmlTag(itemXml, 'description') || extractXmlTag(itemXml, 'summary') || extractXmlTag(itemXml, 'content:encoded');
const author = extractXmlTag(itemXml, 'author') || extractXmlTag(itemXml, 'dc:creator');
const comments = extractXmlTag(itemXml, 'comments');
const guidMatch = /<guid\b([^>]*)>([\s\S]*?)<\/guid>/i.exec(itemXml || '');
const guid = guidMatch ? decodeXmlEntities(stripCdata(String(guidMatch[2] || '').trim())) : '';
const guidIsPermaLinkMatch = guidMatch && /\bisPermaLink\s*=\s*(["']?)(true|false)\1/i.exec(String(guidMatch[1] || ''));
const categories = Array.from(String(itemXml || '').matchAll(/<category\b([^>]*)>([\s\S]*?)<\/category>/gi)).map(function (match) {
const categoryAttributes = String(match[1] || '');
const categoryDomainMatch = /\bdomain\s*=\s*(["'])([^"']+)\1/i.exec(categoryAttributes);
return {
value: decodeXmlEntities(stripCdata(String(match[2] || '').trim())),
domain: categoryDomainMatch ? decodeXmlEntities(String(categoryDomainMatch[2] || '').trim()) : ''
};
}).filter(function (category) {
return Boolean(category.value);
});
const enclosureMatch = /<enclosure\b([^>]*)\/?>(?:\s*)/i.exec(itemXml || '');
const enclosureUrlMatch = enclosureMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
const enclosureLengthMatch = enclosureMatch ? /\blength\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
const enclosureTypeMatch = enclosureMatch ? /\btype\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
const sourceMatch = /<source\b([^>]*)>([\s\S]*?)<\/source>/i.exec(itemXml || '');
const sourceUrlMatch = sourceMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(sourceMatch[1] || '')) : null;
const parsedItem = {
title: title,
link: link,
description: description,
author: author,
comments: comments,
guid: guid,
guidIsPermaLink: guidIsPermaLinkMatch ? String(guidIsPermaLinkMatch[2] || '').toLowerCase() === 'true' : null,
pubDate: pubDate,
categories: categories,
enclosure: enclosureMatch ? {
url: enclosureUrlMatch ? decodeXmlEntities(String(enclosureUrlMatch[2] || '').trim()) : '',
length: enclosureLengthMatch ? Number(enclosureLengthMatch[2]) || null : null,
type: enclosureTypeMatch ? decodeXmlEntities(String(enclosureTypeMatch[2] || '').trim()) : ''
} : null,
source: sourceMatch ? {
title: decodeXmlEntities(stripCdata(String(sourceMatch[2] || '').trim())),
url: sourceUrlMatch ? decodeXmlEntities(String(sourceUrlMatch[2] || '').trim()) : ''
} : null,
rawXml: itemXml
};
return parsedItem;
});
}
async function fetchRssFeedItems(feedUrl, itemLimit) {
const xmlText = await loadUrlText(feedUrl);
return parseRssItems(xmlText, itemLimit);
}
async function replaceRssFeedItems(connection, rssFeedId, items) {
const normalizedItems = Array.isArray(items) ? items : [];
await connection.query('DELETE FROM i_rss_feed_items WHERE rss_feed_id = ?', [rssFeedId]);
if (!normalizedItems.length) {
return 0;
}
const insertValues = normalizedItems.map(function (item, index) {
return [
rssFeedId,
index + 1,
JSON.stringify(item || {})
];
});
await connection.query(
'INSERT INTO i_rss_feed_items (rss_feed_id, position, item_json) VALUES ?',
[insertValues]
);
return insertValues.length;
}
function buildRssFeedPayload(req, existingRssFeed) {
const fallback = existingRssFeed || {};
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'RSS feed name');
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 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));
if (!name) {
const error = new Error('RSS feed name is required.');
error.statusCode = 400;
throw error;
}
if (!feedUrl) {
const error = new Error('RSS feed URL is required.');
error.statusCode = 400;
throw error;
}
let parsedUrl;
try {
parsedUrl = new URL(feedUrl);
} catch (_error) {
const error = new Error('Enter a valid RSS feed URL.');
error.statusCode = 400;
throw error;
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
const error = new Error('RSS feed URL must start with http or https.');
error.statusCode = 400;
throw error;
}
if (!Number.isFinite(updateIntervalValue)) {
const error = new Error('Update interval must be a number.');
error.statusCode = 400;
throw error;
}
if (!Number.isFinite(itemLimit)) {
const error = new Error('Item count must be a number.');
error.statusCode = 400;
throw error;
}
return {
name: name,
feedUrl: parsedUrl.toString(),
updateIntervalValue: Math.floor(updateIntervalValue),
updateIntervalUnit: updateIntervalUnit,
itemLimit: Math.floor(itemLimit)
};
}
module.exports = {
fetchRssFeedsData,
fetchRssFeedsPage,
fetchRssFeedById,
fetchRssFeedItemsByFeedId,
normalizeRssFeedItem,
buildRssFeedPayload,
fetchRssFeedItems,
replaceRssFeedItems
};
+105 -4
View File
@@ -1,3 +1,5 @@
// Screen slug helpers and screen lookup utilities.
function slugify(value) {
return String(value || '')
.toLowerCase()
@@ -7,13 +9,105 @@ function slugify(value) {
.replace(/-{2,}/g, '-');
}
function normalizePlayerBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function getConfiguredPlayerIdentifier() {
return String(process.env.PLAYER_IDENTIFIER || process.env.PLAYER_DEVICE_ID || '').trim() || null;
}
async function fetchPlayerRecordByIdentifier(pool, identifier) {
const normalizedIdentifier = String(identifier || '').trim();
if (!normalizedIdentifier) {
return null;
}
const [rows] = await pool.query(
`SELECT id, identifier, public_base_url, internal_base_url, last_seen_at
FROM d_players
WHERE identifier = ?
LIMIT 1`,
[normalizedIdentifier]
);
return rows[0] || null;
}
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 playerBaseUrl = await fetchPlayerPublicBaseUrl(pool);
if (!playerBaseUrl) {
return {};
}
const [rows] = await pool.query(`
SELECT slug
FROM d_screens
ORDER BY slug ASC
`);
const playerUrls = {};
rows.forEach(function (row) {
const slug = String(row && row.slug || '').trim();
const playerUrl = buildScreenPlayerUrl({ slug: slug }, playerBaseUrl);
if (slug && playerUrl && !playerUrls[slug]) {
playerUrls[slug] = playerUrl;
}
});
return playerUrls;
}
async function fetchPlayerPublicBaseUrl(pool) {
const configuredPlayerIdentifier = getConfiguredPlayerIdentifier();
const [rows] = await pool.query(`
SELECT public_base_url
FROM d_players
WHERE identifier = ?
LIMIT 1
`, [configuredPlayerIdentifier]);
return normalizePlayerBaseUrl(rows[0] && rows[0].public_base_url) || null;
}
async function fetchScreenPlayerRecord(pool, slug) {
if (slug) {
const [rows] = await pool.query(
`SELECT slug
FROM d_screens
WHERE slug = ?
LIMIT 1`,
[slug]
);
if (!rows.length) {
return null;
}
}
return fetchPlayerRecordByIdentifier(pool, getConfiguredPlayerIdentifier());
}
async function uniqueScreenSlug(pool, baseSlug, excludeId) {
const start = baseSlug || `screen-${Date.now()}`;
let candidate = start;
let counter = 2;
while (true) {
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) {
sql += ' AND id <> ?';
params.push(excludeId);
@@ -30,20 +124,27 @@ async function uniqueScreenSlug(pool, baseSlug, excludeId) {
async function fetchScreenById(pool, id) {
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
FROM screens s
LEFT JOIN playlists p ON p.id = s.playlist_id
FROM d_screens s
LEFT JOIN c_playlists p ON p.id = s.playlist_id
WHERE s.id = ?
`, [id]);
return rows[0] || null;
}
async function fetchScreenEditData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, 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 };
}
module.exports = {
slugify,
normalizePlayerBaseUrl,
getConfiguredPlayerIdentifier,
fetchPlayerRecordByIdentifier,
buildScreenPlayerUrl,
fetchScreenPlayerUrls,
fetchPlayerPublicBaseUrl,
fetchScreenPlayerRecord,
uniqueScreenSlug,
fetchScreenById,
fetchScreenEditData
+431 -27
View File
@@ -1,36 +1,116 @@
const { fetchTemplateById } = require('./templates');
const { parseJsonSafe } = require('./utils');
// Slide data access helpers, including rich-text normalization and payload building.
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
const { fetchTemplateById } = require('./templates');
const { parseJsonSafe, validateMaxLength } = require('./utils');
const TITLE_MAX_LENGTH = 255;
const { buildQrCodeContent } = require('./qr-code');
const DEFAULT_FONT_SIZE = 32;
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
function sanitizeRichTextAttributes(tagName, attrText) {
const 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'],
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
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']
};
const allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
const lowerKey = String(key || '').toLowerCase();
if (!allowed.includes(lowerKey)) {
return '';
}
const 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') {
const targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (!attrs.includes(' rel="noreferrer noopener"')) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + String(value || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;') + '"');
return '';
});
return attrs.join('');
}
function sanitizeRichText(html) {
let output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, (tag) => {
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
const closing = Boolean(match[1]);
const name = String(match[2] || '').toLowerCase();
const attrText = String(match[3] || '');
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
return '';
}
if (name === 'br') {
return '<br>';
if (closing) {
return `</${name}>`;
}
return closing ? `</${name}>` : `<${name}>`;
return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`;
});
}
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) {
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.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
FROM slides s
LEFT JOIN slide_templates st ON st.id = s.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
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, st.canvas_size_id, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_slides s
LEFT JOIN c_templates st ON st.id = s.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE s.id = ?
`, [id]);
if (!slides.length) {
@@ -52,6 +132,14 @@ function getFilesByField(files) {
return map;
}
function getSubmittedValue(body, key, fallback) {
if (body && Object.prototype.hasOwnProperty.call(body, key)) {
return String(body[key] || '').trim();
}
return fallback;
}
function sanitizeTextColor(value, fallback) {
const raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
@@ -60,10 +148,19 @@ function sanitizeTextColor(value, fallback) {
return fallback || '#000000';
}
function sanitizeFontSize(value, fallback) {
const raw = String(value || '').trim();
const parsed = Math.round(Number(raw.replace(/[^0-9.]/g, '')));
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
return Math.max(8, Number(fallback || DEFAULT_FONT_SIZE));
}
function getTextRegionStyle(body, region, existingContent) {
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const fontFamily = String(body[`region_font_family_${region.id}`] || existing.font_family || region.font_family || 'Arial').trim() || 'Arial';
const fontSize = Math.max(8, Number(body[`region_font_size_${region.id}`] || existing.font_size || 24));
const fontSize = sanitizeFontSize(body[`region_font_size_${region.id}`], existing.font_size || DEFAULT_FONT_SIZE);
const fontColor = sanitizeTextColor(body[`region_font_color_${region.id}`] || existing.font_color || region.font_color || '#000000');
return {
font_family: fontFamily,
@@ -72,15 +169,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 = {};
template.regions.forEach((region) => {
for (const region of template.regions) {
if (region.region_type === 'image') {
const uploaded = filesByField[`region_image_${region.id}`];
const existing = body[`existing_region_image_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
content[region.region_key] = {
type: 'image',
value: uploaded ? `/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') {
const submitted = body[`region_webpage_${region.id}`];
@@ -89,6 +321,32 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
type: 'webpage',
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') {
const submitted = body[`region_rtmp_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
content[region.region_key] = {
type: 'rtmp',
value: submitted === undefined ? String(current.value || '').trim() : String(submitted || '').trim(),
disable_audio: Boolean(body[`region_disable_audio_${region.id}`])
};
} else if (region.region_type === 'html') {
const submitted = body[`region_html_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
@@ -96,24 +354,164 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
type: 'html',
value: submitted === undefined ? current : String(submitted || '')
};
} else if (region.region_type === 'time-date') {
const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const timezoneValue = body[`region_timezone_${region.id}`];
content[region.region_key] = {
type: 'time-date',
value: submitted === undefined ? String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '') : String(submitted || ''),
timezone: timezoneValue === undefined || timezoneValue === null ? String(current.timezone || current.time_zone || '') : String(timezoneValue || '').trim()
};
} else if (region.region_type === 'timetable') {
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
const suffix = '_' + region.id;
const generic = {};
const submittedText = body[`region_text_${region.id}`];
const normalizedText = submittedText === undefined ? String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : '') : String(submittedText || '');
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];
});
if (Object.prototype.hasOwnProperty.call(generic, 'timetable_display_mode')) {
generic.display_mode = generic.timetable_display_mode;
delete generic.timetable_display_mode;
}
if (Object.prototype.hasOwnProperty.call(generic, 'timetable_max_items')) {
generic.max_items = generic.timetable_max_items;
delete generic.timetable_max_items;
}
Object.keys(current).forEach((key) => {
if (generic[key] === undefined) {
generic[key] = current[key];
}
});
delete generic.timetable_display_mode;
delete generic.timetable_max_items;
generic.text = normalizedText;
generic.value = normalizedText;
generic.type = region.region_type;
content[region.region_key] = generic;
} else if (region.region_type === 'rss') {
const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const style = getTextRegionStyle(body, region, existingContent);
const feedId = body[`region_rss_feed_id_${region.id}`];
const itemNumber = body[`region_rss_item_number_${region.id}`];
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] = {
type: 'rss',
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
feed_id: feedId === undefined || feedId === null ? (current.feed_id || null) : (feedId === '' ? null : Number(feedId)),
item_number: Math.min(itemCount, Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1),
variable_name: 'item',
font_family: style.font_family,
font_size: style.font_size,
font_color: style.font_color
};
} else if (region.region_type === 'api') {
const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const style = getTextRegionStyle(body, region, existingContent);
const sourceId = body[`region_api_source_id_${region.id}`];
const itemNumber = body[`region_api_item_number_${region.id}`];
const itemsPath = body[`region_api_items_path_${region.id}`];
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
content[region.region_key] = {
type: 'api',
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
source_id: sourceId === undefined || sourceId === null ? (current.source_id || null) : (sourceId === '' ? null : Number(sourceId)),
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(),
variable_name: 'item',
font_family: style.font_family,
font_size: style.font_size,
font_color: style.font_color
};
} else if (region.region_type === 'weather') {
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const locationId = body[`region_weather_location_id_${region.id}`];
const submittedForecastMode = body[`region_weather_forecast_mode_${region.id}`];
const forecastMode = ['current', 'hourly'].includes(submittedForecastMode) ? submittedForecastMode : 'daily';
const style = getTextRegionStyle(body, region, existingContent);
content[region.region_key] = {
type: 'weather',
value: body[`region_text_${region.id}`] !== undefined ? String(body[`region_text_${region.id}`] || '') : String(current.value || ''),
weather_location_id: locationId === undefined || locationId === null ? (current.weather_location_id || null) : (locationId === '' ? null : Number(locationId)),
forecast_mode: body[`region_weather_forecast_mode_${region.id}`] === undefined ? (['current', 'hourly'].includes(current.forecast_mode) ? current.forecast_mode : 'daily') : forecastMode,
font_family: style.font_family,
font_size: style.font_size,
font_color: style.font_color
};
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather'].includes(String(region.region_type || '').trim())) {
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
const suffix = '_' + region.id;
const generic = {};
Object.keys(body || {}).forEach((key) => {
if (!key.startsWith('region_') || !key.endsWith(suffix)) {
return;
}
const field = key.slice('region_'.length, -suffix.length);
if (!field || field === 'type' || field === 'key' || field === 'name' || field === 'label') {
return;
}
generic[field] = body[key];
});
Object.keys(filesByField || {}).forEach((fieldName) => {
if (!fieldName.startsWith('region_') || !fieldName.endsWith(suffix)) {
return;
}
const field = fieldName.slice('region_'.length, -suffix.length);
if (!field) {
return;
}
generic[field] = `/media/uploads/${filesByField[fieldName].filename}`;
});
Object.keys(current).forEach((key) => {
if (generic[key] === undefined) {
generic[key] = current[key];
}
});
generic.type = region.region_type;
content[region.region_key] = generic;
} else {
const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
const style = getTextRegionStyle(body, region, existingContent);
content[region.region_key] = {
type: 'text',
value: submitted === undefined ? current : String(submitted),
value: sanitizeRichText(stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || '')))),
font_family: style.font_family,
font_size: style.font_size,
font_color: style.font_color
};
}
});
}
return content;
}
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 filesByField = getFilesByField(req.files || []);
const template = templateId ? await fetchTemplateById(pool, templateId) : null;
@@ -132,23 +530,29 @@ async function buildSlidePayload(pool, req, existingSlide) {
}
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 {
title,
body: existingSlide ? existingSlide.body : null,
templateId: template.id,
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)),
mediaPath: null,
mediaType: null
contentJson: JSON.stringify(content)
};
}
return {
title,
body: existingSlide ? existingSlide.body : null,
templateId: null,
contentJson: existingSlide ? existingSlide.content_json : null,
mediaPath: existingSlide ? existingSlide.media_path : null,
mediaType: existingSlide ? existingSlide.media_type : null
contentJson: existingSlide ? existingSlide.content_json : null
};
}
+141 -39
View File
@@ -1,6 +1,10 @@
const { parseJsonSafe, readFormArray } = require('./utils');
// Template data access helpers and region normalization logic.
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html'];
const { parseJsonSafe, readFormArray, validateMaxLength } = require('./utils');
const animationPresets = require('../web/public/js/templates/animation-presets');
const TEMPLATE_NAME_MAX_LENGTH = 255;
const REGION_NAME_MAX_LENGTH = 255;
function sanitizeBackgroundColor(value) {
const raw = String(value || '').trim();
@@ -10,9 +14,105 @@ function sanitizeBackgroundColor(value) {
return '#111111';
}
function normalizeBackgroundGradient(value) {
let gradient = value;
if (typeof gradient === 'string') {
try {
gradient = JSON.parse(gradient);
} catch (_error) {
gradient = null;
}
}
if (!gradient || typeof gradient !== 'object' || Array.isArray(gradient)) {
return null;
}
const sourceStops = Array.isArray(gradient.stops) && gradient.stops.length
? gradient.stops
: (Array.isArray(gradient.colors) ? gradient.colors.map((color, index, colors) => ({
color,
position: colors.length > 1 ? Math.round((index / (colors.length - 1)) * 100) : 0
})) : []);
const stops = sourceStops.slice(0, 12).map((stop) => ({
color: sanitizeBackgroundColor(stop && stop.color),
position: Math.max(0, Math.min(100, Number.isFinite(Number(stop && stop.position)) ? Number(stop.position) : 0))
}));
if (stops.length < 2) {
return null;
}
const angle = Number(gradient.angle);
return JSON.stringify({
type: 'linear',
stops,
angle: Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90
});
}
function normalizeTemplateRegionType(value) {
const rawType = String(value || 'text').trim();
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text';
return rawType || 'text';
}
function normalizeTemplateRegionLockRatio(value) {
const rawRatio = String(value || '').trim();
if (!/^\d+\s*:\s*\d+$/.test(rawRatio)) {
return null;
}
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) {
@@ -39,30 +139,30 @@ function ensureUniqueTemplateRegionNames(regions) {
async function fetchTemplateById(pool, id) {
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.background_gradient, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st
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
WHERE st.id = ?
`, [id]);
if (!templates.length) {
return null;
}
const template = templates[0];
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, 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;
return template;
}
async function fetchTemplatesData(pool) {
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.background_gradient, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st
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
`);
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, 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 };
}
@@ -71,17 +171,22 @@ function extractTemplateRegions(body) {
if (regionsJson) {
const parsed = parseJsonSafe(regionsJson);
if (Array.isArray(parsed)) {
return parsed.map((region) => ({
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
region_type: normalizeTemplateRegionType(region.region_type),
label: String(region.region_name || region.label || region.region_key || '').trim(),
font_family: ['text', 'html'].includes(normalizeTemplateRegionType(region.region_type)) ? String(region.font_family || '').trim() || null : null,
x: Number(region.x || 0),
y: Number(region.y || 0),
width: Number(region.width || 100),
height: Number(region.height || 100),
z_index: Number(region.z_index || 0)
})).filter((region) => region.region_key && region.label);
return parsed.map((region) => {
const regionType = normalizeTemplateRegionType(region.region_type);
const regionName = validateMaxLength(region.region_name || region.region_key || region.label || '', REGION_NAME_MAX_LENGTH, 'Region name');
return {
region_key: regionName,
region_type: regionType,
label: regionName,
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
x: Number(region.x || 0),
y: Number(region.y || 0),
width: Number(region.width || 100),
height: Number(region.height || 100),
z_index: Number(region.z_index || 0)
};
}).filter((region) => region.region_key && region.label);
}
}
@@ -89,16 +194,17 @@ function extractTemplateRegions(body) {
const names = readFormArray(body, 'region_name[]');
const labels = readFormArray(body, 'region_label[]');
const types = readFormArray(body, 'region_type[]');
const ratios = readFormArray(body, 'region_lock_ratio[]');
const animationJsons = readFormArray(body, 'region_animation_json[]');
const xs = readFormArray(body, 'region_x[]');
const ys = readFormArray(body, 'region_y[]');
const widths = readFormArray(body, 'region_width[]');
const heights = readFormArray(body, 'region_height[]');
const zs = readFormArray(body, 'region_z[]');
const fonts = readFormArray(body, 'font_family[]');
const regions = [];
for (let i = 0; i < keys.length; i += 1) {
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 regionType = normalizeTemplateRegionType(rawType);
if (!name) {
@@ -108,7 +214,8 @@ function extractTemplateRegions(body) {
region_key: name,
region_type: regionType,
label: name,
font_family: ['text', 'html'].includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
x: Number(xs[i] || 0),
y: Number(ys[i] || 0),
width: Number(widths[i] || 100),
@@ -129,7 +236,7 @@ function getFilesByField(files) {
}
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;
let canvasWidth = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_width) || 1920));
let canvasHeight = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_height) || 1080));
@@ -138,8 +245,12 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
const backgroundImage = filesByField.background_image;
const removeBackgroundImage = Boolean(req.body.remove_background_image);
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
const submittedBackgroundGradient = Object.prototype.hasOwnProperty.call(req.body, 'background_gradient')
? req.body.background_gradient
: existingTemplate && existingTemplate.background_gradient;
const backgroundGradient = normalizeBackgroundGradient(submittedBackgroundGradient);
const backgroundImagePath = backgroundImage
? `/uploads/${backgroundImage.filename}`
? `/media/uploads/${backgroundImage.filename}`
: removeBackgroundImage
? null
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
@@ -152,7 +263,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
let resolvedCanvasSizeId = canvasSizeId;
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];
if (!canvasSize) {
const error = new Error('Canvas size not found.');
@@ -171,17 +282,6 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
error.statusCode = 400;
throw error;
}
regions = [{
region_key: 'region_1',
region_type: 'text',
label: 'Region 1',
font_family: 'Arial',
x: 120,
y: 120,
width: Math.max(200, Math.round(canvasWidth * 0.22)),
height: Math.max(120, Math.round(canvasHeight * 0.15)),
z_index: 1
}];
}
ensureUniqueTemplateRegionNames(regions);
@@ -193,6 +293,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
canvasSizeHeight: canvasHeight,
backgroundImagePath,
backgroundColor,
backgroundGradient,
regions
};
}
@@ -202,5 +303,6 @@ module.exports = {
fetchTemplatesData,
extractTemplateRegions,
buildTemplatePayload,
normalizeBackgroundGradient,
parseJsonSafe
};
+144
View File
@@ -0,0 +1,144 @@
// Timetable group and entry data access helpers.
const { fetchPagedRows, validateMaxLength } = require('./utils');
const NAME_MAX_LENGTH = 255;
const DESCRIPTION_MAX_LENGTH = 255;
const DEFAULT_TIME_ZONE = 'Europe/London';
function normalizeTimeZone(value, fallback) {
const raw = String(value || '').trim();
if (!raw) {
return String(fallback || DEFAULT_TIME_ZONE).trim() || DEFAULT_TIME_ZONE;
}
try {
new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date());
return raw;
} catch (_error) {
const error = new Error('Timetable time zone is invalid.');
error.statusCode = 400;
throw error;
}
}
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.timezone, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
FROM i_timetable_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_timetable_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.timezone, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
FROM i_timetable_groups g
ORDER BY g.modified_at DESC, g.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM i_timetable_groups',
searchColumns: ['g.name', 'g.short_description'],
searchTerm: searchTerm,
sortColumns: {
name: 'g.name',
description: 'g.short_description',
timezone: 'g.timezone',
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, timezone, created_at, modified_at, created_by, modified_by FROM i_timetable_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_timetable_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');
const timezone = normalizeTimeZone(req.body.timezone || req.body.time_zone || fallback.timezone || DEFAULT_TIME_ZONE, fallback.timezone || DEFAULT_TIME_ZONE);
if (!name) {
const error = new Error('Timetable group name is required.');
error.statusCode = 400;
throw error;
}
return {
name: name,
shortDescription: shortDescription,
timezone: timezone
};
}
module.exports = {
normalizeDisplayMode,
fetchTimetablesData,
fetchTimetableGroupsPage,
fetchTimetableGroupById,
fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload,
normalizeTimeZone
};
+376 -1
View File
@@ -1,3 +1,5 @@
// Shared helpers for parsing JSON, reading form arrays, and duplicate-name checks.
function parseJsonSafe(value) {
if (!value) {
return null;
@@ -22,7 +24,380 @@ function readFormArray(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) {
const pageNumber = Math.floor(Number(value) || 1);
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) {
const selectSql = String(options && options.selectSql || '').trim();
const countSql = String(options && options.countSql || '').trim();
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 currentPage = normalizePageNumber(options && options.page);
if (!selectSql || !countSql) {
throw new Error('fetchPagedRows requires selectSql and countSql.');
}
const orderByIndex = findTopLevelOrderByIndex(selectSql);
const groupByIndex = findTopLevelGroupByIndex(selectSql);
let baseSelectSql = selectSql;
let orderBySql = '';
let groupBySql = '';
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 totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const offset = (safeCurrentPage - 1) * pageSize;
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 {
rows: rows || [],
totalItems: totalItems,
totalPages: totalPages,
currentPage: safeCurrentPage,
pageSize: pageSize
};
}
async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) {
const normalizedName = String(name || '').trim();
if (!normalizedName) {
return null;
}
const normalizedColumnName = String(columnName || 'name').trim() || 'name';
const params = [normalizedName];
let sql = `SELECT id, \`${normalizedColumnName}\` AS name FROM \`${tableName}\` WHERE LOWER(TRIM(\`${normalizedColumnName}\`)) = LOWER(TRIM(?))`;
if (excludeId !== undefined && excludeId !== null) {
sql += ' AND id <> ?';
params.push(excludeId);
}
sql += ' LIMIT 1';
const [rows] = await pool.query(sql, params);
return rows[0] || null;
}
module.exports = {
parseJsonSafe,
readFormArray
readFormArray,
validateMaxLength,
truncateToMaxLength,
normalizePageNumber,
normalizeSortDirection,
buildSortOrderClause,
findTopLevelOrderByIndex,
findTopLevelGroupByIndex,
buildSearchFilter,
fetchPagedRows,
fetchDuplicateName
};
+67
View File
@@ -0,0 +1,67 @@
// Convert cached weather snapshots for display without refetching.
function convertTemperature(value, fromUnit, toUnit) {
const number = Number(value);
if (!Number.isFinite(number) || fromUnit === toUnit) return value;
const converted = toUnit === 'fahrenheit' ? number * 9 / 5 + 32 : (number - 32) * 5 / 9;
return Math.round(converted * 10) / 10;
}
function convertWind(value, fromUnit, toUnit) {
const number = Number(value);
if (!Number.isFinite(number) || fromUnit === toUnit) return value;
const metresPerSecond = fromUnit === 'mph' ? number * 0.44704 : fromUnit === 'kmh' ? number / 3.6 : number;
const converted = toUnit === 'mph' ? metresPerSecond / 0.44704 : toUnit === 'kmh' ? metresPerSecond * 3.6 : metresPerSecond;
return Math.round(converted * 10) / 10;
}
function convertPrecipitation(value, fromUnit, toUnit) {
const number = Number(value);
if (!Number.isFinite(number) || fromUnit === toUnit) return value;
const converted = toUnit === 'inch' ? number / 25.4 : number * 25.4;
return Math.round(converted * 100) / 100;
}
function temperatureUnitFromLabel(label) {
return /f/i.test(String(label || '')) ? 'fahrenheit' : 'celsius';
}
function windUnitFromLabel(label) {
const value = String(label || '').toLowerCase();
return value.includes('mph') ? 'mph' : value.includes('m/s') ? 'ms' : 'kmh';
}
function precipitationUnitFromLabel(label) {
return /in/i.test(String(label || '')) ? 'inch' : 'mm';
}
function convertField(data, fields, converter, fromUnit, toUnit) {
fields.forEach(function (field) {
if (data[field] === undefined || data[field] === null) return;
data[field] = Array.isArray(data[field])
? data[field].map(function (value) { return converter(value, fromUnit, toUnit); })
: converter(data[field], fromUnit, toUnit);
});
}
function convertWeatherSnapshot(snapshot, targetUnits) {
const source = snapshot && typeof snapshot === 'object' ? snapshot : {};
const target = Object.assign({ temperature: 'celsius', wind: 'kmh', precipitation: 'mm' }, targetUnits || {});
const result = JSON.parse(JSON.stringify(source));
const currentUnits = source.current_units || {};
const hourlyUnits = source.hourly_units || currentUnits;
const dailyUnits = source.daily_units || currentUnits;
convertField(result.current || {}, ['temperature_2m', 'apparent_temperature'], convertTemperature, temperatureUnitFromLabel(currentUnits.temperature_2m), target.temperature);
convertField(result.current || {}, ['wind_speed_10m'], convertWind, windUnitFromLabel(currentUnits.wind_speed_10m), target.wind);
convertField(result.current || {}, ['precipitation', 'rain'], convertPrecipitation, precipitationUnitFromLabel(currentUnits.precipitation), target.precipitation);
convertField(result.hourly || {}, ['temperature_2m'], convertTemperature, temperatureUnitFromLabel(hourlyUnits.temperature_2m), target.temperature);
convertField(result.hourly || {}, ['wind_speed_10m'], convertWind, windUnitFromLabel(hourlyUnits.wind_speed_10m), target.wind);
convertField(result.hourly || {}, ['precipitation'], convertPrecipitation, precipitationUnitFromLabel(hourlyUnits.precipitation), target.precipitation);
convertField(result.daily || {}, ['temperature_2m_max', 'temperature_2m_min'], convertTemperature, temperatureUnitFromLabel(dailyUnits.temperature_2m_max), target.temperature);
convertField(result.daily || {}, ['wind_speed_10m_max'], convertWind, windUnitFromLabel(dailyUnits.wind_speed_10m_max), target.wind);
convertField(result.daily || {}, ['precipitation_sum'], convertPrecipitation, precipitationUnitFromLabel(dailyUnits.precipitation_sum), target.precipitation);
return result;
}
module.exports = { convertWeatherSnapshot };
+138
View File
@@ -0,0 +1,138 @@
// Weather location data access and form normalization.
const { fetchPagedRows, validateMaxLength } = require('./utils');
const { fetchAppSettings } = require('./app-settings');
const NAME_MAX_LENGTH = 255;
const LOCATION_MAX_LENGTH = 255;
const TIMEZONE_MAX_LENGTH = 128;
const PROVIDERS = ['open-meteo', 'pirate-weather'];
const TEMPERATURE_UNITS = ['celsius', 'fahrenheit'];
const WIND_UNITS = ['kmh', 'mph', 'ms'];
const PRECIPITATION_UNITS = ['mm', 'inch'];
async function fetchWeatherLocationSuggestions(query) {
const search = validateMaxLength(query, LOCATION_MAX_LENGTH, 'Location search');
if (!search) {
return [];
}
const response = await fetch('https://geocoding-api.open-meteo.com/v1/search?name=' + encodeURIComponent(search) + '&count=8&language=en&format=json', {
headers: { Accept: 'application/json', 'User-Agent': 'Pulse Signage weather location lookup' }
});
if (!response.ok) {
throw new Error('Weather location lookup failed.');
}
const data = await response.json();
return (Array.isArray(data.results) ? data.results : []).map(function (result) {
return {
label: [result.name, result.admin1, result.country].filter(Boolean).join(', '),
latitude: Number(result.latitude),
longitude: Number(result.longitude),
timezone: String(result.timezone || '')
};
}).filter(function (result) {
return result.label && Number.isFinite(result.latitude) && Number.isFinite(result.longitude) && result.timezone;
});
}
function normalizeChoice(value, choices, fallback) {
const normalized = String(value || '').trim().toLowerCase();
return choices.includes(normalized) ? normalized : fallback;
}
async function fetchWeatherLocationsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, created_at, modified_at FROM i_weather_locations ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_weather_locations',
searchColumns: ['name', 'location_label', 'timezone', 'provider'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
location: 'location_label',
provider: 'provider',
interval: ['update_interval_value', 'update_interval_unit'],
last_pulled: 'last_pulled_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ weatherLocations: paged.rows }, paged);
}
async function fetchWeatherLocationsData(pool) {
const [rows] = await pool.query('SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_json, created_at, modified_at FROM i_weather_locations ORDER BY modified_at DESC, id DESC');
return { weatherLocations: rows };
}
async function fetchWeatherLocationById(pool, id) {
const [rows] = await pool.query('SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_json, created_at, modified_at, created_by, modified_by FROM i_weather_locations WHERE id = ?', [id]);
return rows[0] || null;
}
async function fetchWeatherLocationForecast(pool, location) {
const source = location || {};
const settings = await fetchAppSettings(pool);
const latitude = Number(source.latitude);
const longitude = Number(source.longitude);
const temperatureUnit = source.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius';
const windUnit = source.wind_unit === 'mph' ? 'mph' : source.wind_unit === 'ms' ? 'ms' : 'kmh';
const precipitationUnit = source.precipitation_unit === 'inch' ? 'inch' : 'mm';
let url;
let headers = { Accept: 'application/json', 'User-Agent': 'Pulse Signage weather reader' };
if (source.provider === 'pirate-weather') {
const apiKey = String(settings['weather.pirate_weather_api_key'] || '').trim();
if (!apiKey) throw new Error('Pirate Weather API key is not configured.');
url = 'https://api.pirateweather.net/forecast/' + encodeURIComponent(apiKey) + '/' + latitude + ',' + longitude + '?units=' + (temperatureUnit === 'fahrenheit' ? 'us' : 'si');
} else {
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), timezone: String(source.timezone || 'auto'), forecast_days: '7', forecast_hours: '24', current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,weather_code,wind_speed_10m,wind_direction_10m,uv_index,cloud_cover', hourly: 'temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,uv_index,cloud_cover', daily: 'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max,precipitation_sum,wind_speed_10m_max,uv_index_max,cloud_cover_mean', temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit });
const apiKey = String(settings['weather.open_meteo_api_key'] || '').trim();
if (apiKey) params.set('apikey', apiKey);
url = 'https://api.open-meteo.com/v1/forecast?' + params.toString();
}
const response = await fetch(url, { headers: headers });
if (!response.ok) throw new Error('Weather provider returned HTTP ' + response.status + '.');
const snapshot = await response.json();
return { snapshot: snapshot, responseJson: JSON.stringify(snapshot), fetchedAt: new Date() };
}
function buildWeatherLocationPayload(req, existingLocation) {
const body = req && req.body ? req.body : {};
const fallback = existingLocation || {};
const name = validateMaxLength(body.name || fallback.name || '', NAME_MAX_LENGTH, 'Weather location name');
const locationLabel = validateMaxLength(body.location_label || fallback.location_label || '', LOCATION_MAX_LENGTH, 'Location label');
const latitude = Number(body.latitude !== undefined ? body.latitude : fallback.latitude);
const longitude = Number(body.longitude !== undefined ? body.longitude : fallback.longitude);
const timezone = validateMaxLength(body.timezone || fallback.timezone || '', TIMEZONE_MAX_LENGTH, 'Timezone');
const provider = normalizeChoice(body.provider || fallback.provider, PROVIDERS, 'open-meteo');
const temperatureUnit = normalizeChoice(body.temperature_unit || fallback.temperature_unit, TEMPERATURE_UNITS, 'celsius');
const windUnit = normalizeChoice(body.wind_unit || fallback.wind_unit, WIND_UNITS, 'kmh');
const precipitationUnit = normalizeChoice(body.precipitation_unit || fallback.precipitation_unit, PRECIPITATION_UNITS, 'mm');
const updateIntervalValue = Number(body.update_interval_value || fallback.update_interval_value || 30);
const updateIntervalUnit = normalizeChoice(body.update_interval_unit || fallback.update_interval_unit, ['minutes', 'hours'], 'minutes');
if (!name || !locationLabel || !timezone) {
const error = new Error('Name, location label, and timezone are required.');
error.statusCode = 400;
throw error;
}
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90 || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
const error = new Error('Latitude must be between -90 and 90, and longitude must be between -180 and 180.');
error.statusCode = 400;
throw error;
}
if (!Number.isInteger(updateIntervalValue) || updateIntervalValue < 1 || updateIntervalValue > 1440) {
const error = new Error('Update interval must be a whole number between 1 and 1440.');
error.statusCode = 400;
throw error;
}
return { name, locationLabel, latitude, longitude, timezone, provider, temperatureUnit, windUnit, precipitationUnit, updateIntervalValue, updateIntervalUnit };
}
module.exports = { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload, PROVIDERS, TEMPERATURE_UNITS, WIND_UNITS, PRECIPITATION_UNITS };
-274
View File
@@ -1,274 +0,0 @@
const mysql = require('mysql2/promise');
const { hashPassword } = require('./auth');
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 addColumnIfMissing(pool, tableName, columnName, columnDefinition) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (rows.length && Number(rows[0].column_count) > 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
}
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM player_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
async function ensureSchema(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS canvas_sizes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
width INT NOT NULL,
height INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_canvas_sizes_dimensions (width, height)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'canvas_sizes', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS playlists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'playlists', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'playlists', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
canvas_size_id INT NULL,
background_image_path VARCHAR(512) NULL,
background_color VARCHAR(32) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'slide_templates', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'modified_by', 'INT NULL');
await pool.query(`
INSERT IGNORE INTO 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)
`);
const [legacyTemplateColumns] = await pool.query(`
SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'slide_templates'
AND column_name IN ('canvas_width', 'canvas_height')
`);
if (legacyTemplateColumns[0] && Number(legacyTemplateColumns[0].column_count) === 2) {
await pool.query(`
UPDATE slide_templates st
JOIN canvas_sizes cs ON cs.width = st.canvas_width AND cs.height = st.canvas_height
SET st.canvas_size_id = cs.id
WHERE st.canvas_size_id IS NULL
`);
}
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_template_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
region_key VARCHAR(100) NOT NULL,
region_type VARCHAR(20) NOT NULL,
label VARCHAR(255) NOT NULL,
x INT NOT NULL DEFAULT 0,
y INT NOT NULL DEFAULT 0,
width INT NOT NULL DEFAULT 100,
height INT NOT NULL DEFAULT 100,
z_index INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'slide_template_regions', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS slides (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
body TEXT NULL,
template_id INT NULL,
content_json JSON NULL,
media_path VARCHAR(512) NULL,
media_type VARCHAR(100) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'slides', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'slides', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS playlist_slides (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_id INT NOT NULL,
slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
duration_seconds INT NOT NULL DEFAULT 10,
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
schedule_start_datetime DATETIME NULL,
schedule_end_datetime DATETIME NULL,
schedule_start_time TIME NULL,
schedule_end_time TIME NULL,
schedule_days_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'playlist_slides', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS screens (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
playlist_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'screens', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS player_onboarding_devices (
device_id VARCHAR(128) PRIMARY KEY,
client_name VARCHAR(255) NULL,
screen_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NULL,
username VARCHAR(255) NOT NULL UNIQUE,
password_hash CHAR(64) NOT NULL,
password_salt VARCHAR(64) NOT NULL,
password_iterations INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'users', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'users', 'password_hash', 'CHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL');
await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'users', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'users', 'modified_by', 'INT NULL');
await pool.query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
session_hash CHAR(64) PRIMARY KEY,
user_id INT NOT NULL,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'auth_sessions', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'auth_sessions', 'modified_by', 'INT NULL');
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
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 = ""');
}
module.exports = {
createPool,
ensureSchema,
pruneStaleOnboardingDevices
};
+107
View File
@@ -0,0 +1,107 @@
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(
`UPDATE a_permissions
SET name = ?, section_name = ?, description = ?, modified_by = ?
WHERE permission_key = ?`,
[permission.name, permission.sectionName, permission.description || null, null, permission.key]
);
await pool.query(
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
SELECT ?, ?, ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM a_permissions WHERE permission_key = ?
)`,
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null, permission.key]
);
}
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]
);
}
const [legacyRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', ['administrators']);
const [currentRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
if (legacyRoleRows.length && !currentRoleRows.length) {
await pool.query(
`UPDATE a_roles
SET role_key = ?, name = ?, description = ?, modified_by = ?
WHERE role_key = ?`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, 'administrators']
);
}
await pool.query(
`INSERT INTO a_roles (role_key, name, description, created_by, modified_by)
SELECT ?, ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM a_roles WHERE role_key = ?
)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null, DEFAULT_ROLE.key]
);
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 INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
SELECT ?, permissions.id, NULL, NULL
FROM a_permissions permissions
LEFT JOIN a_role_permissions existing
ON existing.role_id = ? AND existing.permission_id = permissions.id
WHERE existing.id IS NULL`,
[defaultRoleId, 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
};
+28
View File
@@ -0,0 +1,28 @@
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 screen_id IS NULL
AND modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
module.exports = {
createPool,
pruneStaleOnboardingDevices
};
+486
View File
@@ -0,0 +1,486 @@
const { version: appVersion } = require('#root/package.json');
const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('./migrations');
// Snapshot only: keep this file aligned with the current schema state.
async function ensureSchema(pool, options) {
const schemaLockName = 'pulse_signage_schema_lock';
const schemaConnection = await pool.getConnection();
try {
pool = schemaConnection;
const [lockRows] = await pool.query('SELECT GET_LOCK(?, 120) AS lock_acquired', [schemaLockName]);
if (!Number(lockRows && lockRows[0] && lockRows[0].lock_acquired)) {
throw new Error('Unable to acquire the schema migration lock.');
}
try {
const currentVersion = await detectSchemaVersion(pool);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: currentVersion });
const updateRequired = pendingMigrations.length > 0;
console.info('[schema] previous=' + currentVersion + ' current=' + appVersion + ' update=' + (updateRequired ? 'yes' : 'no'));
await pool.query(`
CREATE TABLE IF NOT EXISTS c_canvas_sizes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
width INT NOT NULL,
height 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,
UNIQUE KEY uq_canvas_sizes_dimensions (width, height)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS c_playlists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
fade_between_slides 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_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
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
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS c_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
canvas_size_id INT NULL,
background_image_path VARCHAR(512) NULL,
background_color VARCHAR(32) NULL,
background_gradient LONGTEXT 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 c_template_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
region_key VARCHAR(100) NOT NULL,
region_type VARCHAR(20) NOT NULL,
label VARCHAR(255) NOT NULL,
font_family VARCHAR(100) NULL,
lock_ratio VARCHAR(20) NULL,
animation_json JSON NULL,
x INT NOT NULL DEFAULT 0,
y INT NOT NULL DEFAULT 0,
width INT NOT NULL DEFAULT 100,
height INT NOT NULL DEFAULT 100,
z_index INT NOT NULL DEFAULT 0,
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 c_slides (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
template_id INT NULL,
content_json JSON NULL,
thumbnail_path VARCHAR(512) 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 c_playlist_slides (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_id INT NOT NULL,
slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
disable_audio TINYINT(1) NOT NULL DEFAULT 1,
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_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,
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_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_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
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS d_players (
id INT AUTO_INCREMENT PRIMARY KEY,
identifier VARCHAR(128) NOT NULL UNIQUE,
public_base_url VARCHAR(512) NULL,
internal_base_url VARCHAR(512) NULL,
last_seen_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS d_screens (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
playlist_id INT 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_screens_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS d_announcements (
id INT AUTO_INCREMENT PRIMARY KEY,
message TEXT NOT NULL,
short_label VARCHAR(255) NOT NULL DEFAULT '',
announcement_type VARCHAR(32) NOT NULL DEFAULT 'lower-third',
color_key VARCHAR(32) NOT NULL DEFAULT 'primary',
icon_key VARCHAR(64) NOT NULL DEFAULT 'megaphone-fill',
duration_seconds INT NULL,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
INDEX idx_announcements_expires_at (expires_at),
INDEX idx_announcements_modified_at (modified_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS d_announcement_screens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
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,
UNIQUE KEY uq_announcement_screens_pair (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,
name VARCHAR(255) NOT NULL,
feed_url VARCHAR(1024) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
update_interval_value INT NOT NULL DEFAULT 60,
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
item_limit INT NOT NULL DEFAULT 1,
last_pulled_at 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
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS i_rss_feed_items (
id INT AUTO_INCREMENT PRIMARY KEY,
rss_feed_id INT NOT NULL,
position INT NOT NULL,
item_json MEDIUMTEXT 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_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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS i_api_sources (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
api_url VARCHAR(1024) NOT NULL,
request_method VARCHAR(10) NOT NULL DEFAULT 'GET',
request_body_json MEDIUMTEXT NULL,
auth_method VARCHAR(32) NOT NULL DEFAULT 'none',
auth_username VARCHAR(255) NULL,
auth_password MEDIUMTEXT NULL,
auth_bearer_token MEDIUMTEXT NULL,
auth_header_name VARCHAR(255) NULL,
auth_header_value MEDIUMTEXT NULL,
token_url VARCHAR(1024) NULL,
token_request_body_json MEDIUMTEXT NULL,
token_response_path VARCHAR(255) NULL DEFAULT 'access_token',
token_header_name VARCHAR(255) NULL DEFAULT 'Authorization',
token_header_prefix VARCHAR(64) NULL DEFAULT 'Bearer',
items_path VARCHAR(255) NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
update_interval_value INT NOT NULL DEFAULT 60,
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
last_pulled_at TIMESTAMP NULL,
last_pull_error MEDIUMTEXT NULL,
last_response_status INT NULL,
last_response_content_type VARCHAR(255) NULL,
last_response_json MEDIUMTEXT 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_weather_locations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
location_label VARCHAR(255) NOT NULL,
latitude DECIMAL(9,6) NOT NULL,
longitude DECIMAL(9,6) NOT NULL,
timezone VARCHAR(128) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'open-meteo',
temperature_unit VARCHAR(16) NOT NULL DEFAULT 'celsius',
wind_unit VARCHAR(16) NOT NULL DEFAULT 'kmh',
precipitation_unit VARCHAR(16) NOT NULL DEFAULT 'mm',
enabled TINYINT(1) NOT NULL DEFAULT 1,
update_interval_value INT NOT NULL DEFAULT 30,
update_interval_unit VARCHAR(16) NOT NULL DEFAULT 'minutes',
last_pulled_at DATETIME NULL,
last_pull_error VARCHAR(1024) NULL,
last_response_json MEDIUMTEXT 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_weather_locations_modified_at (modified_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS i_timetable_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
short_description VARCHAR(255) NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'Europe/London',
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_timetable_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_timetable_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_timetable_groups(id) ON DELETE CASCADE,
INDEX idx_timetable_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 (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
device_id VARCHAR(128) NOT NULL UNIQUE,
client_name VARCHAR(255) NULL,
screen_id INT 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_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
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS a_users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NULL,
username VARCHAR(255) NOT NULL UNIQUE,
password_hash CHAR(64) NOT NULL,
password_salt VARCHAR(64) NOT NULL,
password_iterations INT NOT NULL,
must_change_password TINYINT(1) NOT NULL DEFAULT 0,
account_locked TINYINT(1) NOT NULL DEFAULT 0,
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 a_roles (
id INT AUTO_INCREMENT PRIMARY KEY,
role_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT 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 a_permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
permission_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
section_name VARCHAR(255) NOT NULL,
description TEXT 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 a_role_permissions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
role_id INT NOT NULL,
permission_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,
UNIQUE KEY uq_role_permissions_pair (role_id, permission_id),
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 a_permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS a_user_roles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
role_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,
UNIQUE KEY uq_user_roles_pair (user_id, role_id),
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 a_roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS a_sessions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
session_hash CHAR(64) NOT NULL UNIQUE,
user_id INT NOT NULL,
ip_address VARCHAR(255) NULL,
user_agent VARCHAR(512) NULL,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
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
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS a_login_attempts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
rate_key VARCHAR(600) NOT NULL UNIQUE,
failed_count INT NOT NULL DEFAULT 0,
last_failed_at DATETIME NULL,
locked_until DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS o_background_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_key VARCHAR(191) NULL,
task_type VARCHAR(100) NOT NULL,
title VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL DEFAULT 'general',
status VARCHAR(20) NOT NULL,
payload_json MEDIUMTEXT NULL,
metadata_json MEDIUMTEXT NULL,
attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
error_message MEDIUMTEXT NULL,
INDEX idx_background_tasks_status (status),
INDEX idx_background_tasks_key (task_key),
INDEX idx_background_tasks_type (task_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS o_app_settings (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(191) NOT NULL UNIQUE,
setting_value JSON 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
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await runMigrations(pool, Object.assign({}, options, { currentVersion: currentVersion }));
await recordSchemaVersion(pool, appVersion);
} finally {
await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () {
});
}
} finally {
schemaConnection.release();
}
}
module.exports = {
ensureSchema,
};
+1024
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+548 -29
View File
@@ -2,69 +2,571 @@ const express = require('express');
const fs = require('fs');
const http = require('http');
const path = require('path');
const { WebSocket } = require('ws');
const common = require('./common');
const { createPlayerRuntime } = require('./player/runtime');
const { createPlayerPlaylistService } = require('./player/playlist');
const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
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 { pruneStaleOnboardingDevices } = require('./db');
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
const { createRequestAuthHeaders } = require('#src/request-auth');
const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/data/player-registry');
// Player runtime, upload API, and websocket wiring.
// Player runtime, media API, and websocket wiring.
async function start() {
const app = express();
const pool = common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 3001);
const pool = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '') ? null : common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 8081);
const PLAYER_PUBLIC_URL = String(process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const WEB_INTERNAL_URL = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, '');
const isRemotePlayer = Boolean(BRIDGE_PUBLIC_URL);
const PLAYER_INTERNAL_URL = String(isRemotePlayer ? BRIDGE_PUBLIC_URL : (process.env.PLAYER_INTERNAL_URL || PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '')).trim().replace(/\/$/, '');
const PLAYER_DEVICE_ID = getConfiguredPlayerIdentifier();
const PLAYER_AGENT_RECONNECT_DELAY_MS = Number(process.env.PLAYER_AGENT_RECONNECT_DELAY_MS || 5000);
const ASSET_DIR = path.join(__dirname, 'player', 'public');
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
const ONBOARDING_QUEUE_FILE = path.join(UPLOAD_DIR, 'player-onboarding-queue.json');
const MEDIA_DIR = path.join(__dirname, '..', 'media');
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
const RECONNECT_SYNC_STALE_MS = 60 * 1000;
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
let thinClientSocket = null;
let lastDisconnectAt = 0;
let playerPublicBaseUrl = PLAYER_PUBLIC_URL || null;
let refreshThinClientRegistration = null;
let activePairingCode = '';
let activePairingCodes = [];
let activePairingSessions = [];
const playerRuntime = createPlayerRuntime({
pool: pool,
normalizeDeviceId: normalizeDeviceId
normalizeDeviceId: normalizeDeviceId,
notifySnapshot: function (snapshot) {
if (!thinClientSocket || thinClientSocket.readyState !== WebSocket.OPEN) {
return;
}
try {
thinClientSocket.send(JSON.stringify({
type: 'snapshot',
deviceId: PLAYER_DEVICE_ID,
slug: snapshot && snapshot.slug ? String(snapshot.slug).trim() : '',
connections: Array.isArray(snapshot && snapshot.connections) ? snapshot.connections : []
}));
} catch (_error) {
}
}
});
const playerPlaylistService = createPlayerPlaylistService({
pool: pool,
common: common
const playerPlaylistService = isRemotePlayer
? null
: createPlayerPlaylistService({
pool: pool,
common: common,
mediaDir: MEDIA_DIR,
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
});
const rtmpStreamService = createRtmpStreamService({
mediaDir: MEDIA_DIR
});
const server = http.createServer(app);
playerRuntime.installWebsocket(server);
app.use(express.json());
registerPlayerOnboardingRoutes(app, {
pool: pool,
common: common,
playerRuntime: playerRuntime,
onboardingStore: onboardingStore,
QRCode: require('qrcode')
});
registerPlayerRoutes(app, {
pool: pool,
common: common,
uploadDir: UPLOAD_DIR,
assetDir: ASSET_DIR,
playerRuntime: playerRuntime,
playerPlaylistService: playerPlaylistService
});
let hasLoggedPlayerStartup = false;
function normalizePlayerPublicBaseUrl(value) {
const normalized = String(value || '').trim().replace(/\/$/, '');
if (!normalized) {
return null;
}
try {
return new URL(normalized).origin.replace(/\/$/, '');
} catch (_error) {
return normalized;
}
}
function setPlayerPublicBaseUrl(value) {
const nextBaseUrl = normalizePlayerPublicBaseUrl(value);
if (!nextBaseUrl || nextBaseUrl === playerPublicBaseUrl) {
return;
}
playerPublicBaseUrl = nextBaseUrl;
if (typeof refreshThinClientRegistration === 'function') {
refreshThinClientRegistration();
}
}
function getPlayerPublicBaseUrl() {
return playerPublicBaseUrl;
}
function logPlayerStartup(connectionState) {
if (hasLoggedPlayerStartup) {
return;
}
hasLoggedPlayerStartup = true;
console.info('[player] startup', {
mode: isRemotePlayer ? 'bridge client' : 'local',
connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false,
publicBaseUrl: getPlayerPublicBaseUrl(),
bridgeBaseUrl: PLAYER_INTERNAL_URL || null,
bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null
});
}
fs.mkdirSync(MEDIA_DIR, { recursive: true });
function resolveLocalMediaFilePath(fileName) {
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return null;
}
const resolvedMediaDir = path.resolve(MEDIA_DIR);
const resolvedFilePath = path.resolve(MEDIA_DIR, relativePath);
if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) {
return null;
}
return resolvedFilePath;
}
async function triggerWebMediaSync() {
const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL;
if (!isRemotePlayer || !syncBaseUrl) {
return false;
}
const requestBody = {
playerIdentifier: PLAYER_DEVICE_ID,
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL
};
try {
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-media',
body: requestBody
});
const response = await fetch(`${syncBaseUrl}/api/internal/sync/player-media`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json',
'Content-Type': 'application/json'
}, authHeaders),
body: JSON.stringify(requestBody)
});
return Boolean(response && response.ok);
} catch (_error) {
console.warn('[player] Startup media sync failed');
return false;
}
}
async function triggerWebFontSync() {
const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL;
if (!isRemotePlayer || !syncBaseUrl) {
return false;
}
const requestBody = {
playerIdentifier: PLAYER_DEVICE_ID,
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL
};
try {
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-font',
body: requestBody
});
const response = await fetch(`${syncBaseUrl}/api/internal/sync/player-font`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json',
'Content-Type': 'application/json'
}, authHeaders),
body: JSON.stringify(requestBody)
});
return Boolean(response && response.ok);
} catch (_error) {
console.warn('[player] Startup font sync failed');
return false;
}
}
let webMediaSyncCompleted = false;
let webFontSyncCompleted = false;
let webFontSyncTriggered = false;
function shouldTriggerReconnectSync() {
if (!lastDisconnectAt) {
return true;
}
return Date.now() - lastDisconnectAt >= RECONNECT_SYNC_STALE_MS;
}
async function handleThinClientCommand(socket, rawMessage) {
let payload = null;
try {
payload = JSON.parse(String(rawMessage || ''));
} catch (_error) {
return;
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || String(payload.type || '').trim() !== 'command') {
return;
}
const requestId = String(payload.requestId || '').trim() || null;
const command = String(payload.command || '').trim().toLowerCase();
const response = {
type: 'command-response',
requestId: requestId,
ok: false
};
try {
if (command === 'media-put') {
const relativePath = String(payload.relativePath || payload.filename || '').trim();
const filePath = resolveLocalMediaFilePath(relativePath);
const bodyBase64 = String(payload.bodyBase64 || '').trim();
if (!filePath || !bodyBase64) {
response.error = 'Invalid media payload.';
} else {
const bodyBuffer = Buffer.from(bodyBase64, 'base64');
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, bodyBuffer);
response.ok = true;
}
} else if (command === 'media-delete') {
const relativePath = String(payload.relativePath || payload.filename || '').trim();
const filePath = resolveLocalMediaFilePath(relativePath);
if (!filePath) {
response.error = 'Invalid media path.';
} else {
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (!error || error.code !== 'ENOENT') {
throw error;
}
}
response.ok = true;
}
} else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname', 'announcement-refresh'].indexOf(command) !== -1) {
const screenSlug = String(payload.screenSlug || payload.slug || '').trim();
if (!screenSlug) {
response.error = 'Screen slug is required.';
} else if (payload.connectionId) {
const sent = await playerRuntime.sendCommandToConnection(screenSlug, String(payload.connectionId || '').trim(), payload);
response.ok = sent > 0;
if (!response.ok) {
response.error = 'Player is not connected.';
}
} else {
const sent = await playerRuntime.broadcastCommand(screenSlug, payload);
response.ok = sent > 0;
if (!response.ok) {
response.error = 'Player is not connected.';
}
}
} else {
response.error = 'Unsupported command.';
}
} catch (error) {
response.error = error && error.message ? error.message : 'Command failed.';
}
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(response));
}
}
app.use(function (error, _req, res, _next) {
console.error(error);
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
});
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
await ensureFontLibrary(MEDIA_DIR);
registerPlayerOnboardingRoutes(app, {
pool: pool,
common: common,
playerRuntime: playerRuntime,
onboardingStore: onboardingStore,
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
playerDeviceId: PLAYER_DEVICE_ID,
onPairingCode: function (code, codes) {
activePairingCode = String(code || '').trim().toUpperCase();
activePairingSessions = Array.isArray(codes) ? codes.map(function (entry) {
return { deviceId: String(entry && entry.deviceId || '').trim(), clientId: String(entry && entry.clientId || '').trim(), code: String(entry && entry.code || '').trim().toUpperCase() };
}).filter(function (entry) { return entry.deviceId && entry.code; }) : [];
activePairingCodes = activePairingSessions.map(function (entry) { return entry.code; });
if (typeof refreshThinClientRegistration === 'function') {
refreshThinClientRegistration();
}
}
});
registerPlayerRoutes(app, {
pool: pool,
common: common,
mediaDir: MEDIA_DIR,
assetDir: ASSET_DIR,
playerRuntime: playerRuntime,
playerPlaylistService: playerPlaylistService,
rtmpStreamService: rtmpStreamService,
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
playerDeviceId: PLAYER_DEVICE_ID,
onPlayerPublicBaseUrl: setPlayerPublicBaseUrl
});
function createThinClientWebSocketUrl() {
if (!BRIDGE_PUBLIC_URL) {
return null;
}
return BRIDGE_PUBLIC_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players';
}
function startThinClientRegistration() {
const thinClientUrl = createThinClientWebSocketUrl();
if (!thinClientUrl) {
return null;
}
let socket = null;
let reconnectTimer = null;
let heartbeatTimer = null;
function clearTimers() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function connect() {
clearTimers();
const timestamp = String(Date.now());
const authHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: '/ws/players',
timestamp: timestamp
});
let webMediaSyncTriggered = false;
socket = new WebSocket(thinClientUrl, {
headers: Object.assign({
'x-pulse-request-timestamp': timestamp
}, authHeaders)
});
thinClientSocket = socket;
function sendHeartbeat() {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(JSON.stringify({
type: 'heartbeat',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_URL,
pairingCode: activePairingCode,
pairingCodes: activePairingCodes,
pairingSessions: activePairingSessions
}));
}
refreshThinClientRegistration = sendHeartbeat;
function triggerMediaSyncIfNeeded() {
if (webMediaSyncTriggered || webMediaSyncCompleted) {
return;
}
webMediaSyncTriggered = true;
triggerWebMediaSync().then(function (success) {
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
if (!success) {
webMediaSyncTriggered = false;
}
}).catch(function () {
webMediaSyncTriggered = false;
});
}
function triggerFontSyncIfNeeded() {
if (webFontSyncTriggered || webFontSyncCompleted) {
return;
}
webFontSyncTriggered = true;
triggerWebFontSync().then(function (success) {
webFontSyncCompleted = Boolean(success) || webFontSyncCompleted;
if (!success) {
webFontSyncTriggered = false;
}
}).catch(function () {
webFontSyncTriggered = false;
webFontSyncCompleted = false;
});
}
function sendSnapshot(slug) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
try {
socket.send(JSON.stringify({
type: 'snapshot',
deviceId: PLAYER_DEVICE_ID,
slug: String(slug || '').trim(),
connections: playerRuntime.snapshotConnections(slug)
}));
} catch (_error) {
}
}
socket.on('open', function () {
logPlayerStartup({
connected: true
});
socket.send(JSON.stringify({
type: 'register',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_URL,
pairingCode: activePairingCode,
pairingCodes: activePairingCodes,
pairingSessions: activePairingSessions
}));
playerRuntime.snapshotSlugs().forEach(function (slug) {
sendSnapshot(slug);
});
heartbeatTimer = setInterval(function () {
sendHeartbeat();
}, DB_SYNC_INTERVAL_MS);
});
socket.on('message', function (rawMessage) {
let parsedMessage = null;
try {
parsedMessage = JSON.parse(String(rawMessage || '{}'));
} catch (_error) {
parsedMessage = null;
}
if (parsedMessage && String(parsedMessage.type || '').trim() === 'registered') {
sendHeartbeat();
return;
}
if (parsedMessage && String(parsedMessage.type || '').trim() === 'heartbeat-ack') {
if (shouldTriggerReconnectSync()) {
triggerMediaSyncIfNeeded();
triggerFontSyncIfNeeded();
}
return;
}
handleThinClientCommand(socket, rawMessage).catch(function (error) {
try {
socket.send(JSON.stringify({
type: 'command-response',
requestId: null,
ok: false,
error: error && error.message ? error.message : 'Command failed.'
}));
} catch (_sendError) {
// ignore send errors
}
});
});
socket.on('close', function () {
lastDisconnectAt = Date.now();
webFontSyncTriggered = false;
webFontSyncCompleted = false;
webMediaSyncCompleted = false;
clearTimers();
thinClientSocket = null;
refreshThinClientRegistration = null;
reconnectTimer = setTimeout(connect, PLAYER_AGENT_RECONNECT_DELAY_MS);
});
socket.on('error', function () {
try {
socket.close();
} catch (_error) {
// ignore reconnect noise
}
});
}
connect();
return function stop() {
clearTimers();
if (socket) {
try {
socket.close();
} catch (_error) {
// ignore close errors
}
socket = null;
}
};
}
if (!isRemotePlayer) {
logPlayerStartup({
connected: false
});
}
const stopThinClientRegistration = startThinClientRegistration();
server.listen(PORT, function () {
console.log(`Pulse Signage app listening on port ${PORT}`);
});
async function syncDatabaseState() {
if (isRemotePlayer) {
return;
}
try {
await common.ensureSchema(pool);
await recordPlayerHeartbeat(pool, {
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_URL
}).catch(function (error) {
console.error(error);
});
if (playerRuntime.snapshotAllConnections().length > 0) {
await pruneStaleOnboardingDevices(pool);
await common.pruneStaleOnboardingDevices(pool);
}
await onboardingStore.flushBindings(function (entry) {
@@ -83,11 +585,28 @@ async function start() {
}
await syncDatabaseState();
if (PLAYER_DEVICE_ID && !isRemotePlayer) {
const { upsertPlayerRegistration } = require('./player/onboarding');
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) {
console.error(error);
});
}
setInterval(function () {
if (isRemotePlayer) {
return;
}
syncDatabaseState().catch(function (error) {
console.error(error);
});
}, DB_SYNC_INTERVAL_MS);
process.on('exit', function () {
if (typeof stopThinClientRegistration === 'function') {
stopThinClientRegistration();
}
});
}
module.exports = { start };
@@ -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>
+454
View File
@@ -0,0 +1,454 @@
// RTMP stream session management and ffmpeg probe orchestration.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
function createRtmpStreamService(options) {
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
const ffmpegPath = options && options.ffmpegPath ? options.ffmpegPath : 'ffmpeg';
const ffprobePath = options && options.ffprobePath ? options.ffprobePath : 'ffprobe';
const probeIntervalMs = options && options.probeIntervalMs ? Number(options.probeIntervalMs) : 2000;
const sessions = new Map();
const watchedSources = new Set();
const sourceStatusCache = new Map();
const sourceProbePromises = new Map();
let probeTimer = null;
if (!mediaDir) {
throw new Error('mediaDir is required');
}
const cacheRoot = path.join(mediaDir, 'rtmp-cache');
function normalizeSourceUrl(value) {
const sourceUrl = String(value || '').trim();
if (!sourceUrl || !/^rtmps?:\/\//i.test(sourceUrl)) {
const error = new Error('A valid RTMP URL is required.');
error.statusCode = 400;
throw error;
}
return sourceUrl;
}
function getSessionKey(sourceUrl, disableAudio) {
return crypto.createHash('sha1').update(String(sourceUrl)).update('\0').update(disableAudio ? '1' : '0').digest('hex');
}
async function ensureDirectory(dirPath) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
async function waitForFile(filePath, timeoutMs) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const stat = await fs.promises.stat(filePath);
if (stat.isFile() && stat.size > 0) {
return true;
}
} catch (_error) {
// keep waiting
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
}
function runCommand(command, args, timeoutMs) {
return new Promise(function (resolve) {
var child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe']
});
var stdout = '';
var stderr = '';
var finished = false;
var timer = null;
function done(result) {
if (finished) {
return;
}
finished = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
resolve(result);
}
child.stdout.on('data', function (chunk) {
stdout += String(chunk || '');
});
child.stderr.on('data', function (chunk) {
stderr += String(chunk || '');
});
child.on('error', function (error) {
done({ ok: false, error: error, stdout: stdout, stderr: stderr, timedOut: false });
});
child.on('exit', function (code, signal) {
done({ ok: code === 0, code: code, signal: signal, stdout: stdout, stderr: stderr, timedOut: false });
});
timer = setTimeout(function () {
try {
child.kill('SIGKILL');
} catch (_error) {
// ignore timeout cleanup errors
}
done({ ok: false, code: null, signal: 'SIGKILL', stdout: stdout, stderr: stderr, timedOut: true });
}, Math.max(1000, Number(timeoutMs || 0) || 4000));
});
}
async function probeSourceUrl(sourceUrl) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
const result = await runCommand(ffprobePath, [
'-hide_banner',
'-loglevel', 'error',
'-rw_timeout', '3000000',
'-show_streams',
'-of', 'json',
normalizedSource
], 4000);
if (!result.ok) {
return {
live: false,
timedOut: Boolean(result.timedOut),
stderr: String(result.stderr || '').trim()
};
}
try {
const parsed = JSON.parse(String(result.stdout || '{}'));
const streams = Array.isArray(parsed && parsed.streams) ? parsed.streams : [];
return {
live: streams.length > 0,
timedOut: false,
stderr: String(result.stderr || '').trim()
};
} catch (_error) {
return {
live: false,
timedOut: false,
stderr: String(result.stderr || '').trim()
};
}
}
function getSourceStatus(sourceUrl) {
return sourceStatusCache.get(String(sourceUrl || '').trim()) || null;
}
function setSourceStatus(sourceUrl, nextStatus) {
const normalizedSource = String(sourceUrl || '').trim();
if (!normalizedSource) {
return null;
}
const status = Object.assign({
live: false,
probing: false,
checkedAt: Date.now(),
timedOut: false,
stderr: ''
}, nextStatus || {});
status.live = Boolean(status.live);
status.probing = Boolean(status.probing);
status.checkedAt = Number(status.checkedAt || Date.now());
sourceStatusCache.set(normalizedSource, status);
return status;
}
function registerSourceWatch(sourceUrl) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
watchedSources.add(normalizedSource);
return normalizedSource;
}
async function refreshSourceStatus(sourceUrl) {
const normalizedSource = registerSourceWatch(sourceUrl);
const existingPromise = sourceProbePromises.get(normalizedSource) || null;
if (existingPromise) {
return existingPromise;
}
setSourceStatus(normalizedSource, Object.assign({}, getSourceStatus(normalizedSource) || {}, {
probing: true
}));
const probePromise = probeSourceUrl(normalizedSource).then(function (probe) {
return setSourceStatus(normalizedSource, {
live: Boolean(probe.live),
probing: false,
checkedAt: Date.now(),
timedOut: Boolean(probe.timedOut),
stderr: String(probe.stderr || '')
});
}).catch(function (error) {
return setSourceStatus(normalizedSource, {
live: false,
probing: false,
checkedAt: Date.now(),
timedOut: false,
stderr: String(error && error.message ? error.message : '')
});
}).finally(function () {
sourceProbePromises.delete(normalizedSource);
});
sourceProbePromises.set(normalizedSource, probePromise);
return probePromise;
}
function scheduleSourceRefresh(sourceUrl) {
const normalizedSource = registerSourceWatch(sourceUrl);
const currentStatus = getSourceStatus(normalizedSource);
if (currentStatus && currentStatus.probing) {
return sourceProbePromises.get(normalizedSource) || Promise.resolve(currentStatus);
}
return refreshSourceStatus(normalizedSource);
}
function isSourceStatusFresh(status) {
if (!status || !status.checkedAt) {
return false;
}
return Date.now() - Number(status.checkedAt || 0) < probeIntervalMs;
}
function sweepWatchedSources() {
watchedSources.forEach(function (sourceUrl) {
const status = getSourceStatus(sourceUrl);
if (!status || !isSourceStatusFresh(status) || status.live === false) {
scheduleSourceRefresh(sourceUrl);
}
});
}
if (probeIntervalMs > 0) {
probeTimer = setInterval(function () {
sweepWatchedSources();
}, probeIntervalMs);
if (probeTimer && typeof probeTimer.unref === 'function') {
probeTimer.unref();
}
}
async function isSessionLive(session) {
if (!session || !session.process) {
return false;
}
if (session.exitCode !== undefined && session.exitCode !== null) {
return false;
}
if (session.exitSignal !== undefined && session.exitSignal !== null) {
return false;
}
try {
const stat = await fs.promises.stat(session.manifestPath);
return stat.isFile() && stat.size > 0;
} catch (_error) {
return false;
}
}
function buildPlaylistUrl(key) {
return '/api/rtmp/streams/' + encodeURIComponent(key) + '/index.m3u8';
}
async function ensureSession(sourceUrl, disableAudio) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
const normalizedDisableAudio = Boolean(disableAudio);
const key = getSessionKey(normalizedSource, normalizedDisableAudio);
const directory = path.join(cacheRoot, key);
const manifestPath = path.join(directory, 'index.m3u8');
const existingSession = sessions.get(key) || null;
if (existingSession && existingSession.process && existingSession.exitCode === null && existingSession.exitSignal === null) {
return existingSession;
}
if (existingSession) {
sessions.delete(key);
try {
if (existingSession.process && typeof existingSession.process.kill === 'function') {
existingSession.process.kill('SIGKILL');
}
} catch (_error) {
// ignore cleanup errors
}
try {
await fs.promises.rm(directory, { recursive: true, force: true });
} catch (_error2) {
// ignore cleanup errors
}
}
await ensureDirectory(directory);
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-nostdin',
'-i', normalizedSource,
'-fflags', '+genpts',
'-f', 'hls',
'-hls_time', '1',
'-hls_init_time', '1',
'-hls_list_size', '8',
'-hls_flags', 'delete_segments+append_list+omit_endlist+independent_segments+program_date_time',
'-hls_segment_filename', path.join(directory, 'segment-%05d.ts')
];
if (normalizedDisableAudio) {
args.push('-an');
args.push('-c:v', 'copy');
} else {
args.push('-c', 'copy');
}
args.push(manifestPath);
const child = spawn(ffmpegPath, args, {
stdio: ['ignore', 'ignore', 'pipe']
});
child.stderr.on('data', function (chunk) {
const message = String(chunk || '').trim();
if (message) {
console.error('[rtmp]', message);
}
});
child.on('exit', function (code, signal) {
const session = sessions.get(key);
if (session && session.process === child) {
session.process = null;
session.exitCode = code;
session.exitSignal = signal;
}
});
const session = {
key: key,
sourceUrl: normalizedSource,
disableAudio: normalizedDisableAudio,
directory: directory,
manifestPath: manifestPath,
playlistUrl: buildPlaylistUrl(key),
process: child,
exitCode: null,
exitSignal: null,
ready: waitForFile(manifestPath, 5000)
};
sessions.set(key, session);
return session;
}
async function getPlaylistUrl(sourceUrl, disableAudio) {
const session = await ensureSession(sourceUrl, disableAudio);
await session.ready.catch(function () {
return false;
});
return session.playlistUrl + '?t=' + Date.now();
}
async function getSessionStatus(sourceUrl, disableAudio) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
const normalizedDisableAudio = Boolean(disableAudio);
registerSourceWatch(normalizedSource);
let sourceStatus = getSourceStatus(normalizedSource);
if (!sourceStatus) {
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
return null;
});
sourceStatus = getSourceStatus(normalizedSource);
} else if (!isSourceStatusFresh(sourceStatus) && !sourceStatus.probing) {
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
return null;
});
}
if (!sourceStatus || !sourceStatus.live) {
return {
session: null,
ready: false,
live: false,
probing: Boolean(sourceStatus && sourceStatus.probing),
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
timedOut: Boolean(sourceStatus && sourceStatus.timedOut),
stderr: String(sourceStatus && sourceStatus.stderr || '')
};
}
const session = await ensureSession(normalizedSource, normalizedDisableAudio);
const ready = await session.ready.catch(function () {
return false;
});
const live = ready && Boolean(session && session.process && session.exitCode === null && session.exitSignal === null);
return {
session: session,
ready: ready,
live: live,
probing: Boolean(sourceStatus && sourceStatus.probing),
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
timedOut: false,
stderr: ''
};
}
function getSessionByKey(key) {
return sessions.get(String(key || '').trim()) || null;
}
async function getManifestFilePath(key) {
const session = getSessionByKey(key);
if (!session) {
return null;
}
if (!session.process || session.exitCode !== null || session.exitSignal !== null) {
return null;
}
const live = await isSessionLive(session);
if (!live) {
return null;
}
return session.manifestPath;
}
async function getSegmentFilePath(key, fileName) {
const session = getSessionByKey(key);
if (!session) {
return null;
}
const segmentName = path.basename(String(fileName || '').trim());
if (!segmentName || segmentName === 'index.m3u8') {
return null;
}
return path.join(session.directory, segmentName);
}
return {
ensureSession: ensureSession,
getPlaylistUrl: getPlaylistUrl,
getSessionByKey: getSessionByKey,
isSessionLive: isSessionLive,
getSessionStatus: getSessionStatus,
refreshSourceStatus: refreshSourceStatus,
scheduleSourceRefresh: scheduleSourceRefresh,
getSourceStatus: getSourceStatus,
getManifestFilePath: getManifestFilePath,
getSegmentFilePath: getSegmentFilePath
};
}
module.exports = {
createRtmpStreamService: createRtmpStreamService
};
-205
View File
@@ -1,205 +0,0 @@
const { isClientNameAvailable } = require('../client-name-check');
const { isTransientDbError } = require('./onboarding-store');
function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
}
function getPublicBaseUrl(req) {
const configured = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
if (configured) {
return configured;
}
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0];
const host = forwardedHost || String(req.headers.host || '').trim();
return `${protocol}://${host}`.replace(/\/$/, '');
}
async function getOnboardingStatus(pool, deviceId) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
if (!normalizedDeviceId) {
return null;
}
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
FROM player_onboarding_devices d
LEFT JOIN screens s ON s.id = d.screen_id
WHERE d.device_id = ?`,
[normalizedDeviceId]
);
return rows[0] || null;
}
async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedClientName = String(clientName || '').trim();
const normalizedScreenSlug = String(screenSlug || '').trim();
if (!normalizedDeviceId) {
throw new Error('Device ID is required.');
}
if (!normalizedClientName) {
throw new Error('Client name is required.');
}
if (!normalizedScreenSlug) {
throw new Error('Screen is required.');
}
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]);
if (!screenRows.length) {
throw new Error('Screen not found.');
}
const screen = screenRows[0];
const available = typeof isClientNameAvailableOnScreen === 'function'
? await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections)
: true;
if (!available) {
const error = new Error('Client name already exists.');
error.statusCode = 400;
throw error;
}
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',
[normalizedDeviceId, normalizedClientName, screen.id]
);
return getOnboardingStatus(pool, normalizedDeviceId);
}
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function'
? playerRuntime.snapshotAllConnections()
: [];
try {
return await commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections);
} catch (error) {
if (!isTransientDbError(error)) {
throw error;
}
if (onboardingStore && typeof onboardingStore.enqueueBinding === 'function') {
await onboardingStore.enqueueBinding({
deviceId: deviceId,
clientName: clientName,
screenSlug: screenSlug,
queuedAt: new Date().toISOString()
});
}
return {
device_id: normalizeDeviceId(deviceId),
client_name: String(clientName || '').trim(),
screen_slug: String(screenSlug || '').trim(),
queued: true
};
}
}
function registerPlayerOnboardingRoutes(app, options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : 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;
if (!app || !pool || !common || !playerRuntime || !QRCode) {
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.');
}
app.get('/', function (_req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerOnboardingLandingPage());
});
app.get('/onboard', function (req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
});
app.get('/api/onboarding/status', async function (req, res, next) {
try {
const status = await getOnboardingStatus(pool, req.query.deviceId);
res.json({
deviceId: normalizeDeviceId(req.query.deviceId),
onboarded: Boolean(status && status.screen_id),
clientName: status ? status.client_name : null,
screenId: status ? status.screen_id : null,
screenSlug: status ? status.screen_slug : null,
screenName: status ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null
});
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/screens', async function (_req, res, next) {
try {
const [rows] = await pool.query('SELECT id, name, slug FROM screens ORDER BY name ASC, id ASC');
res.json({ screens: rows });
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/qr', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId);
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
const onboardingUrl = `${getPublicBaseUrl(req)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const svg = await QRCode.toString(onboardingUrl, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
res.send(svg);
} catch (error) {
next(error);
}
});
app.post('/api/onboarding', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.body && req.body.deviceId);
const clientName = String((req.body && req.body.clientName) || '').trim();
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
if (!clientName) {
return res.status(400).json({ error: 'Client name is required' });
}
if (!screenSlug) {
return res.status(400).json({ error: 'Screen is required' });
}
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
res.json({
deviceId: deviceId,
clientName: status ? status.client_name : clientName,
screenId: status && status.screen_id ? status.screen_id : null,
screenSlug: status ? status.screen_slug : screenSlug,
screenName: status && status.screen_name ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`,
queued: Boolean(status && status.queued)
});
} catch (error) {
next(error);
}
});
}
module.exports = {
normalizeDeviceId: normalizeDeviceId,
getPublicBaseUrl: getPublicBaseUrl,
getOnboardingStatus: getOnboardingStatus,
commitDeviceBinding: commitDeviceBinding,
bindDeviceToScreen: bindDeviceToScreen,
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
};
+633
View File
@@ -0,0 +1,633 @@
// Player onboarding routes and signup flow helpers.
const express = require('express');
const crypto = require('crypto');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
const { getSharedSecret, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry');
const { isTransientDbError } = require('./store');
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
const ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS = 8;
const PAIRING_CODE_LENGTH = 6;
const PAIRING_CODE_TTL_MS = 15 * 60 * 1000;
const PAIRING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const onboardingSignupAttempts = new Map();
function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
}
function createPairingCode() {
const bytes = crypto.randomBytes(PAIRING_CODE_LENGTH);
let code = '';
for (let index = 0; index < PAIRING_CODE_LENGTH; index += 1) {
code += PAIRING_CODE_ALPHABET[bytes[index] % PAIRING_CODE_ALPHABET.length];
}
return code;
}
function createPairingSession(deviceId, clientId) {
return { deviceId: normalizeDeviceId(deviceId), clientId: normalizeDeviceId(clientId), code: createPairingCode(), expiresAt: Date.now() + PAIRING_CODE_TTL_MS };
}
function isValidOnboardingPairingCode(pairingSession, pairingCode, now) {
const suppliedCode = Buffer.from(String(pairingCode || '').trim().toUpperCase());
const expectedCode = Buffer.from(String(pairingSession && pairingSession.code || '').trim());
const currentTime = Number(now || Date.now());
return Boolean(pairingSession && pairingSession.deviceId && pairingSession.expiresAt > currentTime && suppliedCode.length === expectedCode.length && suppliedCode.length > 0 && crypto.timingSafeEqual(suppliedCode, expectedCode));
}
function getPublicBaseUrl(req, configuredUrl) {
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0];
const host = forwardedHost || String(req.headers.host || '').trim();
if (host) {
return `${protocol}://${host}`.replace(/\/$/, '');
}
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
return configured || null;
}
function getRequestIp(req) {
const forwardedFor = String(req && req.headers && req.headers['x-forwarded-for'] || '').trim().split(',')[0];
if (forwardedFor) {
return forwardedFor;
}
const remoteAddress = req && req.socket && req.socket.remoteAddress ? String(req.socket.remoteAddress).trim() : '';
if (!remoteAddress) {
return 'unknown';
}
return remoteAddress.toLowerCase().startsWith('::ffff:') ? remoteAddress.slice(7) : remoteAddress;
}
function getOnboardingLimitKey(req, deviceId) {
return [getRequestIp(req), normalizeDeviceId(deviceId) || 'anonymous'].join('|');
}
function getPlayerPublicBaseUrl(req, configuredUrl) {
return getPublicBaseUrl(req, configuredUrl);
}
function getPlayerInternalBaseUrl(configuredUrl) {
const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
return configured || null;
}
function clearOnboardingSignupAttempts() {
if (onboardingSignupAttempts.size > 1000) {
onboardingSignupAttempts.clear();
}
}
function isOnboardingSignupRateLimited(req, deviceId) {
const now = Date.now();
const key = getOnboardingLimitKey(req, deviceId);
const attempts = onboardingSignupAttempts.get(key) || [];
const windowStart = now - ONBOARDING_SIGNUP_LIMIT_WINDOW_MS;
const recentAttempts = attempts.filter(function (timestamp) {
return timestamp >= windowStart;
});
if (recentAttempts.length >= ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS) {
onboardingSignupAttempts.set(key, recentAttempts);
return Math.max(1, Math.ceil((recentAttempts[0] + ONBOARDING_SIGNUP_LIMIT_WINDOW_MS - now) / 1000));
}
recentAttempts.push(now);
onboardingSignupAttempts.set(key, recentAttempts);
clearOnboardingSignupAttempts();
return 0;
}
async function getOnboardingStatus(pool, deviceId) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
if (!normalizedDeviceId) {
return null;
}
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
FROM d_onboarding_devices d
LEFT JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?`,
[normalizedDeviceId]
);
return rows[0] || null;
}
async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedClientName = String(clientName || '').trim();
const normalizedScreenSlug = String(screenSlug || '').trim();
if (!normalizedDeviceId) {
throw new Error('Device ID is required.');
}
if (!normalizedClientName) {
throw new Error('Client name is required.');
}
if (!normalizedScreenSlug) {
throw new Error('Screen is required.');
}
return withClientNameReservation(pool, normalizedClientName, async function () {
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [normalizedScreenSlug]);
if (!screenRows.length) {
throw new Error('Screen not found.');
}
const screen = screenRows[0];
const available = await isClientNameAvailable(pool, normalizedClientName, null, liveConnections);
if (!available) {
const error = new Error('Client name already exists.');
error.statusCode = 400;
throw error;
}
await pool.query(
`UPDATE d_onboarding_devices
SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
[normalizedClientName, screen.id, normalizedDeviceId]
);
await pool.query(
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
SELECT ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
)`,
[normalizedDeviceId, normalizedClientName, screen.id, normalizedDeviceId]
);
return getOnboardingStatus(pool, normalizedDeviceId);
});
}
async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, '');
const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, '');
if (!normalizedDeviceId) {
return null;
}
return upsertPlayerRegistrationRecord(pool, {
identifier: normalizedDeviceId,
publicBaseUrl: normalizedPublicBaseUrl || null,
internalBaseUrl: 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;
}
return {
screen_id: Number(screenRows[0].id),
screen_slug: normalizedScreenSlug
};
}
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function'
? playerRuntime.snapshotAllConnections()
: [];
try {
return await commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections);
} catch (error) {
if (!isTransientDbError(error)) {
throw error;
}
if (onboardingStore && typeof onboardingStore.enqueueBinding === 'function') {
await onboardingStore.enqueueBinding({
deviceId: deviceId,
clientName: clientName,
screenSlug: screenSlug,
queuedAt: new Date().toISOString()
});
}
return {
device_id: normalizeDeviceId(deviceId),
client_name: String(clientName || '').trim(),
screen_slug: String(screenSlug || '').trim(),
queued: true
};
}
}
function registerPlayerOnboardingRoutes(app, options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
const onPairingCode = options && typeof options.onPairingCode === 'function' ? options.onPairingCode : null;
const pairingSessions = new Map();
function getPairingSession(deviceId, clientId) {
const normalizedDeviceId = normalizeDeviceId(deviceId) || playerDeviceId;
if (!normalizedDeviceId) {
return null;
}
const sessionKey = `${normalizedDeviceId}:${normalizeDeviceId(clientId) || 'default'}`;
let pairingSession = pairingSessions.get(sessionKey);
if (!isValidOnboardingPairingCode(pairingSession, pairingSession && pairingSession.code)) {
pairingSession = createPairingSession(normalizedDeviceId, clientId);
pairingSessions.set(sessionKey, pairingSession);
}
if (onPairingCode) {
onPairingCode(pairingSession.code, Array.from(pairingSessions.entries()).filter(function (entry) {
return isValidOnboardingPairingCode(entry[1], entry[1] && entry[1].code);
}).map(function (entry) {
return { deviceId: entry[1].deviceId, clientId: entry[1].clientId || null, code: entry[1].code };
}));
}
return pairingSession;
}
function findPairingSession(pairingCode) {
for (const [deviceId, pairingSession] of pairingSessions.entries()) {
if (isValidOnboardingPairingCode(pairingSession, pairingCode)) {
return { deviceId: pairingSession.deviceId, session: pairingSession };
}
}
return null;
}
if (!app || !common) {
throw new Error('registerPlayerOnboardingRoutes requires app and common.');
}
if (!bridgeBaseUrl && (!pool || !playerRuntime)) {
throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless bridgeBaseUrl is configured.');
}
const sharedSecret = getSharedSecret();
async function fetchThinClient(req, pathname, options) {
if (!bridgeBaseUrl) {
return null;
}
const requestOptions = options && typeof options === 'object' ? options : {};
const method = String(requestOptions.method || req.method || 'GET').trim().toUpperCase();
const body = Object.prototype.hasOwnProperty.call(requestOptions, 'body') ? requestOptions.body : undefined;
const requestPathname = String(pathname || '').split('?')[0];
const headers = Object.assign({}, requestOptions.headers || {}, createRequestAuthHeaders({
method: method,
pathname: requestPathname,
body: body
}));
if (req.headers['x-pulse-page-auth']) {
headers['x-pulse-page-auth'] = String(req.headers['x-pulse-page-auth']).trim();
}
if (requestOptions.contentType) {
headers['content-type'] = requestOptions.contentType;
}
return fetch(new URL(pathname, bridgeBaseUrl).toString(), {
method: method,
headers: headers,
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
});
}
async function readJsonResponse(response) {
if (!response) {
return null;
}
const contentType = String(response.headers && typeof response.headers.get === 'function' ? response.headers.get('content-type') : '').toLowerCase();
if (contentType.indexOf('application/json') === -1 && contentType.indexOf('+json') === -1) {
return null;
}
try {
return await response.json();
} catch (_error) {
return null;
}
}
function requireOnboardingPageAuth(req, res, next) {
if (!sharedSecret) {
return next();
}
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload || String(payload.scope || '').trim() !== 'onboarding') {
return res.status(401).json({ error: 'Onboarding page authentication required.' });
}
req.playerPageAuth = payload;
next();
}
function requireOnboardingAuth(req, res, next) {
if (!sharedSecret) {
return next();
}
const pageToken = String(req.headers['x-pulse-page-auth'] || '').trim();
const pagePayload = verifyPageAuthToken(pageToken);
if (pagePayload && String(pagePayload.scope || '').trim() === 'onboarding') {
req.playerPageAuth = pagePayload;
return next();
}
if (verifyRequestAuth(req)) {
return next();
}
return res.status(401).json({ error: 'Onboarding authentication required.' });
}
app.get('/', async function (req, res, next) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
try {
const deviceId = normalizeDeviceId(req.query && req.query.clientId);
let status = null;
if (deviceId) {
if (bridgeBaseUrl) {
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId), {
method: 'GET'
});
status = await readJsonResponse(response);
} else {
status = await getOnboardingStatus(pool, deviceId);
}
}
const screenId = status && (status.screen_id || status.screenId);
const screenSlug = status && (status.screen_slug || status.screenSlug);
if (screenId && screenSlug) {
return res.redirect('/screen/' + encodeURIComponent(screenSlug));
}
res.send(common.renderPlayerOnboardingLandingPage({ pairingCode: '' }));
} catch (error) {
next(error);
}
});
app.get('/onboard', async function (req, res, next) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
try {
const deviceId = playerDeviceId;
const onboardingBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '') || getPublicBaseUrl(req, playerPublicUrl);
const clientId = normalizeDeviceId(req.query.clientId);
const pairingSession = getPairingSession(deviceId, clientId);
const pairingParams = [];
if (pairingSession && pairingSession.code) {
pairingParams.push(`code=${encodeURIComponent(pairingSession.code)}`);
}
const pairingQuery = pairingParams.length ? `?${pairingParams.join('&')}` : '';
if (bridgeBaseUrl && pairingSession && pairingSession.code) {
const response = await fetchThinClient(req, `/api/onboarding/url?pairingCode=${encodeURIComponent(pairingSession.code)}`);
const payload = await readJsonResponse(response);
if (payload && payload.url) {
return res.redirect(String(payload.url));
}
}
return res.redirect(`${onboardingBaseUrl}/pairing${pairingQuery}`);
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/status', function (req, res, next) {
if (sharedSecret) {
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload || ['onboarding', 'player'].indexOf(String(payload.scope || '').trim()) === -1) {
return res.status(401).json({ error: 'Onboarding page authentication required.' });
}
req.playerPageAuth = payload;
}
next();
}, async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId || req.query.clientId);
if (bridgeBaseUrl) {
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), {
method: 'GET'
});
if (!response) {
return res.status(502).json({ error: 'Player bridge unavailable.' });
}
res.status(response.status);
const payload = await readJsonResponse(response);
if (!payload) {
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
}
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null;
return res.json(payload);
}
const status = await getOnboardingStatus(pool, deviceId);
res.json({
deviceId: normalizeDeviceId(deviceId),
onboarded: Boolean(status && status.screen_id),
clientName: status ? status.client_name : null,
screenId: status ? status.screen_id : null,
screenSlug: status ? status.screen_slug : null,
screenName: status ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null
});
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/resolve', requireOnboardingAuth, function (req, res) {
const pairingCode = String(req.query.pairingCode || '').trim();
const pairing = findPairingSession(pairingCode);
if (!pairing) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
res.json({ deviceId: pairing.deviceId, clientId: pairing.session.clientId || null });
});
app.get('/api/onboarding/session', requireOnboardingPageAuth, function (req, res) {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
const clientId = normalizeDeviceId(req.query.clientId);
const pairingSession = getPairingSession(deviceId, clientId);
if (!pairingSession) {
return res.status(503).json({ error: 'Player identity is unavailable.' });
}
res.set('Cache-Control', 'no-store');
res.json({ deviceId: deviceId, pairingCode: pairingSession.code });
});
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try {
if (bridgeBaseUrl) {
const response = await fetchThinClient(_req, '/api/onboarding/screens', {
method: 'GET'
});
res.status(response.status);
const text = await response.text();
res.type(response.headers.get('content-type') || 'application/json');
return res.send(text);
}
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
res.json({ screens: rows });
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/qr', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
const clientId = normalizeDeviceId(req.query.clientId);
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
const pairingSession = getPairingSession(deviceId, clientId);
if (!pairingSession) {
return res.status(503).json({ error: 'Pairing session is unavailable.' });
}
if (bridgeBaseUrl) {
const response = await fetchThinClient(req, `/api/onboarding/qr?pairingCode=${encodeURIComponent(pairingSession.code)}`);
if (response && response.ok) {
const svg = await response.text();
res.set('Content-Type', response.headers.get('content-type') || 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
return res.send(svg);
}
}
const onboardingBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '') || getPublicBaseUrl(req, playerPublicUrl);
const onboardingUrl = `${onboardingBaseUrl}/pairing?code=${encodeURIComponent(pairingSession.code)}`;
const svg = await createStyledQrCodeSvg({
value: onboardingUrl,
qr_margin: 20,
qr_dots_type: 'dots',
qr_dots_color: '#f4f8f5',
qr_corners_square_type: 'dot',
qr_corners_square_color: '#f4f8f5',
qr_corners_dot_type: 'dot',
qr_corners_dot_color: '#f0bd70',
qr_background_transparent: true
});
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
res.send(svg);
} catch (error) {
next(error);
}
});
app.post('/api/onboarding', express.json(), requireOnboardingAuth, async function (req, res, next) {
try {
const deviceId = playerDeviceId;
const clientName = String((req.body && req.body.clientName) || '').trim();
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
const pairingCode = String((req.body && req.body.pairingCode) || '').trim();
const clientId = normalizeDeviceId(req.body && req.body.clientId);
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
if (retryAfterSeconds) {
res.set('Retry-After', String(retryAfterSeconds));
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
}
const pairing = findPairingSession(pairingCode);
const pairingSession = pairing && pairing.session;
if (!isValidOnboardingPairingCode(pairingSession, pairingCode)) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
if (bridgeBaseUrl) {
const forwardedBody = Object.assign({}, req.body || {}, {
clientId: clientId || null
});
const response = await fetch(new URL('/api/onboarding', bridgeBaseUrl).toString(), {
method: 'POST',
headers: Object.assign({
'content-type': 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/onboarding',
body: forwardedBody
}), req.headers['x-pulse-page-auth'] ? { 'x-pulse-page-auth': String(req.headers['x-pulse-page-auth']).trim() } : {}),
body: JSON.stringify(forwardedBody)
});
res.status(response.status);
const payload = await readJsonResponse(response);
if (!payload) {
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
}
if (response.ok) {
pairingSessions.delete(deviceId);
}
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`;
return res.json(payload);
}
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
if (!clientName) {
return res.status(400).json({ error: 'Client name is required' });
}
if (!screenSlug) {
return res.status(400).json({ error: 'Screen is required' });
}
if (!clientId) {
return res.status(400).json({ error: 'Client ID is required' });
}
const status = await bindDeviceToScreen(pool, clientId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
pairingSessions.delete(deviceId);
res.json({
deviceId: deviceId,
clientName: status ? status.client_name : clientName,
screenId: status && status.screen_id ? status.screen_id : null,
screenSlug: status ? status.screen_slug : screenSlug,
screenName: status && status.screen_name ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`,
queued: Boolean(status && status.queued)
});
} catch (error) {
const statusCode = Number(error && error.statusCode || error && error.status || 500);
res.status(Number.isFinite(statusCode) && statusCode >= 400 ? statusCode : 500).json({
error: error && error.message ? error.message : 'Onboarding failed.'
});
}
});
}
module.exports = {
normalizeDeviceId: normalizeDeviceId,
getPublicBaseUrl: getPublicBaseUrl,
getPlayerPublicBaseUrl: getPlayerPublicBaseUrl,
getPlayerInternalBaseUrl: getPlayerInternalBaseUrl,
getOnboardingStatus: getOnboardingStatus,
commitDeviceBinding: commitDeviceBinding,
upsertPlayerRegistration: upsertPlayerRegistration,
bindPlayerToScreen: bindPlayerToScreen,
bindDeviceToScreen: bindDeviceToScreen,
isValidOnboardingPairingCode: isValidOnboardingPairingCode,
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
};
@@ -7,6 +7,12 @@
var form = document.getElementById("onboarding-form");
var message = document.getElementById("onboarding-message");
var screenSelect = document.getElementById("onboarding-screen-select");
function getSessionStorageItem(key) {
try { return window.sessionStorage.getItem(key) || ""; } catch (_error) { return ""; }
}
function setSessionStorageItem(key, value) {
try { window.sessionStorage.setItem(key, value); } catch (_error) {}
}
function setMessage(value) { if (message) { message.textContent = value || ""; } }
function parseResponseError(response) {
return response.text().then(function (text) {
@@ -39,15 +45,19 @@
return screens;
});
}
if (!deviceId) { setMessage("Missing device id. Scan the QR code from the player screen again."); return; }
try { window.localStorage.setItem(deviceKey, deviceId); } catch (_error) {}
try {
if (!deviceId) {
deviceId = window.sessionStorage.getItem(deviceKey) || "";
}
if (deviceId) {
window.sessionStorage.setItem(deviceKey, deviceId);
}
} catch (_error) {}
loadScreens().then(function () {
try {
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
var storedClientName = getSessionStorageItem(clientNameKey) || "";
var clientNameInput = form.querySelector("input[name=\"clientName\"]");
if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; }
if (screenSelect && storedScreenSlug) { screenSelect.value = storedScreenSlug; }
} catch (_error) {}
});
form.addEventListener("submit", function (event) {
@@ -55,13 +65,18 @@
var formData = new FormData(form);
var clientName = String(formData.get("clientName") || "").trim();
var screenSlug = String(formData.get("screenSlug") || "").trim();
var pairingCode = String(formData.get("pairingCode") || "").trim();
if (!clientName) { setMessage("Client name is required."); return; }
if (!screenSlug) { setMessage("Screen is required."); return; }
setMessage("Saving client...");
var payload = { clientName: clientName, screenSlug: screenSlug, pairingCode: pairingCode };
if (deviceId) {
payload.deviceId = deviceId;
}
fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
body: JSON.stringify(payload)
})
.then(function (response) {
if (response.ok) {
@@ -73,7 +88,7 @@
})
.then(function (payload) {
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
setMessage("Onboarding complete.");
if (form) {
@@ -85,4 +100,4 @@
.catch(function (error) { setMessage(error && error.message ? error.message : "Unable to save onboarding."); });
});
}());
</script>
</script>
@@ -0,0 +1,83 @@
<script>
(function () {
var deviceKey = "pulse-signage-player-device-id";
var clientNameKey = "pulse-signage-player-client-name";
function getSessionStorageItem(key) {
try { return window.sessionStorage.getItem(key) || ""; } catch (_error) { return ""; }
}
function setSessionStorageItem(key, value) {
try { window.sessionStorage.setItem(key, value); } catch (_error) {}
}
function getClientNameStorageKey(_screenSlug) {
return clientNameKey;
}
var screenKey = "pulse-signage-player-screen-slug";
var qr = document.getElementById("onboarding-qr");
var pairingCodeElement = document.getElementById("onboarding-pairing-code");
var qrPlaceholderSrc = "data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E";
function getDeviceId() {
var configuredDeviceId = document.getElementById("onboarding-shell");
configuredDeviceId = configuredDeviceId ? String(configuredDeviceId.getAttribute("data-player-device-id") || "").trim() : "";
if (configuredDeviceId) { return configuredDeviceId; }
var stored = "";
try { stored = window.sessionStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
if (stored) { return stored; }
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "device-" + Date.now() + "-" + Math.random().toString(16).slice(2));
try { window.sessionStorage.setItem(deviceKey, next); } catch (_error2) {}
return next;
}
function loadQr(deviceId, clientId) {
if (!qr) { return; }
qr.onerror = function () {
qr.src = qrPlaceholderSrc;
};
qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId) + "&clientId=" + encodeURIComponent(clientId);
}
function loadPairingSession(deviceId, clientId) {
return fetch("/api/onboarding/session?deviceId=" + encodeURIComponent(deviceId) + "&clientId=" + encodeURIComponent(clientId), { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
if (payload && payload.pairingCode && pairingCodeElement) {
pairingCodeElement.textContent = payload.pairingCode;
}
return payload;
})
.catch(function () { return null; });
}
function getClientId() {
var stored = getSessionStorageItem("pulse-signage-player-client-id");
if (stored) { return stored; }
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "client-" + Date.now() + "-" + Math.random().toString(16).slice(2));
setSessionStorageItem("pulse-signage-player-client-id", next);
return next;
}
function redirectIfOnboarded(deviceId) {
var bindingId = getClientId() || deviceId;
return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(bindingId), { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
if (payload && payload.onboarded && payload.screenSlug) {
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug) + "?clientId=" + encodeURIComponent(clientId));
return true;
}
return false;
})
.catch(function () { return false; });
}
var clientId = getClientId();
var deviceId = getDeviceId();
redirectIfOnboarded(deviceId).then(function (redirected) {
if (redirected) { return; }
if (qr && !qr.getAttribute("src")) {
qr.src = qrPlaceholderSrc;
}
loadPairingSession(deviceId, clientId).then(function () {
loadQr(deviceId, clientId);
});
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
});
}());
</script>
@@ -1,3 +1,5 @@
// File-backed storage for onboarding device mappings.
const fs = require('fs');
const path = require('path');
+29 -22
View File
@@ -4,10 +4,31 @@
const onboardingClientNameStorageKey = 'pulse-signage-player-client-name';
const onboardingDeviceIdStorageKey = 'pulse-signage-player-device-id';
function getSessionStorageItem(key) {
try {
return window.sessionStorage.getItem(key) || '';
} catch (_error) {
return '';
}
}
function setSessionStorageItem(key, value) {
try {
window.sessionStorage.setItem(key, value);
} catch (_error) {
// ignore storage errors
}
}
function getOnboardingDeviceId() {
try {
var storedDeviceId = window.localStorage.getItem(onboardingDeviceIdStorageKey) || '';
return String(storedDeviceId || '').trim();
var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey);
if (storedDeviceId) {
return String(storedDeviceId || '').trim();
}
var nextDeviceId = window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'device-' + Date.now() + '-' + Math.random().toString(16).slice(2);
setSessionStorageItem(onboardingDeviceIdStorageKey, nextDeviceId);
return String(nextDeviceId || '').trim();
} catch (_error) {
return '';
}
@@ -19,24 +40,14 @@
return onboardingClientName;
}
try {
var storedClientName = window.localStorage.getItem(onboardingClientNameStorageKey);
var storedClientName = getSessionStorageItem(onboardingClientNameStorageKey);
if (storedClientName) {
onboardingClientName = storedClientName;
try {
window.localStorage.setItem('pulse-signage-player-client-name', storedClientName);
} catch (_mirrorError) {
// ignore storage errors
}
return onboardingClientName;
}
var genericClientName = window.localStorage.getItem('pulse-signage-player-client-name');
var genericClientName = getSessionStorageItem('pulse-signage-player-client-name');
if (genericClientName) {
onboardingClientName = genericClientName;
try {
window.localStorage.setItem(onboardingClientNameStorageKey, genericClientName);
} catch (_error) {
// ignore storage errors
}
return onboardingClientName;
}
} catch (_error) {
@@ -51,14 +62,10 @@
return;
}
onboardingClientName = normalizedName;
try {
window.localStorage.setItem('pulse-signage-player-client-name', normalizedName);
window.localStorage.setItem(onboardingClientNameStorageKey, normalizedName);
} catch (_error) {
// ignore storage errors
}
setSessionStorageItem('pulse-signage-player-client-name', normalizedName);
setSessionStorageItem(onboardingClientNameStorageKey, normalizedName);
if (socket && socket.readyState === WebSocket.OPEN) {
sendCommandHello(socket);
sendCommandState(socket);
}
}
@@ -83,7 +90,7 @@
});
}).then(function (payload) {
var serverName = payload && payload.clientName ? String(payload.clientName).trim() : '';
if (serverName) {
if (serverName && !getOnboardingClientName()) {
applyOnboardingClientName(serverName, null);
}
return onboardingClientName || getOnboardingClientName();
@@ -1,141 +0,0 @@
<script>
(function () {
var deviceKey = "pulse-signage-player-device-id";
var clientNameKey = "pulse-signage-player-client-name";
function getClientNameStorageKey(_screenSlug) {
return clientNameKey;
}
var screenKey = "pulse-signage-player-screen-slug";
var qr = document.getElementById("onboarding-qr");
var status = document.getElementById("onboarding-status");
var localForm = document.getElementById("onboarding-local-form");
var localMessage = document.getElementById("onboarding-message");
var localScreenSelect = document.getElementById("onboarding-screen-select");
function parseResponseError(response) {
return response.text().then(function (text) {
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
try {
var payload = JSON.parse(text);
return payload && payload.error ? payload.error : fallbackMessage;
} catch (_error) {
return fallbackMessage;
}
});
}
function getDeviceId() {
var stored = "";
try { stored = window.localStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
if (stored) { return stored; }
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "device-" + Date.now() + "-" + Math.random().toString(16).slice(2));
try { window.localStorage.setItem(deviceKey, next); } catch (_error2) {}
return next;
}
function setStatus(message) { if (status) { status.textContent = message; } }
function setLocalMessage(message) { if (localMessage) { localMessage.textContent = message || ""; } }
function setSelectOptions(select, screens, selectedSlug) {
if (!select) { return; }
while (select.firstChild) { select.removeChild(select.firstChild); }
var placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Select a screen";
select.appendChild(placeholder);
(Array.isArray(screens) ? screens : []).forEach(function (screen) {
var option = document.createElement("option");
option.value = String(screen && screen.slug ? screen.slug : "");
option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen");
if (selectedSlug && String(option.value) === String(selectedSlug)) {
option.selected = true;
}
select.appendChild(option);
});
}
function loadScreens(selectedSlug) {
return fetch("/api/onboarding/screens", { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
var screens = payload && Array.isArray(payload.screens) ? payload.screens : [];
setSelectOptions(localScreenSelect, screens, selectedSlug);
return screens;
})
.catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; });
}
function loadQr(deviceId) {
if (qr) { qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); }
}
function submitOnboarding(deviceId, clientName, screenSlug) {
return fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
})
.then(function (response) {
if (response.ok) {
return response.json();
}
return parseResponseError(response).then(function (messageText) {
throw new Error(messageText);
});
})
.then(function (payload) {
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
setLocalMessage("Onboarding complete.");
if (localForm) {
Array.prototype.slice.call(localForm.querySelectorAll("input, select, button")).forEach(function (control) {
control.disabled = true;
});
}
});
}
function redirectIfOnboarded(deviceId) {
return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(deviceId), { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
if (payload && payload.onboarded && payload.screenSlug) {
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
return true;
}
return false;
})
.catch(function () { return false; });
}
var deviceId = getDeviceId();
if (localForm) {
localForm.addEventListener("submit", function (event) {
event.preventDefault();
var formData = new FormData(localForm);
var clientName = String(formData.get("clientName") || "").trim();
var screenSlug = String(formData.get("screenSlug") || "").trim();
if (!clientName) { setLocalMessage("Client name is required."); return; }
if (!screenSlug) { setLocalMessage("Screen is required."); return; }
setLocalMessage("Saving client...");
submitOnboarding(deviceId, clientName, screenSlug).catch(function (error) {
setLocalMessage(error && error.message ? error.message : "Unable to save onboarding.");
});
});
}
loadScreens().then(function () {
try {
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
if (storedClientName && localForm) {
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
if (clientNameInput && !clientNameInput.value) { clientNameInput.value = storedClientName; }
}
if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; }
} catch (_error) {}
});
redirectIfOnboarded(deviceId).then(function (redirected) {
if (redirected) { return; }
loadQr(deviceId);
setStatus("Waiting for onboarding to finish.");
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
});
}());
</script>
+263
View File
@@ -0,0 +1,263 @@
// 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 (typeof getCommandClientId === 'function') {
request.setRequestHeader('x-pulse-client-id', getCommandClientId());
}
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();
});
})();
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -5,7 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{TITLE}}</title>
<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=37" />
{{{STYLESHEETS}}}
</head>
<body class="{{BODY_CLASS}}">
{{{BODY}}}
+374 -76
View File
@@ -1,8 +1,17 @@
// Player playlist assembly, snapshot persistence, and playlist revision helpers.
const crypto = require('crypto');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
const fs = require('fs');
const path = require('path');
const { createStyledQrCodeDataUrl } = require('../data/qr-code');
function createPlayerPlaylistService(options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null;
const snapshotDir = options && options.snapshotDir ? options.snapshotDir : null;
const mediaDir = options && options.mediaDir ? path.resolve(String(options.mediaDir)) : null;
const remoteImageCacheDir = mediaDir ? path.join(mediaDir, 'player-cache', 'remote-images') : null;
if (!pool) {
throw new Error('pool is required');
@@ -11,81 +20,358 @@ function createPlayerPlaylistService(options) {
throw new Error('common is required');
}
async function buildScreenPlaylist(slug) {
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
if (!screenRows.length) {
return { screen: null, playlist: null, slides: [] };
function getSnapshotFilePath(slug) {
if (!snapshotDir) {
return null;
}
const screen = screenRows[0];
if (!screen.playlist_id) {
return {
screen: screen,
playlist: null,
slides: [],
revision: getPlaylistRevision(screen, null, [], [], [])
};
const normalizedSlug = String(slug || '').trim();
if (!normalizedSlug) {
return null;
}
return path.join(snapshotDir, `${normalizedSlug}.json`);
}
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null;
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,
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,
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
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ?
ORDER BY ps.position ASC, ps.id ASC
`, [screen.playlist_id]);
async function readSnapshot(slug) {
const filePath = getSnapshotFilePath(slug);
if (!filePath) {
return null;
}
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed;
} catch (error) {
if (error && error.code === 'ENOENT') {
return null;
}
return null;
}
}
const templateIds = slideRows
.filter(function (slide) { return slide.template_id; })
.map(function (slide) { return slide.template_id; });
const templatesById = {};
let templateRows = [];
let regionRows = [];
if (templateIds.length) {
[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,
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
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id IN (?)
`, [templateIds]);
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, 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]);
templateRows.forEach(function (template) {
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
templatesById[template.id] = template;
async function writeSnapshot(slug, payload) {
const filePath = getSnapshotFilePath(slug);
if (!filePath) {
return;
}
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
}
async function createQrPreviewDataUrl(value) {
return createStyledQrCodeDataUrl(value);
}
function getPlaceholderImageExpressions(value, output) {
const expressions = output || [];
const source = String(value || '');
const pattern = /\{\{\s*([^{}]*?\.image\s*\([^{}]*\)[^{}]*?)\s*\}\}/gi;
let match = null;
while ((match = pattern.exec(source))) {
expressions.push(String(match[1] || '').trim());
}
return expressions;
}
function resolvePathValue(value, expression) {
const parsed = String(expression || '').replace(/\.image\s*\([^)]*\)\s*$/i, '').trim();
return parsed.split('.').reduce(function (current, segment) {
return current === undefined || current === null ? '' : current[segment];
}, value);
}
function getApiItems(responseJson, itemsPath) {
let current = responseJson;
const pathValue = String(itemsPath || '').trim();
if (pathValue) {
pathValue.split('.').forEach(function (segment) {
current = current === undefined || current === null ? '' : current[segment];
});
return Array.isArray(current) ? current : [];
}
if (Array.isArray(responseJson)) return responseJson;
if (responseJson && Array.isArray(responseJson.items)) return responseJson.items;
if (responseJson && Array.isArray(responseJson.results)) return responseJson.results;
if (responseJson && Array.isArray(responseJson.data)) return responseJson.data;
return responseJson ? [responseJson] : [];
}
const slides = slideRows.map(function (slide) {
return {
id: slide.id,
title: slide.title,
body: slide.body,
duration_seconds: slide.duration_seconds,
schedule_mode: slide.schedule_mode,
schedule_start_datetime: slide.schedule_start_datetime,
schedule_end_datetime: slide.schedule_end_datetime,
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: slide.template_id ? templatesById[slide.template_id] || null : null,
content: common.parseJsonSafe(slide.content_json) || {}
};
});
async function cacheRemoteImage(url) {
const remoteUrl = String(url || '').trim();
if (!remoteImageCacheDir || !/^https?:\/\//i.test(remoteUrl)) return '';
const hash = crypto.createHash('sha256').update(remoteUrl).digest('hex');
await fs.promises.mkdir(remoteImageCacheDir, { recursive: true });
const existing = (await fs.promises.readdir(remoteImageCacheDir)).find(function (name) { return name.startsWith(hash + '.'); });
if (existing) return '/media/player-cache/remote-images/' + existing;
try {
const response = await fetch(remoteUrl, { signal: AbortSignal.timeout(15000) });
if (!response.ok || !String(response.headers.get('content-type') || '').toLowerCase().startsWith('image/')) return '';
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length > 10 * 1024 * 1024) return '';
const contentType = String(response.headers.get('content-type') || '').toLowerCase();
const extension = contentType.includes('svg') ? '.svg' : contentType.includes('png') ? '.png' : contentType.includes('webp') ? '.webp' : contentType.includes('gif') ? '.gif' : '.jpg';
const fileName = hash + extension;
const filePath = path.join(remoteImageCacheDir, fileName);
await fs.promises.writeFile(filePath, bytes);
return '/media/player-cache/remote-images/' + fileName;
} catch (_error) {
return '';
}
}
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows);
async function cachePlaceholderImages(slides, rssFeeds, apiSources) {
if (!remoteImageCacheDir) return;
const usedPaths = new Set();
const allSlideRows = await pool.query('SELECT content_json FROM c_slides').then(function (result) { return result[0] || []; });
const allContents = allSlideRows.map(function (row) { return common.parseJsonSafe(row.content_json) || {}; });
const sourceContent = allContents.concat((slides || []).map(function (slide) { return slide.content || {}; }));
const cacheSource = async function (source, item, expressions) {
if (!source || !item) return;
for (const expression of expressions) {
const remoteUrl = String(resolvePathValue(item, expression) || '').trim();
const localPath = await cacheRemoteImage(remoteUrl);
if (localPath) {
source.imageCache = source.imageCache || {};
source.imageCache[remoteUrl] = localPath;
usedPaths.add(localPath);
}
}
};
for (const content of sourceContent) {
for (const key of Object.keys(content || {})) {
const region = content[key];
if (!region || typeof region !== 'object') continue;
const expressions = getPlaceholderImageExpressions(region.value, []);
if (!expressions.length) continue;
const itemNumber = Math.max(1, Number(region.item_number || 1)) - 1;
if (String(region.type || '').toLowerCase() === 'api') {
const source = (apiSources || []).find(function (entry) { return Number(entry.id) === Number(region.source_id); });
const items = source ? getApiItems(source.responseJson, region.items_path) : [];
await cacheSource(source, items[itemNumber], expressions);
}
if (String(region.type || '').toLowerCase() === 'rss') {
const feed = (rssFeeds || []).find(function (entry) { return Number(entry.id) === Number(region.feed_id); });
await cacheSource(feed, feed && feed.items && feed.items[itemNumber], expressions);
}
}
}
const files = await fs.promises.readdir(remoteImageCacheDir).catch(function () { return []; });
await Promise.all(files.filter(function (file) { return !usedPaths.has('/media/player-cache/remote-images/' + file); }).map(function (file) {
return fs.promises.unlink(path.join(remoteImageCacheDir, file)).catch(function () {});
}));
}
return { screen: screen, playlist: playlist, slides: slides, revision: revision };
async function buildScreenPlaylist(slug) {
try {
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) {
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [], weatherLocations: [] };
}
const screen = screenRows[0];
if (!screen.playlist_id) {
const payloadWithoutPlaylist = {
screen: screen,
playlist: null,
slides: [],
rssFeeds: [],
apiSources: [],
timetableGroups: [],
weatherLocations: [],
revision: getPlaylistRevision(screen, null, [], [], [], [], [], [])
};
await writeSnapshot(slug, 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 c_playlists WHERE id = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(`
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.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
FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ?
ORDER BY ps.position ASC, ps.id ASC
`, [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
.filter(function (slide) { return slide.template_id; })
.map(function (slide) { return slide.template_id; });
const templatesById = {};
let templateRows = [];
let regionRows = [];
if (templateIds.length) {
[templateRows] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, 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
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id IN (?)
`, [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) {
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
templatesById[template.id] = template;
});
}
function getVideoRegionDurationSeconds(contentJson) {
if (!contentJson) {
return null;
}
try {
const parsed = common.parseJsonSafe(contentJson) || {};
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegions = Object.keys(parsed).map(function (key) { return parsed[key]; }).filter(function (region) {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = videoRegions.reduce(function (longest, region) {
const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000;
return regionDuration > longest ? regionDuration : longest;
}, 0);
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 {
id: slide.id,
title: slide.title,
modified_at: slide.modified_at,
duration_seconds: videoDuration || storedDuration,
use_video_duration: Boolean(slide.use_video_duration),
disable_audio: disableAudio,
scheduleRules: rulesBySlideId[Number(slide.id)] || [],
template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null,
content: content
};
}));
let rssFeeds = [];
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
const rssData = await common.fetchRssFeedsData(pool);
const feedRows = Array.isArray(rssData && rssData.rssFeeds) ? rssData.rssFeeds : [];
rssFeeds = await Promise.all(feedRows.map(async function (feed) {
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
return Object.assign({}, feed, { items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
}
let apiSources = [];
if (typeof common.fetchApiSourcesData === 'function') {
const apiData = await common.fetchApiSourcesData(pool);
const sourceRows = Array.isArray(apiData && apiData.apiSources) ? apiData.apiSources : [];
apiSources = sourceRows.map(function (source) {
return Object.assign({}, source, {
responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null
});
});
}
let timetableGroups = [];
if (typeof common.fetchTimetablesData === 'function') {
const timetableData = await common.fetchTimetablesData(pool);
timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
}
let weatherLocations = [];
if (typeof common.fetchWeatherLocationsData === 'function') {
const weatherData = await common.fetchWeatherLocationsData(pool);
weatherLocations = (weatherData.weatherLocations || []).map(function (location) {
const responseJson = common.parseJsonSafe ? common.parseJsonSafe(location.last_response_json) : null;
return Object.assign({}, location, {
responseJson: convertWeatherSnapshot(responseJson, {
temperature: location.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius',
wind: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'ms' : 'kmh',
precipitation: location.precipitation_unit === 'inch' ? 'inch' : 'mm'
})
});
});
}
await cachePlaceholderImages(slides, rssFeeds, apiSources);
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, weatherLocations: weatherLocations, revision: revision };
await writeSnapshot(slug, payload);
return payload;
} catch (error) {
const snapshot = await readSnapshot(slug);
if (snapshot) {
return snapshot;
}
throw error;
}
}
function updatePlaylistRevisionHash(hash, value) {
@@ -93,7 +379,7 @@ function createPlayerPlaylistService(options) {
hash.update('\0');
}
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows) {
function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups, weatherLocations) {
const hash = crypto.createHash('sha1');
updatePlaylistRevisionHash(hash, screen && screen.id);
@@ -103,24 +389,29 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, playlist && playlist.id);
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
updatePlaylistRevisionHash(hash, playlist && playlist.skip_unavailable_rtmp);
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
updatePlaylistRevisionHash(hash, slide.id);
updatePlaylistRevisionHash(hash, slide.title);
updatePlaylistRevisionHash(hash, slide.body);
updatePlaylistRevisionHash(hash, slide.template_id);
updatePlaylistRevisionHash(hash, slide.content_json);
updatePlaylistRevisionHash(hash, slide.media_path);
updatePlaylistRevisionHash(hash, slide.media_type);
updatePlaylistRevisionHash(hash, slide.modified_at);
updatePlaylistRevisionHash(hash, slide.position);
updatePlaylistRevisionHash(hash, slide.duration_seconds);
updatePlaylistRevisionHash(hash, slide.schedule_mode);
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
updatePlaylistRevisionHash(hash, slide.schedule_end_time);
updatePlaylistRevisionHash(hash, slide.schedule_days_json);
updatePlaylistRevisionHash(hash, slide.use_video_duration);
updatePlaylistRevisionHash(hash, slide.disable_audio);
});
(Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) {
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) {
@@ -131,6 +422,8 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, template.canvas_size_height);
updatePlaylistRevisionHash(hash, template.background_image_path);
updatePlaylistRevisionHash(hash, template.background_color);
updatePlaylistRevisionHash(hash, template.background_gradient);
updatePlaylistRevisionHash(hash, template.background_gradient);
updatePlaylistRevisionHash(hash, template.modified_at);
});
@@ -149,6 +442,11 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, region.modified_at);
});
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || []));
updatePlaylistRevisionHash(hash, JSON.stringify(weatherLocations || []));
return hash.digest('hex');
}
+335 -71
View File
@@ -4,16 +4,16 @@ body {
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
background: #0a0a0a;
color: #fff;
font-family: Arial, sans-serif;
}
body.onboarding-page {
background:
radial-gradient(circle at top, rgba(82, 144, 255, 0.28), transparent 32%),
radial-gradient(circle at bottom right, rgba(34, 197, 94, 0.18), transparent 26%),
linear-gradient(160deg, #09111f 0%, #0b1323 52%, #111827 100%);
radial-gradient(circle at 78% 20%, rgba(55, 195, 178, 0.16), transparent 28%),
radial-gradient(circle at 12% 90%, rgba(237, 177, 89, 0.12), transparent 30%),
linear-gradient(145deg, #07131b 0%, #0b2028 58%, #10252a 100%);
overflow-x: hidden;
overflow-y: auto;
}
@@ -22,25 +22,69 @@ body.onboarding-page #app {
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 {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #111;
background: #0a0a0a;
position: relative;
}
.onboarding-shell {
min-height: 100vh;
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(16px, 3vw, 40px);
padding: clamp(24px, 5vw, 72px);
box-sizing: border-box;
}
.onboarding-stage {
width: min(100%, 1160px);
}
.onboarding-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
}
.onboarding-brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 13px;
background: #f0bd70;
color: #10252a;
font-size: 1.45rem;
font-weight: 800;
}
.onboarding-label {
margin: 3px 0 0;
color: #8faeb0;
font-size: 0.88rem;
}
.onboarding-card {
width: min(100%, 1040px);
padding: clamp(20px, 3vw, 40px);
@@ -58,14 +102,35 @@ body.onboarding-page #app {
line-height: 1.05;
}
.onboarding-stage h1 {
max-width: 560px;
font-size: clamp(2.5rem, 5vw, 5rem);
letter-spacing: 0;
}
.onboarding-stage--landing h1 {
max-width: 500px;
font-size: clamp(2.1rem, 3.6vw, 3.4rem);
line-height: 1.08;
}
.onboarding-kicker {
margin: 0 0 12px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #8ab4ff;
color: #f0bd70;
font-size: 0.82rem;
}
.onboarding-step {
margin: 0 0 18px;
color: #70d0c2;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.onboarding-copy {
margin: 0 0 28px;
color: #cbd5e1;
@@ -75,32 +140,121 @@ body.onboarding-page #app {
.onboarding-layout {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr);
grid-template-columns: minmax(320px, 0.9fr) minmax(320px, 1.1fr);
gap: clamp(20px, 3vw, 32px);
align-items: stretch;
}
.onboarding-copy-panel {
display: flex;
grid-column: 1;
grid-row: 1;
flex-direction: column;
justify-content: center;
}
.onboarding-instructions {
display: grid;
gap: 14px;
max-width: 440px;
margin: 18px 0 0;
padding: 0;
list-style: none;
counter-reset: setup-step;
}
.onboarding-instructions li {
display: grid;
grid-template-columns: 24px 1fr;
gap: 10px;
color: #b7cccd;
font-size: 0.96rem;
line-height: 1.35;
counter-increment: setup-step;
}
.onboarding-instructions li::before {
content: counter(setup-step);
width: 22px;
height: 22px;
display: grid;
place-items: center;
border: 1px solid rgba(112, 208, 194, 0.55);
border-radius: 50%;
color: #70d0c2;
font-size: 0.75rem;
font-weight: 700;
}
.onboarding-pin-block {
margin-top: 28px;
}
.onboarding-pin-label {
display: block;
margin-bottom: 10px;
color: #8faeb0;
font-size: 0.92rem;
}
.onboarding-pin-label strong { color: #f0bd70; }
.onboarding-pin {
display: block;
color: #f4f8f5;
font-size: clamp(1.7rem, 3vw, 2.8rem);
font-weight: 800;
letter-spacing: 0.18em;
line-height: 1;
}
.onboarding-qr-pane {
display: grid;
gap: 16px;
width: min(100%, 420px);
grid-column: 2;
grid-row: 1;
gap: 0;
align-content: start;
justify-self: center;
}
.onboarding-qr-frame {
display: flex;
width: min(100%, 420px);
aspect-ratio: 1;
box-sizing: border-box;
justify-content: center;
padding: 22px;
border-radius: 26px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
align-items: center;
justify-self: center;
padding: 14px;
border-radius: 22px;
background: rgba(7, 19, 27, 0.7);
border: 1px solid rgba(244, 248, 245, 0.42);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.22);
}
.onboarding-qr-frame img {
width: min(100%, 320px);
width: min(100%, 390px);
aspect-ratio: 1;
display: block;
background: #fff;
border-radius: 18px;
background: transparent;
border-radius: 16px;
padding: 12px;
box-sizing: border-box;
}
.onboarding-qr-caption {
margin: 6px 0 0;
color: #8faeb0;
font-size: 0.92rem;
text-align: center;
}
.onboarding-brand-title {
margin-bottom: 10px;
font-size: 1.2rem;
font-weight: 700;
letter-spacing: 0.1em;
}
.onboarding-form {
@@ -164,14 +318,14 @@ body.onboarding-page #app {
}
.onboarding-status {
margin-top: 8px;
margin-top: clamp(32px, 6vh, 56px);
min-height: 1.4em;
color: #cbd5e1;
color: #8faeb0;
font-size: 0.96rem;
}
.onboarding-card--landing .onboarding-status {
text-align: center;
text-align: left;
}
@media (max-width: 860px), (orientation: portrait) {
@@ -183,12 +337,45 @@ body.onboarding-page #app {
grid-template-columns: 1fr;
}
.onboarding-copy-panel,
.onboarding-qr-pane {
grid-column: 1;
}
.onboarding-copy-panel {
grid-row: 1;
}
.onboarding-qr-pane {
grid-row: 2;
}
.onboarding-card {
width: 100%;
}
.onboarding-qr-frame img {
width: min(100%, 280px);
width: min(100%, 390px);
}
.onboarding-qr-frame {
width: min(100%, 420px);
}
.onboarding-qr-pane {
width: min(100%, 420px);
}
.onboarding-stage--landing h1 {
font-size: clamp(2rem, 7vw, 3rem);
}
.onboarding-header {
margin-bottom: 38px;
}
.onboarding-pin {
font-size: clamp(1.7rem, 9vw, 2.8rem);
}
}
@@ -199,6 +386,8 @@ body.onboarding-page #app {
height: 100%;
opacity: 0;
transition: opacity 560ms ease;
will-change: opacity;
backface-visibility: hidden;
}
.slide-shell.is-visible {
@@ -220,6 +409,54 @@ body.screen-blackout #app {
opacity: 0;
}
.player-keyboard-feedback {
position: fixed;
top: 1.5rem;
left: 50%;
z-index: 10000;
padding: 0.65rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.32);
border-radius: 0.4rem;
background: rgba(15, 23, 42, 0.88);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -0.5rem);
transition: opacity 120ms ease, transform 120ms ease;
}
.player-keyboard-feedback.is-visible {
opacity: 1;
transform: translate(-50%, 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 {
width: 100%;
height: 100%;
@@ -250,15 +487,6 @@ body.screen-blackout #app {
display: block;
}
.slide img,
.slide video,
.slide iframe {
width: 100%;
height: 100%;
object-fit: contain;
border: 0;
}
.body {
position: absolute;
left: 5%;
@@ -312,7 +540,9 @@ body.screen-blackout #app {
box-sizing: border-box;
}
.template-region.text {
.template-region.text,
.template-region.api,
.template-region.rss {
color: #fff;
display: block;
padding: 0;
@@ -322,38 +552,39 @@ body.screen-blackout #app {
line-height: 1.35;
}
.template-region.text .template-region-text-scale {
.template-region .template-region-text-scale {
display: block;
transform-origin: top left;
overflow: hidden;
}
.template-region.text .template-region-text-scale > * {
margin: 0;
.template-region .template-region-text-scale > * {
margin: 1em 0;
}
.template-region.text .template-region-text-scale > * + * {
margin-top: 0.5em;
.template-region .template-region-text-scale > *:first-child {
margin-top: 0;
}
.template-region.text .template-region-text-scale ul,
.template-region.text .template-region-text-scale ol {
.template-region .template-region-text-scale > *:last-child {
margin-bottom: 1em;
}
.template-region .template-region-text-scale ul,
.template-region .template-region-text-scale ol {
padding-left: 1.2em;
}
.template-region.text .template-region-text-scale code {
.template-region .template-region-text-scale code {
white-space: pre-wrap;
}
.template-region.text > * {
margin: 0;
}
.template-region.text > * + * {
margin-top: 0.5em;
}
.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;
}
@@ -364,6 +595,19 @@ body.screen-blackout #app {
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 {
width: 100%;
height: 100%;
@@ -373,6 +617,14 @@ body.screen-blackout #app {
overflow: hidden;
}
.template-region.qr-code img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
background: #fff;
}
.template-region.html iframe {
width: 100%;
height: 100%;
@@ -382,6 +634,24 @@ body.screen-blackout #app {
overflow: hidden;
}
.template-region.rtmp {
background: #000;
}
.template-region.rtmp video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
}
.template-region-rtmp-placeholder {
position: absolute;
inset: 0;
display: flex;
}
.template-region-placeholder {
width: 100%;
height: 100%;
@@ -393,29 +663,6 @@ body.screen-blackout #app {
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 {
position: fixed;
width: 1px;
@@ -433,3 +680,20 @@ body.screen-blackout #app {
border: 0;
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,155 @@
(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',
'orange', 'amber', 'olive', 'teal', 'sky', 'indigo', 'violet', 'fuchsia', 'pink',
'navy', 'steel', 'slate', 'graphite', 'midnight'
].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)' },
orange: { accent: '#c84e10', foreground: '#ffffff', glow: 'rgba(200, 78, 16, 0.34)' },
amber: { accent: '#a56710', foreground: '#ffffff', glow: 'rgba(165, 103, 16, 0.34)' },
olive: { accent: '#5f7f0f', foreground: '#ffffff', glow: 'rgba(95, 127, 15, 0.34)' },
teal: { accent: '#12827d', foreground: '#ffffff', glow: 'rgba(18, 130, 125, 0.34)' },
sky: { accent: '#127caf', foreground: '#ffffff', glow: 'rgba(18, 124, 175, 0.34)' },
indigo: { accent: '#6f60ea', foreground: '#ffffff', glow: 'rgba(111, 96, 234, 0.34)' },
violet: { accent: '#9553db', foreground: '#ffffff', glow: 'rgba(149, 83, 219, 0.34)' },
fuchsia: { accent: '#b347be', foreground: '#ffffff', glow: 'rgba(179, 71, 190, 0.34)' },
pink: { accent: '#cd388d', foreground: '#ffffff', glow: 'rgba(205, 56, 141, 0.34)' },
navy: { accent: '#1d2d4c', foreground: '#ffffff', glow: 'rgba(29, 45, 76, 0.34)' },
steel: { accent: '#3a4860', foreground: '#ffffff', glow: 'rgba(58, 72, 96, 0.34)' },
slate: { accent: '#566577', foreground: '#ffffff', glow: 'rgba(86, 101, 119, 0.34)' },
graphite: { accent: '#32363c', foreground: '#ffffff', glow: 'rgba(50, 54, 60, 0.34)' },
midnight: { accent: '#1e1d2d', foreground: '#ffffff', glow: 'rgba(30, 29, 45, 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;
})();
@@ -0,0 +1,661 @@
// Capture the current viewport dimensions.
function getCurrentViewport() {
return {
width: window.innerWidth,
height: window.innerHeight
};
}
var slideOutroTimers = [];
// Command websocket and player-state helpers.
// Send the current playback state to the command websocket.
function sendCommandState(currentSlide) {
if (!commandSocket || commandSocket.readyState !== WebSocket.OPEN) {
return;
}
commandSocket.send(JSON.stringify({
type: 'state',
clientId: getCommandClientId(),
clientName: getOnboardingClientName() || null,
deviceId: getOnboardingDeviceId() || null,
userAgent: window.navigator.userAgent || '',
page: window.location.href,
viewport: getCurrentViewport(),
paused: isPaused,
blackout: isBlackout,
currentSlide: currentSlide ? {
id: currentSlide.id || null,
title: currentSlide.title || '',
kind: currentSlide.kind || '',
playlistSignature: currentPlaylistSignature || ''
} : null
}));
}
// Debounce command-state updates during rapid changes.
function scheduleCommandStateUpdate() {
if (commandStateTimer) {
window.clearTimeout(commandStateTimer);
}
commandStateTimer = window.setTimeout(function () {
commandStateTimer = null;
sendCommandState(lastRenderedSlide);
}, 300);
}
// Debounce rerenders after viewport changes.
function scheduleViewportRenderUpdate() {
if (viewportRenderTimer) {
window.clearTimeout(viewportRenderTimer);
}
viewportRenderTimer = window.setTimeout(function () {
viewportRenderTimer = null;
if (slides.length) {
var activeSlides = getCurrentActiveSlides();
if (getCurrentRenderKey(activeSlides) === lastRenderedViewKey) {
return;
}
showCurrent();
}
}, 150);
}
// Cancel the current slide-advance timer.
function clearSlideTimer() {
if (timer) {
window.clearTimeout(timer);
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.
function scheduleSlideAdvance(delayMs) {
clearSlideTimer();
var holdDelayMs = Math.max(1, Number(delayMs || 0));
slideExpiresAt = Date.now() + holdDelayMs;
scheduleSlideOutro(holdDelayMs);
timer = window.setTimeout(function () {
timer = null;
slideExpiresAt = null;
pausedRemainingMs = null;
clearSlideOutroTimer();
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides();
if (activeSlides.length < 2) {
showCurrent();
return;
}
if (index >= activeSlides.length) {
index = 0;
}
index = (index + 1) % activeSlides.length;
showCurrent();
}, holdDelayMs);
}
// Return the slide duration without shifting it for fade timing.
function getSlideHoldDelay(delayMs) {
var holdDelayMs = Math.max(1, Number(delayMs || 0));
return holdDelayMs;
}
// Cancel any pending fade-transition cleanup.
function clearSlideTransitionTimer() {
if (slideTransitionTimer) {
window.clearTimeout(slideTransitionTimer);
slideTransitionTimer = null;
}
}
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.
function renderSlideMarkup(markup, shouldFade) {
clearSlideTransitionTimer();
destroyRegionInstances(app);
if (typeof destroyRtmpRegions === 'function') {
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 });
});
}
function schedulePostRenderSetup(root, delayMs) {
if (!root) {
return;
}
if (root.isConnected === false) {
return;
}
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(root);
}
if (!isThumbnailPreview()) {
initializeRegionInstances(root);
}
initializeRenderedVideoPlayback(root, delayMs);
if (typeof playRegionAnimations === 'function') {
playRegionAnimations(root, 'intro');
}
}
if (!shouldFade) {
app.innerHTML = markup;
schedulePostRenderSetup(app, 0);
return app.firstElementChild;
}
var topLevelChildren = Array.prototype.slice.call(app.children || []);
var existingShells = topLevelChildren.filter(function (child) {
return child && child.classList && child.classList.contains('slide-shell');
});
var previousShell = existingShells.length ? existingShells[existingShells.length - 1] : app.firstElementChild;
if (existingShells.length > 1) {
existingShells.slice(0, -1).forEach(function (shell) {
if (shell && shell.parentNode) {
shell.parentNode.removeChild(shell);
}
});
}
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');
nextShell.className = 'slide-shell';
nextShell.style.opacity = '0';
nextShell.innerHTML = markup;
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
app.innerHTML = '';
nextShell.style.opacity = '1';
app.appendChild(nextShell);
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
return nextShell;
}
if (!previousShell.classList.contains('slide-shell')) {
previousShell.classList.add('slide-shell');
}
previousShell.style.opacity = '1';
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
app.appendChild(nextShell);
window.requestAnimationFrame(function () {
nextShell.style.opacity = '1';
previousShell.style.opacity = '0';
schedulePostRenderSetup(nextShell, slideFadeDurationMs / 2);
});
slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) {
previousShell.parentNode.removeChild(previousShell);
}
if (nextShell) {
nextShell.style.opacity = '1';
}
slideTransitionTimer = null;
}, slideFadeDurationMs);
return nextShell;
}
// Mirror blackout state onto the document body.
function syncBlackoutState() {
document.body.classList.toggle('screen-blackout', isBlackout);
}
// Apply pause state and preserve remaining slide time.
function setPaused(nextPaused) {
var normalized = Boolean(nextPaused);
if (isPaused === normalized) {
return;
}
if (normalized) {
pausedRemainingMs = slideExpiresAt ? Math.max(0, slideExpiresAt - Date.now()) : null;
isPaused = true;
clearSlideTimer();
sendCommandState(lastRenderedSlide);
return;
}
isPaused = false;
sendCommandState(lastRenderedSlide);
if (!slides.length || !lastRenderedSlide) {
return;
}
if (pausedRemainingMs !== null) {
scheduleSlideAdvance(pausedRemainingMs);
pausedRemainingMs = null;
}
}
// Apply blackout state and notify the server.
function setBlackout(nextBlackout) {
var normalized = Boolean(nextBlackout);
if (isBlackout === normalized) {
return;
}
isBlackout = normalized;
syncBlackoutState();
sendCommandState(lastRenderedSlide);
}
// Coerce command payload values into booleans or null.
function normalizeBoolean(value) {
if (value === true || value === false) {
return value;
}
if (value === null || value === undefined) {
return null;
}
var normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].indexOf(normalized) !== -1) {
return true;
}
if (['0', 'false', 'no', 'off', ''].indexOf(normalized) !== -1) {
return false;
}
return null;
}
function isEditableTarget(target) {
if (!target) {
return false;
}
if (target.isContentEditable) {
return true;
}
var tagName = String(target.tagName || '').toUpperCase();
return ['INPUT', 'TEXTAREA', 'SELECT', 'OPTION'].indexOf(tagName) !== -1;
}
var keyboardFeedbackTimer = null;
function showKeyboardFeedback(message) {
if (typeof document === 'undefined' || !document.body) {
return;
}
var feedback = document.querySelector('.player-keyboard-feedback');
if (!feedback) {
feedback = document.createElement('div');
feedback.className = 'player-keyboard-feedback';
feedback.setAttribute('aria-live', 'polite');
document.body.appendChild(feedback);
}
feedback.textContent = String(message || '');
feedback.classList.remove('is-visible');
void feedback.offsetWidth;
feedback.classList.add('is-visible');
if (keyboardFeedbackTimer) {
window.clearTimeout(keyboardFeedbackTimer);
}
keyboardFeedbackTimer = window.setTimeout(function () {
feedback.classList.remove('is-visible');
keyboardFeedbackTimer = null;
}, 900);
}
function handlePlayerKeydown(event) {
if (!event || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) {
return;
}
if (isEditableTarget(event.target)) {
return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
navigateSlides(-1);
showKeyboardFeedback('Previous slide');
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
navigateSlides(1);
showKeyboardFeedback('Next slide');
return;
}
if (String(event.key || '').toLowerCase() === 'p') {
event.preventDefault();
setPaused(!isPaused);
showKeyboardFeedback(isPaused ? 'Paused' : 'Playing');
return;
}
if (String(event.key || '').toLowerCase() === 'b') {
event.preventDefault();
setBlackout(!isBlackout);
showKeyboardFeedback(isBlackout ? 'Blackout on' : 'Blackout off');
}
}
window.addEventListener('keydown', handlePlayerKeydown);
// Move to the previous or next active slide.
function navigateSlides(offset) {
const manualSlides = getCurrentActiveSlides();
if (!manualSlides.length) {
return;
}
let currentIndex = manualSlides.findIndex(function (slide) {
return slide && lastRenderedSlide && slide.id === lastRenderedSlide.id;
});
if (currentIndex < 0) {
currentIndex = Math.min(Math.max(index, 0), manualSlides.length - 1);
}
const nextIndex = (currentIndex + offset + manualSlides.length) % manualSlides.length;
clearSlideTimer();
applyPendingPlaylistUpdate();
renderSlideAtIndex(manualSlides, nextIndex);
}
// Route incoming websocket command messages.
function handleCommandMessage(rawMessage) {
var payload;
try {
payload = JSON.parse(String(rawMessage || ''));
} catch (_error) {
return;
}
if (payload && payload.type === 'client-id-conflict') {
handleClientIdConflict();
return;
}
if (!payload || payload.type !== 'command') {
return;
}
switch (payload.command) {
case 'refresh':
refresh();
return;
case 'setclientname':
if (payload.clientName) {
applyOnboardingClientName(payload.clientName, commandSocket);
}
return;
case 'redirect':
if (payload.url) {
var redirectUrl = String(payload.url);
var authorizeMove = payload.moveToken
? fetch('/api/screen-move-authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ moveToken: String(payload.moveToken) })
})
: Promise.resolve();
authorizeMove.finally(function () {
window.location.replace(redirectUrl);
});
}
return;
case 'pause':
var desiredPause = normalizeBoolean(payload.paused);
if (desiredPause !== null) {
setPaused(desiredPause);
} else {
setPaused(!isPaused);
}
return;
case 'blackout':
var desiredBlackout = normalizeBoolean(payload.blackout);
if (desiredBlackout !== null) {
setBlackout(desiredBlackout);
} else {
setBlackout(!isBlackout);
}
return;
case 'previous':
navigateSlides(-1);
return;
case 'next':
navigateSlides(1);
return;
case 'reload':
window.location.reload();
return;
case 'announcement-refresh':
if (typeof refreshAnnouncementOverlay === 'function') {
refreshAnnouncementOverlay();
}
return;
}
}
function handleClientIdConflict() {
var replacementClientId = regenerateCommandClientId();
var onboardingUrl = new URL('/', window.location.origin);
onboardingUrl.searchParams.set('clientId', replacementClientId);
window.location.replace(onboardingUrl.toString());
}
// Retry the command websocket after a disconnect.
function scheduleCommandReconnect() {
if (commandReconnectTimer) {
return;
}
commandReconnectTimer = window.setTimeout(function () {
commandReconnectTimer = null;
connectCommandSocket();
}, 5000);
}
// Open and wire the command websocket connection.
function connectCommandSocket() {
if (!window.WebSocket) {
return;
}
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
return;
}
var socket = new WebSocket(new URL(commandSocketPath, window.location.origin).toString());
commandSocket = socket;
socket.onopen = function () {
if (typeof syncOnboardingClientNameFromServer === 'function') {
syncOnboardingClientNameFromServer(socket).then(function () {
sendCommandState(socket);
});
return;
}
sendCommandState(socket);
};
socket.onmessage = function (event) {
handleCommandMessage(event.data);
};
socket.onclose = function (event) {
commandSocket = null;
if (event && event.code === 4009) {
handleClientIdConflict();
return;
}
scheduleCommandReconnect();
};
socket.onerror = function () {
try {
socket.close();
} catch (_error) {
// ignore socket close errors
}
};
}
+169
View File
@@ -0,0 +1,169 @@
// Show or hide the offline status banner.
function setOfflineBannerVisible(visible, message) {
if (window.__pulseThumbnailPreview) {
return;
}
var normalizedVisible = Boolean(visible);
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
if (normalizedVisible) {
if (!offlineBanner) {
offlineBanner = document.createElement('div');
offlineBanner.className = 'player-offline-banner';
offlineBanner.style.position = 'fixed';
offlineBanner.style.right = '0';
offlineBanner.style.bottom = '0';
offlineBanner.style.left = 'auto';
offlineBanner.style.top = 'auto';
offlineBanner.style.width = '1.25rem';
offlineBanner.style.height = '1.25rem';
offlineBanner.style.zIndex = '9999';
offlineBanner.style.background = 'linear-gradient(135deg, #ff4d4f 0%, #b00020 100%)';
offlineBanner.style.clipPath = 'circle(100% at 100% 100%)';
offlineBanner.style.webkitClipPath = 'circle(100% at 100% 100%)';
offlineBanner.style.boxShadow = '0 0 0 1px rgba(0, 0, 0, 0.16), 0 4px 12px rgba(0, 0, 0, 0.18)';
offlineBanner.style.pointerEvents = 'none';
document.body.appendChild(offlineBanner);
}
offlineBanner.textContent = '';
offlineBanner.setAttribute('aria-label', bannerMessage);
offlineBanner.setAttribute('role', 'img');
offlineBanner.title = bannerMessage;
offlineBannerVisible = true;
return;
}
offlineBannerVisible = false;
if (offlineBanner && offlineBanner.parentNode) {
offlineBanner.parentNode.removeChild(offlineBanner);
}
offlineBanner = null;
}
// Update the offline banner based on connectivity or playlist availability.
function syncOfflineBanner() {
if (window.__pulseThumbnailPreview) {
return;
}
if (!window.navigator.onLine) {
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
return;
}
if (offlineBannerVisible) {
setOfflineBannerVisible(false);
}
}
// Clear any pending playlist refresh retry.
function clearRefreshRetry() {
if (refreshRetryTimer) {
window.clearTimeout(refreshRetryTimer);
refreshRetryTimer = null;
}
}
// Retry playlist refresh with a short backoff while the player is offline.
function scheduleRefreshRetry() {
if (window.__pulseThumbnailPreview) {
return;
}
if (refreshRetryTimer) {
return;
}
if (window.navigator.onLine === false) {
refreshRetryDelayMs = refreshRetryDelayMs ? Math.min(refreshRetryDelayMs * 2, 15000) : 3000;
} else {
refreshRetryDelayMs = refreshRetryDelayMs ? Math.min(refreshRetryDelayMs * 2, 8000) : 3000;
}
refreshRetryTimer = window.setTimeout(function () {
refreshRetryTimer = null;
refresh();
}, refreshRetryDelayMs);
}
function clearScreenWakeLockRetry() {
if (screenWakeLockRetryTimer) {
window.clearTimeout(screenWakeLockRetryTimer);
screenWakeLockRetryTimer = null;
}
}
function supportsScreenWakeLock() {
return Boolean(window.navigator && window.navigator.wakeLock && typeof window.navigator.wakeLock.request === 'function');
}
function scheduleScreenWakeLockRetry() {
if (screenWakeLockRetryTimer) {
return;
}
if (!supportsScreenWakeLock() || document.visibilityState !== 'visible') {
return;
}
screenWakeLockRetryTimer = window.setTimeout(function () {
screenWakeLockRetryTimer = null;
acquireScreenWakeLock();
}, 2000);
}
function releaseScreenWakeLock() {
if (screenWakeLock && typeof screenWakeLock.release === 'function') {
try {
screenWakeLock.release();
} catch (_error) {
// ignore wake lock release errors
}
}
screenWakeLock = null;
screenWakeLockRequestPromise = null;
clearScreenWakeLockRetry();
}
function acquireScreenWakeLock() {
if (!supportsScreenWakeLock() || document.visibilityState !== 'visible') {
return Promise.resolve(null);
}
if (screenWakeLockRequestPromise) {
return screenWakeLockRequestPromise;
}
if (screenWakeLock && screenWakeLock.released === false) {
return Promise.resolve(screenWakeLock);
}
screenWakeLockRequestPromise = window.navigator.wakeLock.request('screen').then(function (sentinel) {
screenWakeLock = sentinel;
screenWakeLock.addEventListener('release', function () {
screenWakeLock = null;
if (document.visibilityState === 'visible') {
scheduleScreenWakeLockRetry();
}
});
clearScreenWakeLockRetry();
return screenWakeLock;
}).catch(function (error) {
screenWakeLock = null;
if (error && error.name !== 'NotAllowedError') {
scheduleScreenWakeLockRetry();
}
return null;
}).finally(function () {
screenWakeLockRequestPromise = null;
});
return screenWakeLockRequestPromise;
}
function syncScreenWakeLock() {
if (!supportsScreenWakeLock()) {
return;
}
if (document.visibilityState === 'visible') {
acquireScreenWakeLock();
return;
}
releaseScreenWakeLock();
}
// Clear the retry cadence after a successful refresh.
function markRefreshHealthy() {
refreshRetryDelayMs = 0;
clearRefreshRetry();
}
@@ -0,0 +1,307 @@
// Render the slide at the requested index within the active set.
async function renderSlideAtIndex(sourceSlides, targetIndex) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
return false;
}
let normalizedIndex = Number(targetIndex || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
}
var slideCount = availableSlides.length;
var currentIndex = normalizedIndex;
var slide = null;
var attempts = 0;
var renderRequestId = ++slideRenderRequestId;
while (attempts < slideCount) {
slide = availableSlides[currentIndex];
if (!slide) {
break;
}
if (currentPlaylistSkipUnavailableRtmp && typeof probeRtmpSlideAvailability === 'function') {
var isAvailable = await probeRtmpSlideAvailability(slide);
if (!isAvailable) {
if (renderRequestId !== slideRenderRequestId) {
return false;
}
currentIndex = (currentIndex + 1) % slideCount;
attempts += 1;
slide = null;
continue;
}
}
if (renderRequestId !== slideRenderRequestId) {
break;
}
break;
}
if (!slide) {
if (currentPlaylistSkipUnavailableRtmp) {
renderEmpty(slides.length ? 'No RTMP slides are currently available.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
scheduleRefreshRetry();
return false;
}
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
return false;
}
index = currentIndex;
var markup = buildSlideMarkup(slide);
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(availableSlides, currentIndex);
}
sendCommandState(slide);
if (!isPaused) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
}
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
return true;
}
// Promote a deferred playlist update at the next safe point.
function applyPendingPlaylistUpdate() {
if (!pendingPlaylistUpdate) {
return false;
}
var nextIndex = Number(index || 0);
slides = pendingPlaylistUpdate.slides;
currentPlaylistSignature = pendingPlaylistUpdate.signature;
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
currentPlaylistSkipUnavailableRtmp = Boolean(pendingPlaylistUpdate.skipUnavailableRtmp);
pendingPlaylistUpdate = null;
clearActiveSlidesCache();
slideMarkupCache = Object.create(null);
templateLayoutCache = Object.create(null);
templateRenderPlanCache = Object.create(null);
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
const activeSlides = getCurrentActiveSlides();
if (!Number.isFinite(nextIndex) || nextIndex < 0) {
nextIndex = 0;
}
index = activeSlides.length ? Math.min(nextIndex, activeSlides.length - 1) : 0;
logDebug('Applied updated playlist on slide transition.');
return true;
}
// Render the current active slide or the empty state.
function showCurrent() {
clearSlideTimer();
const activeSlides = getCurrentActiveSlides();
syncWebpagePreloads(activeSlides, index);
if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(activeSlides, index);
}
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(activeSlides, index);
}
if (!activeSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
return;
}
void renderSlideAtIndex(activeSlides, index);
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.
function handleRtmpPlaybackFailure(message, options) {
if (!currentPlaylistSkipUnavailableRtmp) {
return false;
}
var silent = Boolean(options && options.silent);
const activeSlides = getCurrentActiveSlides();
if (!activeSlides.length) {
return false;
}
clearSlideTimer();
if (!silent) {
logDebug('Skipping RTMP slide because the stream is unavailable.', String(message || ''), 'error');
}
const nextIndex = (Number(index || 0) + 1) % activeSlides.length;
window.setTimeout(function () {
if (!getCurrentActiveSlides().length) {
return;
}
void renderSlideAtIndex(getCurrentActiveSlides(), nextIndex);
}, 0);
return true;
}
// Fetch the latest playlist and queue any updates.
function refresh() {
var request = new XMLHttpRequest();
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
request.open('GET', url, true);
request.timeout = 2500;
if (window.__pulsePageAuthToken) {
request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken);
}
if (typeof getCommandClientId === 'function') {
request.setRequestHeader('x-pulse-client-id', getCommandClientId());
}
if (currentPlaylistEtag) {
request.setRequestHeader('If-None-Match', currentPlaylistEtag);
}
request.onreadystatechange = function () {
if (request.readyState !== 4) {
return;
}
if (request.status === 304) {
markRefreshHealthy();
setOfflineBannerVisible(false);
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
}
return;
}
if (request.status < 200 || request.status >= 300) {
if (request.status === 401 || request.status === 403) {
window.location.replace('/');
return;
}
setOfflineBannerVisible(true);
scheduleRefreshRetry();
logDebug(
'Screen not found or playlist unavailable.',
['URL: ' + url, 'Status: ' + request.status + ' ' + request.statusText, 'Response: ' + String(request.responseText || '').slice(0, 1000)].join(' | '),
'error'
);
return;
}
try {
const responseEtag = String(request.getResponseHeader('ETag') || '').trim();
const data = JSON.parse(request.responseText || '{}');
const nextSignature = getPlaylistRevision(data);
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 nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
var refreshedInitialData = Object.assign({},
typeof initialData !== 'undefined' && initialData ? initialData : (window.initialData || {}), {
screen: data.screen || null,
playlist: data.playlist || null,
slides: nextSlides,
rssFeeds: Array.isArray(data.rssFeeds) ? data.rssFeeds : [],
apiSources: Array.isArray(data.apiSources) ? data.apiSources : [],
timetableGroups: Array.isArray(data.timetableGroups) ? data.timetableGroups : [],
weatherLocations: Array.isArray(data.weatherLocations) ? data.weatherLocations : [],
revision: nextSignature
});
if (typeof initialData !== 'undefined') {
initialData = refreshedInitialData;
}
window.initialData = refreshedInitialData;
savePlaylistSnapshot({
slides: nextSlides,
signature: nextSignature,
fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp,
etag: responseEtag
});
markRefreshHealthy();
setOfflineBannerVisible(false);
const currentActiveSlides = getCurrentActiveSlides();
if (responseEtag) {
currentPlaylistEtag = responseEtag;
}
if (!currentPlaylistSignature) {
slides = nextSlides;
currentPlaylistSignature = nextSignature;
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
index = 0;
showCurrent();
sendCommandState(lastRenderedSlide);
return;
}
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
}
return;
}
syncWebpagePreloads(nextActiveSlides, index);
if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(nextActiveSlides, index);
}
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(nextActiveSlides, index);
}
if (nextActiveSlides.length < 2) {
pendingPlaylistUpdate = {
slides: nextSlides,
signature: nextSignature,
fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp
};
if (!isSlideInList(lastRenderedSlide, nextSlides)) {
applyPendingPlaylistUpdate();
showCurrent();
return;
}
logDebug('Playlist update detected; applying on next slide transition.');
return;
}
pendingPlaylistUpdate = {
slides: nextSlides,
signature: nextSignature,
fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp
};
logDebug('Playlist update detected; applying on next slide transition.');
} catch (_error) {
logDebug(
'Unable to load screen playlist.',
['URL: ' + url, 'Response: ' + String(request.responseText || '').slice(0, 1000)].join(' | '),
'error'
);
setOfflineBannerVisible(true);
scheduleRefreshRetry();
}
};
request.onerror = function () {
logDebug(
'Unable to load screen playlist.',
['URL: ' + url, 'Network error during request.'].join(' | '),
'error'
);
setOfflineBannerVisible(true);
scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
}
};
request.ontimeout = function () {
logDebug(
'Playlist refresh timed out.',
['URL: ' + url, 'Timeout after ' + request.timeout + 'ms'].join(' | '),
'error'
);
setOfflineBannerVisible(true);
scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
}
};
request.send();
}
@@ -0,0 +1,278 @@
// Collect unique webpage URLs from the slide list.
function getWebpageUrls(sourceSlides) {
const urls = [];
(Array.isArray(sourceSlides) ? sourceSlides : []).forEach(function (slide) {
const content = slide && slide.content ? slide.content : {};
const regions = slide && slide.template && Array.isArray(slide.template.regions) ? slide.template.regions : [];
regions.forEach(function (region) {
if (region.region_type !== 'webpage') {
return;
}
const regionContent = content[region.region_key] || {};
const url = String(regionContent.value || '').trim();
if (url && urls.indexOf(url) === -1) {
urls.push(url);
}
});
});
return urls;
}
// Filter the slides down to those that are active right now.
function getActiveSlidesFrom(sourceSlides) {
const now = new Date();
return (Array.isArray(sourceSlides) ? sourceSlides : []).filter(function (slide) {
return isSlideActive(slide, now);
});
}
// Build a cache key for the active slide set.
function getActiveSlidesCacheKey() {
const now = new Date();
return [
currentPlaylistSignature || '',
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours(),
now.getMinutes(),
now.getSeconds()
].join('|');
}
// Return the cached active slide set for the current playlist and second.
function getCurrentActiveSlides() {
const cacheKey = getActiveSlidesCacheKey();
if (cacheKey !== activeSlidesCacheKey) {
activeSlidesCacheValue = getActiveSlidesFrom(slides);
activeSlidesCacheKey = cacheKey;
}
return activeSlidesCacheValue;
}
// Clear the cached active slide set.
function clearActiveSlidesCache() {
activeSlidesCacheKey = '';
activeSlidesCacheValue = [];
}
// Reset render caches when the viewport changes.
function syncRenderCacheViewport() {
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
if (renderCacheViewportKey === viewportKey) {
return;
}
renderCacheViewportKey = viewportKey;
slideMarkupCache = Object.create(null);
templateLayoutCache = Object.create(null);
templateRenderPlanCache = Object.create(null);
}
// Build a signature for the currently rendered view.
function getCurrentRenderKey(activeSlides) {
const viewportKey = window.innerWidth + 'x' + window.innerHeight;
const availableSlides = Array.isArray(activeSlides) ? activeSlides : [];
if (!availableSlides.length) {
return [currentPlaylistSignature || '', viewportKey, 'empty', slides.length ? 'scheduled' : 'assigned'].join('|');
}
let normalizedIndex = Number(index || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
normalizedIndex = 0;
}
const slide = availableSlides[normalizedIndex];
return [currentPlaylistSignature || '', viewportKey, 'slide', slide && slide.id ? slide.id : ''].join('|');
}
// Pick the next slide for webpage preloading.
function getWebpagePreloadSlides(sourceSlides, targetIndex) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
return [];
}
let normalizedIndex = Number(targetIndex || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
normalizedIndex = 0;
}
const preloadSlides = [];
const nextSlide = availableSlides[normalizedIndex + 1];
if (nextSlide) {
preloadSlides.push(nextSlide);
}
return preloadSlides;
}
// Pick the next slide to warm its markup before it becomes visible.
function getSlideMarkupPreloadSlides(sourceSlides, targetIndex) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
return [];
}
let normalizedIndex = Number(targetIndex || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
normalizedIndex = 0;
}
const preloadSlides = [];
const nextSlide = availableSlides[normalizedIndex + 1];
if (nextSlide) {
preloadSlides.push(nextSlide);
}
return preloadSlides;
}
function scheduleSlideMarkupPreload(sourceSlides, targetIndex) {
if (window.__pulseThumbnailPreview) {
return;
}
const preloadSlides = getSlideMarkupPreloadSlides(sourceSlides, targetIndex);
if (!preloadSlides.length || typeof primeSlideMarkup !== 'function') {
return;
}
const slide = preloadSlides[0];
const preloadSignature = [
currentPlaylistSignature || '',
slide && slide.id ? slide.id : '',
slide && slide.template_id ? slide.template_id : '',
slide && slide.modified_at ? slide.modified_at : '',
window.innerWidth + 'x' + window.innerHeight,
videoRegionRenderVersion || 0
].join('|');
if (scheduleSlideMarkupPreload.signature === preloadSignature) {
return;
}
scheduleSlideMarkupPreload.signature = preloadSignature;
window.setTimeout(function () {
if (scheduleSlideMarkupPreload.signature !== preloadSignature) {
return;
}
primeSlideMarkup(slide);
}, 0);
}
// Mount hidden iframe preloads for the chosen webpage URLs.
function syncWebpagePreloads(sourceSlides, targetIndex) {
const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex));
const signature = urls.join('\n');
if (signature === preloadSignature) {
return;
}
if (!urls.length) {
preloadSignature = '';
if (preloadContainer) {
preloadContainer.innerHTML = '';
}
return;
}
if (!preloadContainer) {
preloadContainer = document.createElement('div');
preloadContainer.className = 'webpage-preloads';
preloadContainer.setAttribute('aria-hidden', 'true');
document.body.appendChild(preloadContainer);
}
preloadContainer.innerHTML = urls.map(function (url) {
return '<iframe class="webpage-preload-frame" src="' + escapeHtml(url) + '" title="Webpage preload" tabindex="-1" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
}).join('');
preloadSignature = signature;
}
// Return a stable client id for this screen session.
function getCommandClientId() {
if (commandClientId) {
return commandClientId;
}
try {
var storedClientId = window.sessionStorage.getItem(commandClientStorageKey);
if (storedClientId) {
commandClientId = storedClientId;
return commandClientId;
}
} catch (_error) {
// fall through to ephemeral ID generation
}
commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2));
try {
window.sessionStorage.setItem(commandClientStorageKey, commandClientId);
} catch (_error2) {
// ignore storage errors
}
return commandClientId;
}
function regenerateCommandClientId() {
commandClientId = null;
try {
window.sessionStorage.removeItem(commandClientStorageKey);
} catch (_error) {
// ignore storage errors
}
return getCommandClientId();
}
// Load the most recent playlist snapshot from browser storage.
function loadPlaylistSnapshot() {
try {
var raw = window.localStorage.getItem(playlistSnapshotStorageKey);
if (!raw) {
return null;
}
var parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.slides)) {
return null;
}
return {
slides: parsed.slides.map(normalizeSlide),
signature: String(parsed.signature || ''),
fadeBetweenSlides: Boolean(parsed.fadeBetweenSlides),
etag: String(parsed.etag || '')
};
} catch (_error) {
return null;
}
}
// Save the latest playlist snapshot for offline recovery.
function savePlaylistSnapshot(data) {
try {
window.localStorage.setItem(playlistSnapshotStorageKey, JSON.stringify({
slides: Array.isArray(data && data.slides) ? data.slides : [],
signature: String(data && data.signature || ''),
fadeBetweenSlides: Boolean(data && data.fadeBetweenSlides),
etag: String(data && data.etag || ''),
savedAt: new Date().toISOString()
}));
} catch (_error) {
// ignore storage errors
}
}
// Apply a playlist snapshot to the current in-memory state.
function applyPlaylistSnapshot(data) {
if (!data || !Array.isArray(data.slides)) {
return false;
}
slides = data.slides.map(normalizeSlide);
currentPlaylistSignature = String(data.signature || '');
currentPlaylistFadeBetweenSlides = Boolean(data.fadeBetweenSlides);
currentPlaylistEtag = String(data.etag || '');
pendingPlaylistUpdate = null;
clearActiveSlidesCache();
slideMarkupCache = Object.create(null);
templateLayoutCache = Object.create(null);
templateRenderPlanCache = Object.create(null);
renderCacheViewportKey = '';
index = 0;
return true;
}
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
// 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 ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
const PLAYLIST_CACHE = `pulse-signage-player-playlists-${CACHE_VERSION}`;
function normalizeRequest(request) {
const url = new URL(request.url);
return new Request(`${url.origin}${url.pathname}`, {
method: 'GET',
headers: request.headers,
mode: 'same-origin',
credentials: 'same-origin'
});
}
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) {
if (!response || !response.ok) {
return;
}
const cache = await caches.open(cacheName);
await cache.put(cacheKeyRequest || request, response.clone());
}
async function networkFirst(request, cacheName, cacheKeyRequest) {
try {
const response = await fetch(request);
if (response && response.ok) {
await cacheResponse(cacheName, request, response, cacheKeyRequest);
return response;
}
if (response && response.status === 304) {
const cached = await caches.match(cacheKeyRequest || request);
if (cached) {
return cached;
}
}
const cached = await caches.match(cacheKeyRequest || request);
if (cached) {
return cached;
}
return response;
} catch (_error) {
const cached = await caches.match(cacheKeyRequest || request);
if (cached) {
return cached;
}
return new Response('', { status: 504, statusText: 'Offline' });
}
}
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
if (response && response.ok) {
await cacheResponse(cacheName, request, response);
}
return response;
}
async function staleWhileRevalidate(request, cacheName) {
const cached = await caches.match(request);
const networkPromise = fetch(request).then(async function (response) {
if (response && response.ok) {
await cacheResponse(cacheName, request, response);
}
return response;
}).catch(function () {
return null;
});
if (cached) {
networkPromise.catch(function () {
return null;
});
return cached;
}
const networkResponse = await networkPromise;
if (networkResponse) {
return networkResponse;
}
return new Response('', { status: 504, statusText: 'Offline' });
}
async function networkOnly(request) {
return fetch(request);
}
self.addEventListener('install', function (event) {
self.skipWaiting();
event.waitUntil(Promise.resolve());
});
self.addEventListener('activate', function (event) {
event.waitUntil((async function () {
const expected = [PAGE_CACHE, ASSET_CACHE, MEDIA_CACHE, PLAYLIST_CACHE];
const keys = await caches.keys();
await Promise.all(keys.filter(function (key) {
return expected.indexOf(key) === -1;
}).map(function (key) {
return caches.delete(key);
}));
await self.clients.claim();
})());
});
self.addEventListener('fetch', function (event) {
const request = event.request;
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin) {
return;
}
if (url.pathname === '/sw.js') {
return;
}
if (shouldBypassCache(request)) {
event.respondWith(networkOnly(request));
return;
}
if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirst(request, ASSET_CACHE));
return;
}
if (url.pathname.startsWith('/media/')) {
event.respondWith(networkOnly(request));
return;
}
if (request.mode === 'navigate' || url.pathname === '/' || url.pathname === '/onboard' || /^\/screen\/[^/]+$/.test(url.pathname)) {
event.respondWith(networkFirst(request, PAGE_CACHE, normalizeRequest(request)));
return;
}
if (url.pathname.startsWith('/api/screens/') && url.pathname.endsWith('/playlist')) {
event.respondWith(networkFirst(request, PLAYLIST_CACHE, normalizeRequest(request)));
}
});
File diff suppressed because one or more lines are too long
+153
View File
@@ -0,0 +1,153 @@
// API region helpers for resolving source data and nested values.
var registry = window.pulsePlayerRegionTypes;
var placeholderUtils = window.placeholderUtils || {};
function getApiSourceById(sourceId) {
var sources = Array.isArray(initialData && initialData.apiSources) ? initialData.apiSources : [];
var normalizedId = Number(sourceId || 0);
return sources.find(function (source) {
return Number(source.id) === normalizedId;
}) || null;
}
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 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)) {
return responseJson;
}
if (responseJson && Array.isArray(responseJson.items)) {
return responseJson.items;
}
if (responseJson && Array.isArray(responseJson.results)) {
return responseJson.results;
}
if (responseJson && Array.isArray(responseJson.data)) {
return responseJson.data;
}
return responseJson ? [responseJson] : [];
}
function getApiItem(sourceId, itemNumber, itemsPathOverride) {
var items = getApiSourceItems(sourceId, itemsPathOverride);
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
return items[index] || null;
}
function substituteApiVariables(html, item, sourceId) {
var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') {
return '';
}
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
return '';
}
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
var source = getApiSourceById(sourceId);
imageSource = source && source.imageCache && source.imageCache[imageSource] ? source.imageCache[imageSource] : imageSource;
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return '';
}
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
var hasImageBounds = imageConfig.width && imageConfig.height;
var imageStyle = hasImageBounds
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
return hasImageBounds
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>'
: image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
});
}
function getApiPreviewFallback(item) {
if (!item || typeof item !== 'object') {
return '';
}
var title = String(item.title || item.name || '').trim();
var description = String(item.description || item.summary || item.text || '').trim();
var summary = [];
if (title) {
summary.push('<h3>' + escapeHtml(title) + '</h3>');
}
if (description) {
summary.push('<div>' + sanitizeRichText(description) + '</div>');
}
if (!summary.length) {
return '';
}
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) {
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : null;
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 fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var item = getApiItem(sourceId, itemNumber, itemsPath);
var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== '';
var body = hasSource ? (item ? substituteApiVariables(content, item, sourceId) : '') : content;
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) + ';';
if (!body) {
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
});
+55
View File
@@ -0,0 +1,55 @@
// HTML region rendering with sandboxed iframe output.
var registry = window.pulsePlayerRegionTypes;
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return normalizeRenderableValue(value.value);
}
if (value.text !== undefined) {
return normalizeRenderableValue(value.text);
}
if (value.html !== undefined) {
return normalizeRenderableValue(value.html);
}
if (value.content !== undefined) {
return normalizeRenderableValue(value.content);
}
return '';
}
return String(value === undefined || value === null ? '' : value);
}
function buildHtmlDocument(html) {
var raw = String(html || '').trim();
if (!raw) {
return '';
}
if (/^<!doctype\b/i.test(raw) || /^<html\b/i.test(raw)) {
return raw;
}
return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}html,body{background:transparent !important;}</style></head><body>' + raw + '</body></html>';
}
function renderHtmlRegionContent(value) {
var html = normalizeRenderableValue(value).trim();
if (!html) {
return '';
}
if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
return '<iframe class="template-region-html-frame" sandbox="" allowtransparency="true" scrolling="no" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
}
return '<div class="template-region-html-content" style="width:100%;height:100%;overflow:hidden;background:transparent;">' + html + '</div>';
}
function renderHtmlRegion(region, regionContent) {
return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent && regionContent.value !== undefined ? regionContent.value : '') + '</div>';
}
registry.register('html', {
renderRegion: renderHtmlRegion
});
+15
View File
@@ -0,0 +1,15 @@
// Image region rendering for direct slide media references.
var registry = window.pulsePlayerRegionTypes;
function renderImageRegion(region, regionContent) {
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>';
}
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 () {}
});
+120
View File
@@ -0,0 +1,120 @@
// RSS region helpers for resolving feeds, items, and nested values.
var registry = window.pulsePlayerRegionTypes;
var placeholderUtils = window.placeholderUtils || {};
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) {
var feeds = Array.isArray(initialData && initialData.rssFeeds) ? initialData.rssFeeds : [];
var normalizedId = Number(feedId || 0);
return feeds.find(function (feed) {
return Number(feed.id) === normalizedId;
}) || null;
}
function getRssFeedItem(feedId, itemNumber) {
var feed = getRssFeedById(feedId);
var items = feed && Array.isArray(feed.items) ? feed.items : [];
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
return items[index] || null;
}
function resolveRssPath(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 substituteRssVariables(html, item, feedId) {
var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') {
return '';
}
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
return '';
}
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
var feed = getRssFeedById(feedId);
imageSource = feed && feed.imageCache && feed.imageCache[imageSource] ? feed.imageCache[imageSource] : imageSource;
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return '';
}
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
var hasImageBounds = imageConfig.width && imageConfig.height;
var imageStyle = hasImageBounds
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
return hasImageBounds
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>'
: image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
});
}
function renderRssRegion(region, regionContent) {
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
var feedId = regionContent && regionContent.feed_id !== undefined ? regionContent.feed_id : null;
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
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 item = getRssFeedItem(feedId, itemNumber);
var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== '';
var body = hasFeed ? (item ? substituteRssVariables(content, item, feedId) : '') : content;
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) + ';';
if (!body) {
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
});
+826
View File
@@ -0,0 +1,826 @@
var registry = window.pulsePlayerRegionTypes;
function renderRtmpRegion(region, regionContent) {
var url = String(regionContent.value || '').trim();
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
var skipUnavailable = Boolean(currentPlaylistSkipUnavailableRtmp);
if (!url) {
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' + (disableAudio ? ' muted' : '') + '></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
}
function getRtmpSessionUrl(sourceUrl, disableAudio) {
return '/api/rtmp/session?source=' + encodeURIComponent(sourceUrl) + '&disableAudio=' + (disableAudio ? '1' : '0');
}
var rtmpAvailabilityCache = Object.create(null);
var rtmpAvailabilityRetryMs = 5000;
var rtmpWarmupTimers = Object.create(null);
var rtmpBrowserReadyCache = Object.create(null);
var rtmpBrowserWarmupTimers = Object.create(null);
var rtmpBrowserWarmupContainer = null;
var rtmpBrowserWarmupStates = Object.create(null);
function getRtmpAvailabilityKey(sourceUrl, disableAudio) {
return String(sourceUrl || '').trim() + '\n' + (disableAudio ? '1' : '0');
}
function setRtmpAvailability(sourceUrl, disableAudio, available) {
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
rtmpAvailabilityCache[key] = {
available: available === null ? null : Boolean(available),
pending: available === null,
checkedAt: Date.now()
};
}
function getRtmpAvailability(sourceUrl, disableAudio) {
return rtmpAvailabilityCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
}
function isRtmpAvailabilityStale(status) {
if (!status || !status.checkedAt) {
return true;
}
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
}
function isRtmpSlideUnavailable(slide) {
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
return false;
}
var content = slide.content || {};
return slide.template.regions.some(function (region) {
if (region.region_type !== 'rtmp') {
return false;
}
var regionContent = content[region.region_key] || {};
var sourceUrl = String(regionContent.value || '').trim();
if (!sourceUrl) {
return false;
}
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
var serverStatus = getRtmpAvailability(sourceUrl, disableAudio);
return Boolean(!serverStatus || isRtmpAvailabilityStale(serverStatus) || serverStatus.available !== true);
});
}
function clearRtmpAvailabilityForSlide(slide) {
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
return;
}
var content = slide.content || {};
slide.template.regions.forEach(function (region) {
if (region.region_type !== 'rtmp') {
return;
}
var regionContent = content[region.region_key] || {};
var sourceUrl = String(regionContent.value || '').trim();
if (!sourceUrl) {
return;
}
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
setRtmpAvailability(sourceUrl, disableAudio, null);
});
}
function setRtmpBrowserReady(sourceUrl, disableAudio, ready) {
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
rtmpBrowserReadyCache[key] = {
ready: ready === null ? null : Boolean(ready),
checkedAt: Date.now()
};
}
function getRtmpBrowserReady(sourceUrl, disableAudio) {
return rtmpBrowserReadyCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
}
function isRtmpBrowserReadyStale(status) {
if (!status || !status.checkedAt) {
return true;
}
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
}
function ensureRtmpBrowserWarmupContainer() {
if (rtmpBrowserWarmupContainer) {
return rtmpBrowserWarmupContainer;
}
rtmpBrowserWarmupContainer = document.createElement('div');
rtmpBrowserWarmupContainer.className = 'rtmp-browser-warmups';
rtmpBrowserWarmupContainer.setAttribute('aria-hidden', 'true');
document.body.appendChild(rtmpBrowserWarmupContainer);
return rtmpBrowserWarmupContainer;
}
function getRtmpBrowserWarmupState(sourceUrl, disableAudio) {
return rtmpBrowserWarmupStates[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
}
function clearRtmpBrowserWarmupState(sourceUrl, disableAudio) {
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
var state = rtmpBrowserWarmupStates[key];
if (!state) {
return;
}
if (state.video && state.video.__rtmpBrowserWarmupTimer) {
window.clearTimeout(state.video.__rtmpBrowserWarmupTimer);
state.video.__rtmpBrowserWarmupTimer = null;
}
if (state.video && state.video.__rtmpHls) {
try {
state.video.__rtmpHls.destroy();
} catch (_error) {
// ignore cleanup errors
}
state.video.__rtmpHls = null;
}
if (state.wrapper && state.wrapper.parentNode) {
state.wrapper.parentNode.removeChild(state.wrapper);
}
delete rtmpBrowserWarmupStates[key];
}
function warmupRtmpBrowser(sourceUrl, disableAudio) {
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
var existing = rtmpBrowserWarmupStates[key];
if (existing) {
return existing;
}
var wrapper = document.createElement('div');
wrapper.className = 'rtmp-browser-warmup-entry';
wrapper.setAttribute('aria-hidden', 'true');
var video = document.createElement('video');
video.className = 'template-region-rtmp-video';
video.dataset.rtmpSource = sourceUrl;
video.dataset.rtmpDisableAudio = disableAudio ? '1' : '0';
video.autoplay = true;
video.playsInline = true;
video.preload = 'auto';
video.tabIndex = -1;
video.disablePictureInPicture = true;
video.muted = disableAudio;
wrapper.appendChild(video);
ensureRtmpBrowserWarmupContainer().appendChild(wrapper);
var state = {
wrapper: wrapper,
video: video,
sourceUrl: sourceUrl,
disableAudio: disableAudio
};
rtmpBrowserWarmupStates[key] = state;
setRtmpBrowserReady(sourceUrl, disableAudio, null);
var markReady = function () {
setRtmpBrowserReady(sourceUrl, disableAudio, true);
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
};
var markNotReady = function () {
setRtmpBrowserReady(sourceUrl, disableAudio, false);
};
video.addEventListener('canplay', markReady, { once: true });
video.addEventListener('playing', markReady, { once: true });
video.addEventListener('error', markNotReady, { once: true });
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
var hls = new window.Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 4,
liveMaxLatencyDurationCount: 8,
maxBufferLength: 10,
maxLiveSyncPlaybackRate: 1,
backBufferLength: 10
});
video.__rtmpHls = hls;
hls.attachMedia(video);
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
});
hls.on(window.Hls.Events.ERROR, function (_event, data) {
if (data && data.fatal) {
markNotReady();
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
}
});
return state;
}
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
return state;
}
markNotReady();
return state;
}
function requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure) {
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
credentials: 'same-origin'
}).then(function (response) {
return response.json().catch(function () {
return null;
}).then(function (payload) {
return {
response: response,
payload: payload
};
});
}).then(function (result) {
if (!result || !result.response) {
throw new Error('Unable to initialize RTMP stream.');
}
var response = result.response;
var payload = result.payload || null;
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
if (!response.ok) {
if (response.status === 503 && payload && payload.probing) {
setRtmpBrowserReady(sourceUrl, disableAudio, null);
if (video && !video.__rtmpBrowserWarmupTimer) {
video.__rtmpBrowserWarmupTimer = window.setTimeout(function () {
video.__rtmpBrowserWarmupTimer = null;
requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure);
}, 500);
}
return;
}
throw new Error('Unable to initialize RTMP stream.');
}
if (!playlistUrl) {
throw new Error('RTMP playlist URL was not returned.');
}
setRtmpBrowserReady(sourceUrl, disableAudio, false);
if (video.__rtmpHls && window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
video.__rtmpHls.loadSource(playlistUrl);
return;
}
if (video) {
video.src = playlistUrl;
video.play().catch(function () {
return null;
});
}
}).catch(function () {
setRtmpBrowserReady(sourceUrl, disableAudio, false);
if (typeof onFailure === 'function') {
onFailure();
}
});
}
function getRtmpWarmupSlides(sourceSlides, targetIndex) {
var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
return [];
}
var normalizedIndex = Number(targetIndex || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
normalizedIndex = 0;
}
var warmupSlides = [];
var nextSlide = availableSlides[normalizedIndex + 1];
if (nextSlide) {
warmupSlides.push(nextSlide);
}
return warmupSlides;
}
function getRtmpWarmupEntries(sourceSlides) {
var entries = [];
var seen = Object.create(null);
(Array.isArray(sourceSlides) ? sourceSlides : []).forEach(function (slide) {
var content = slide && slide.content ? slide.content : {};
var regions = slide && slide.template && Array.isArray(slide.template.regions) ? slide.template.regions : [];
regions.forEach(function (region) {
if (region.region_type !== 'rtmp') {
return;
}
var regionContent = content[region.region_key] || {};
var url = String(regionContent.value || '').trim();
if (!url) {
return;
}
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
var key = getRtmpAvailabilityKey(url, disableAudio);
if (seen[key]) {
return;
}
seen[key] = true;
entries.push({
url: url,
disableAudio: disableAudio,
key: key
});
});
});
return entries;
}
function syncRtmpWarmups(sourceSlides, targetIndex) {
var entries = getRtmpWarmupEntries(getRtmpWarmupSlides(sourceSlides, targetIndex));
entries.forEach(function (entry) {
var status = getRtmpAvailability(entry.url, entry.disableAudio);
if (status && !isRtmpAvailabilityStale(status) && status.available === true) {
return;
}
if (rtmpWarmupTimers[entry.key]) {
return;
}
function requestWarmup() {
fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
credentials: 'same-origin'
}).then(function (response) {
return response.json().catch(function () {
return null;
}).then(function (payload) {
return {
response: response,
payload: payload
};
});
}).then(function (result) {
rtmpWarmupTimers[entry.key] = null;
if (!result || !result.response) {
setRtmpAvailability(entry.url, entry.disableAudio, false);
return;
}
if (!result.response.ok) {
if (result.response.status === 503 && result.payload && result.payload.probing) {
setRtmpAvailability(entry.url, entry.disableAudio, null);
rtmpWarmupTimers[entry.key] = window.setTimeout(function () {
rtmpWarmupTimers[entry.key] = null;
requestWarmup();
}, 500);
return;
}
setRtmpAvailability(entry.url, entry.disableAudio, false);
return;
}
setRtmpAvailability(entry.url, entry.disableAudio, true);
}).catch(function () {
rtmpWarmupTimers[entry.key] = null;
setRtmpAvailability(entry.url, entry.disableAudio, false);
});
}
setRtmpAvailability(entry.url, entry.disableAudio, null);
requestWarmup();
});
}
async function probeRtmpSlideAvailability(slide) {
var entries = getRtmpWarmupEntries([slide]);
if (!entries.length) {
return true;
}
var results = await Promise.all(entries.map(function (entry) {
setRtmpAvailability(entry.url, entry.disableAudio, null);
return fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
credentials: 'same-origin'
}).then(function (response) {
return response.json().catch(function () {
return null;
}).then(function (payload) {
return {
response: response,
payload: payload
};
});
}).then(function (result) {
if (!result || !result.response) {
setRtmpAvailability(entry.url, entry.disableAudio, false);
return false;
}
if (!result.response.ok) {
if (result.response.status === 503 && result.payload && result.payload.probing) {
setRtmpAvailability(entry.url, entry.disableAudio, null);
return false;
}
setRtmpAvailability(entry.url, entry.disableAudio, false);
return false;
}
setRtmpAvailability(entry.url, entry.disableAudio, true);
return true;
}).catch(function () {
setRtmpAvailability(entry.url, entry.disableAudio, false);
return false;
});
}));
return results.every(function (value) {
return Boolean(value);
});
}
function syncRtmpBrowserWarmups(sourceSlides, targetIndex) {
return;
}
function isRtmpBrowserUnavailable(slide) {
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
return false;
}
var content = slide.content || {};
return slide.template.regions.some(function (region) {
if (region.region_type !== 'rtmp') {
return false;
}
var regionContent = content[region.region_key] || {};
var sourceUrl = String(regionContent.value || '').trim();
if (!sourceUrl) {
return false;
}
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
var status = getRtmpBrowserReady(sourceUrl, disableAudio);
return Boolean(!status || isRtmpBrowserReadyStale(status) || status.ready !== true);
});
}
function bindRtmpVideoPlaceholder(video, placeholder) {
if (!video) {
return;
}
var markReady = function () {
if (placeholder) {
placeholder.style.display = 'none';
}
};
video.addEventListener('canplay', markReady, { once: true });
video.addEventListener('playing', markReady, { once: true });
if (video.readyState >= 2) {
markReady();
}
}
function startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, isPreload, onFailure) {
var startupTimeoutMs = skipUnavailable ? 6000 : 0;
var startupTimer = null;
var probeRetryTimer = null;
var webReceiveTimer = null;
var webReceiveRetryUsed = false;
function failPlayback(message, silent) {
if (video.dataset.rtmpFailureHandled === '1') {
return;
}
video.dataset.rtmpFailureHandled = '1';
if (typeof onFailure === 'function') {
onFailure(message, silent);
return;
}
if (!isPreload && skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
return;
}
if (placeholder) {
placeholder.style.display = 'flex';
placeholder.textContent = message;
}
}
function clearStartupTimer() {
if (startupTimer) {
window.clearTimeout(startupTimer);
startupTimer = null;
}
if (video.__rtmpStartupTimer) {
video.__rtmpStartupTimer = null;
}
if (probeRetryTimer) {
window.clearTimeout(probeRetryTimer);
probeRetryTimer = null;
}
if (video.__rtmpProbeRetryTimer) {
video.__rtmpProbeRetryTimer = null;
}
if (webReceiveTimer) {
window.clearTimeout(webReceiveTimer);
webReceiveTimer = null;
}
if (video.__rtmpWebReceiveTimer) {
video.__rtmpWebReceiveTimer = null;
}
}
function tryPlay() {
if (video && typeof video.play === 'function') {
video.play().catch(function () {
return null;
});
}
}
function markReceiving() {
if (webReceiveTimer) {
window.clearTimeout(webReceiveTimer);
webReceiveTimer = null;
}
if (video.__rtmpWebReceiveTimer) {
video.__rtmpWebReceiveTimer = null;
}
}
function scheduleReceiveRetry() {
if (isPreload || webReceiveRetryUsed || video.dataset.rtmpFailureHandled === '1') {
return;
}
if (webReceiveTimer) {
return;
}
webReceiveTimer = window.setTimeout(function () {
webReceiveTimer = null;
video.__rtmpWebReceiveTimer = null;
if (video.dataset.rtmpFailureHandled === '1' || webReceiveRetryUsed) {
return;
}
if (video.readyState >= 2) {
return;
}
webReceiveRetryUsed = true;
clearStartupTimer();
try {
if (video.__rtmpHls) {
video.__rtmpHls.destroy();
}
} catch (_error) {
// ignore cleanup errors
}
video.__rtmpHls = null;
requestSession();
}, 1200);
video.__rtmpWebReceiveTimer = webReceiveTimer;
}
function retryWebReceiveOrFail(message, silent) {
if (isPreload || video.dataset.rtmpFailureHandled === '1') {
return;
}
if (!webReceiveRetryUsed) {
webReceiveRetryUsed = true;
clearStartupTimer();
if (webReceiveTimer) {
window.clearTimeout(webReceiveTimer);
webReceiveTimer = null;
}
if (video.__rtmpWebReceiveTimer) {
video.__rtmpWebReceiveTimer = null;
}
window.setTimeout(function () {
requestSession();
}, 500);
return;
}
failPlayback(message, silent);
}
video.dataset.rtmpInitialized = '1';
video.muted = disableAudio;
video.controls = false;
video.playsInline = true;
video.autoplay = true;
bindRtmpVideoPlaceholder(video, placeholder);
video.addEventListener('canplay', markReceiving, { once: true });
video.addEventListener('playing', markReceiving, { once: true });
if (startupTimeoutMs > 0) {
startupTimer = window.setTimeout(function () {
startupTimer = null;
video.__rtmpStartupTimer = null;
failPlayback('Unable to load RTMP stream.', true);
}, startupTimeoutMs);
video.__rtmpStartupTimer = startupTimer;
}
function retryWhileProbing() {
if (probeRetryTimer || video.dataset.rtmpFailureHandled === '1') {
return;
}
probeRetryTimer = window.setTimeout(function () {
probeRetryTimer = null;
video.__rtmpProbeRetryTimer = null;
requestSession();
}, 500);
video.__rtmpProbeRetryTimer = probeRetryTimer;
}
function requestSession() {
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
credentials: 'same-origin'
}).then(function (response) {
return response.json().catch(function () {
return null;
}).then(function (payload) {
return {
response: response,
payload: payload
};
});
}).then(function (result) {
if (!result || !result.response) {
throw new Error('Unable to initialize RTMP stream.');
}
var response = result.response;
var payload = result.payload || null;
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
if (!response.ok) {
if (response.status === 503 && payload && payload.probing) {
setRtmpAvailability(sourceUrl, disableAudio, null);
retryWhileProbing();
return null;
}
setRtmpAvailability(sourceUrl, disableAudio, false);
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
return null;
}
setRtmpAvailability(sourceUrl, disableAudio, true);
if (!playlistUrl) {
throw new Error('RTMP playlist URL was not returned.');
}
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
var hls = new window.Hls({
enableWorker: true,
liveSyncDurationCount: 6,
liveMaxLatencyDurationCount: 12,
maxBufferLength: 30,
maxLiveSyncPlaybackRate: 1,
backBufferLength: 60
});
video.__rtmpHls = hls;
hls.attachMedia(video);
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
hls.loadSource(playlistUrl);
scheduleReceiveRetry();
});
hls.on(window.Hls.Events.MANIFEST_PARSED, function () {
tryPlay();
});
hls.on(window.Hls.Events.ERROR, function (_event, data) {
if (data && data.fatal) {
clearStartupTimer();
setRtmpAvailability(sourceUrl, disableAudio, false);
try {
hls.destroy();
} catch (_error) {
// ignore cleanup errors
}
video.__rtmpHls = null;
retryWebReceiveOrFail('RTMP playback failed.', true);
}
});
return;
}
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = playlistUrl;
tryPlay();
scheduleReceiveRetry();
return;
}
failPlayback('RTMP playback is not supported in this browser.', true);
return null;
}).catch(function () {
clearStartupTimer();
setRtmpAvailability(sourceUrl, disableAudio, false);
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
});
}
requestSession();
}
function syncRtmpRegions(root) {
if (!root) {
return;
}
var videos = root.querySelectorAll('video[data-rtmp-source]');
Array.prototype.forEach.call(videos, function (video) {
if (!video || video.dataset.rtmpInitialized === '1') {
return;
}
var sourceUrl = String(video.dataset.rtmpSource || '').trim();
var disableAudio = String(video.dataset.rtmpDisableAudio || '1') !== '0';
var region = video.parentNode;
var placeholder = region ? region.querySelector('.template-region-rtmp-placeholder') : null;
if (!sourceUrl) {
if (placeholder) {
placeholder.textContent = 'RTMP stream';
}
return;
}
var skipUnavailable = String(video.dataset.rtmpSkipUnavailable || '0') === '1';
var startupTimer = null;
if (skipUnavailable) {
var preflightStatus = getRtmpAvailability(sourceUrl, disableAudio);
if (preflightStatus && !isRtmpAvailabilityStale(preflightStatus) && preflightStatus.available === false) {
failPlayback('Unable to load RTMP stream.', true);
return;
}
}
function failPlayback(message, silent) {
if (video.dataset.rtmpFailureHandled === '1') {
return;
}
video.dataset.rtmpFailureHandled = '1';
if (skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
return;
}
if (placeholder) {
placeholder.style.display = 'flex';
placeholder.textContent = message;
}
}
startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, false);
});
}
function destroyRtmpRegions(root) {
if (!root) {
return;
}
var videos = root.querySelectorAll('video[data-rtmp-source]');
Array.prototype.forEach.call(videos, function (video) {
if (video.__rtmpStartupTimer) {
window.clearTimeout(video.__rtmpStartupTimer);
video.__rtmpStartupTimer = null;
}
if (video.__rtmpProbeRetryTimer) {
window.clearTimeout(video.__rtmpProbeRetryTimer);
video.__rtmpProbeRetryTimer = null;
}
if (video.__rtmpWebReceiveTimer) {
window.clearTimeout(video.__rtmpWebReceiveTimer);
video.__rtmpWebReceiveTimer = null;
}
if (video.__rtmpHls) {
try {
video.__rtmpHls.destroy();
} catch (_error) {
// ignore cleanup errors
}
video.__rtmpHls = null;
}
});
}
registry.register('rtmp', {
renderRegion: renderRtmpRegion
});
+51
View File
@@ -0,0 +1,51 @@
// Text region rendering with font and color normalization.
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 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
});
+330
View File
@@ -0,0 +1,330 @@
// Time/date region rendering and live updates.
var registry = window.pulsePlayerRegionTypes;
var placeholderUtils = window.placeholderUtils || {};
var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
var DEFAULT_STYLE = {
font_family: 'Arial',
font_size: 32,
font_color: '#000000'
};
var timeDateFormatterCache = Object.create(null);
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 getTimeDateFormatter(key, options) {
if (!timeDateFormatterCache[key]) {
timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options);
}
return timeDateFormatterCache[key];
}
function getTimeDateFormattedParts(timeZone, date) {
var targetDate = date instanceof Date ? date : new Date();
var resolvedTimeZone = resolveTimeZone(timeZone);
var numericParts = getTimeDateFormatter('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 = getTimeDateFormatter('weekday-long:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
weekday: 'long'
}).formatToParts(targetDate);
var weekdayShort = getTimeDateFormatter('weekday-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
weekday: 'short'
}).formatToParts(targetDate);
var monthLong = getTimeDateFormatter('month-long:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
month: 'long'
}).formatToParts(targetDate);
var monthShort = getTimeDateFormatter('month-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
month: 'short'
}).formatToParts(targetDate);
var ampm = getTimeDateFormatter('ampm:' + resolvedTimeZone, {
timeZone: resolvedTimeZone,
hour12: true,
hour: '2-digit',
minute: '2-digit'
}).formatToParts(targetDate);
var timezoneShort = getTimeDateFormatter('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: getPart(timezoneShort, 'timeZoneName'),
tz_long: resolvedTimeZone,
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
};
}
function resolveTimeDateTemplatePlaceholder(values, expression) {
var currentPlaceholderUtils = window.placeholderUtils || placeholderUtils || {};
if (typeof currentPlaceholderUtils.resolvePlaceholderExpression === 'function' && typeof currentPlaceholderUtils.formatPlaceholderValue === 'function') {
return currentPlaceholderUtils.formatPlaceholderValue(currentPlaceholderUtils.resolvePlaceholderExpression(values, expression));
}
var parsed = String(expression || '').trim();
return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : '';
}
function renderTimeDateTemplate(format, timeZone, date) {
var template = String(format || '').trim() || DEFAULT_FORMAT;
var values = getTimeDateFormattedParts(timeZone, date);
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
return String(resolveTimeDateTemplatePlaceholder(values, key, { timeZone: timeZone }) || '');
});
}
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 = renderTimeDateTemplate(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(renderTimeDateTemplate(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
});
+160
View File
@@ -0,0 +1,160 @@
// Timetable region rendering for live playback.
var registry = window.pulsePlayerRegionTypes;
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 timeZone = group && (group.timezone || group.time_zone) ? String(group.timezone || group.time_zone) : '';
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 : '',
timeZone: timeZone,
group: group || {},
entries: entries,
index: index + 1
}))) + '</div>';
}).join('') + '</div></div>';
}
registry.register('timetable', {
renderRegion: renderRegion
});
+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 () {})"></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 () {})"></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 () {})"></video></div>';
}
return '';
}
if (window.pulsePlayerRegionTypes && typeof window.pulsePlayerRegionTypes.register === 'function') {
window.pulsePlayerRegionTypes.register('video', {
renderRegion: renderVideoRegion
});
}
+90
View File
@@ -0,0 +1,90 @@
// Weather region rendering for live playback.
var weatherRegistry = window.pulsePlayerRegionTypes;
var weatherPlaceholderUtils = window.placeholderUtils || {};
function weatherIconForCode(code) {
var value = Number(code);
if (value === 0) return 'bi-sun';
if (value <= 3) return 'bi-cloud-sun';
if (value <= 48) return 'bi-cloud-fog';
if (value <= 67 || value > 77 && value <= 82) return 'bi-cloud-rain';
if (value <= 77) return 'bi-cloud-snow';
return 'bi-cloud-lightning-rain';
}
function getWeatherLocation(locationId) {
var locations = Array.isArray(initialData && initialData.weatherLocations) ? initialData.weatherLocations : [];
return locations.find(function (location) { return Number(location.id) === Number(locationId); }) || null;
}
function normalizeWeatherExpression(expression) {
var value = String(expression || '').trim();
var currentAliases = { temp: 'current.temperature_2m', temp_unit: 'current.temperature_unit', feels_like: 'current.apparent_temperature', humidity: 'current.relative_humidity_2m', code: 'current.weather_code', wind: 'current.wind_speed_10m', wind_unit: 'current.wind_speed_unit', precip: 'current.precipitation', precip_unit: 'current.precipitation_unit', uv_index: 'current.uv_index', cloud_cover: 'current.cloud_cover', icon: 'current.weather_code.icon' };
var globalAliases = { temp_unit: 'temperature_unit', wind_unit: 'wind_speed_unit', precip_unit: 'precipitation_unit' };
if (globalAliases[value]) return globalAliases[value];
var currentField = value.replace(/^current\./, '');
var currentMatch = currentField.match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/);
if (currentMatch && currentAliases[currentMatch[1]]) return currentAliases[currentMatch[1]] + (currentMatch[2] || '');
var indexed = value.match(/^(daily|hourly)\.(\d+)\.(.+)$/);
if (!indexed) return value;
var fieldMatch = indexed[3].match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/);
var field = fieldMatch ? fieldMatch[1] : indexed[3];
var aliases = indexed[1] === 'daily' ? { time: 'time', code: 'weather_code', temp_max: 'temperature_2m_max', temp_min: 'temperature_2m_min', precip: 'precipitation_sum', wind: 'wind_speed_10m_max', uv_index: 'uv_index_max', cloud_cover: 'cloud_cover_mean', sunrise: 'sunrise', sunset: 'sunset', icon: 'weather_code' } : { time: 'time', code: 'weather_code', temp: 'temperature_2m', precip: 'precipitation', wind: 'wind_speed_10m', uv_index: 'uv_index', cloud_cover: 'cloud_cover', icon: 'weather_code' };
if (!Object.prototype.hasOwnProperty.call(aliases, field)) return value;
return field === 'icon' ? indexed[1] + '.' + aliases[field] + '.' + indexed[2] + '.icon' + (fieldMatch[2] || '') : indexed[1] + '.' + aliases[field] + '.' + indexed[2] + (fieldMatch[2] || '');
}
function withWeatherUnits(snapshot, location) {
var data = Object.assign({}, snapshot, { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precip_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' });
var temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C';
var windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h';
var precipitationUnit = location.precipitation_unit === 'inch' ? 'in' : 'mm';
data.current = Object.assign({}, snapshot.current || {}, { temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit });
data.daily = Object.assign({}, snapshot.daily || {}, { temperature_unit: temperatureUnit });
data.hourly = Object.assign({}, snapshot.hourly || {}, { temperature_unit: temperatureUnit });
return data;
}
function substituteWeatherVariables(html, value) {
return String(html || '').replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!value || typeof value !== 'object' || typeof weatherPlaceholderUtils.resolvePlaceholderExpression !== 'function' || typeof weatherPlaceholderUtils.formatPlaceholderValue !== 'function') return '';
var rawExpression = String(expression).trim();
var normalizedExpression = normalizeWeatherExpression(rawExpression);
var iconMatch = normalizedExpression.match(/^(?:current\.weather_code|daily\.weather_code\.\d+|hourly\.weather_code\.\d+)\.icon(?:\((\d+)(?:\s*,\s*(\d+))?\))?$/);
if (iconMatch) {
var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, '');
var width = iconMatch[1] ? Math.max(1, Math.min(1000, Number(iconMatch[1]))) : 0;
var height = iconMatch[2] ? Math.max(1, Math.min(1000, Number(iconMatch[2]))) : width;
var style = width ? ' style="display:inline-block;vertical-align:middle;font-size:' + width + 'px;line-height:' + height + 'px;width:' + width + 'px;height:' + height + 'px;"' : '';
var weatherCode = weatherPlaceholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone });
var icon = '<i class="bi ' + weatherIconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>';
return width ? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + width + 'px;height:' + height + 'px;">' + icon + '</span>' : icon;
}
return escapeHtml(weatherPlaceholderUtils.formatPlaceholderValue(weatherPlaceholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone })));
});
}
function renderWeatherRegion(region, regionContent) {
var location = getWeatherLocation(regionContent && regionContent.weather_location_id);
var snapshot = location && location.responseJson && typeof location.responseJson === 'object' ? location.responseJson : null;
var width = Math.max(1, Math.round(Number(region && region.pixelWidth) || 1));
var height = Math.max(1, Math.round(Number(region && region.pixelHeight) || 1));
var scale = Number(region && region.canvasScale) || 1;
var style = 'width:' + width + 'px;height:' + height + 'px;transform:scale(' + scale + ');transform-origin:top left;overflow:hidden;';
if (!location || !snapshot) return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + '"></div></div>';
var current = snapshot.current || {};
var value = String(regionContent && regionContent.value || '');
if (value) {
var weatherData = withWeatherUnits(snapshot, location);
var fontSize = Math.max(8, Number(regionContent && regionContent.font_size || region && (region.font_size || region.fontSize) || 32) || 32);
return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + 'font-family:Arial,sans-serif;font-size:' + fontSize + 'px;line-height:1.5;">' + renderEditorJsContent(substituteWeatherVariables(value, weatherData)) + '</div></div>';
}
var daily = snapshot.daily || {};
var days = (daily.time || []).slice(0, 7).map(function (day, index) {
return '<div class="weather-region-day"><strong>' + escapeHtml(index === 0 ? 'Today' : String(day).slice(5)) + '</strong><i class="bi ' + weatherIconForCode(daily.weather_code && daily.weather_code[index]) + '"></i><span>' + escapeHtml(daily.temperature_2m_max && daily.temperature_2m_max[index] !== undefined ? daily.temperature_2m_max[index] + '°' : '-') + '</span></div>';
}).join('');
return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + 'padding:3%;font-family:Arial,sans-serif;"><div style="display:flex;align-items:center;justify-content:space-between;"><div><div style="font-size:.8em;opacity:.72;">' + escapeHtml(location.location_label || location.name) + '</div><strong style="font-size:2em;">' + escapeHtml(current.temperature_2m === undefined ? '-' : current.temperature_2m) + '°</strong></div><i class="bi ' + weatherIconForCode(current.weather_code) + '" style="font-size:3em;"></i></div><div style="display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:.35em;margin-top:1em;">' + days + '</div></div></div>';
}
weatherRegistry.register('weather', { renderRegion: renderWeatherRegion });
+41
View File
@@ -0,0 +1,41 @@
// Webpage region rendering for embedded live URLs.
var registry = window.pulsePlayerRegionTypes;
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return normalizeRenderableValue(value.value);
}
if (value.text !== undefined) {
return normalizeRenderableValue(value.text);
}
if (value.url !== undefined) {
return normalizeRenderableValue(value.url);
}
if (value.href !== undefined) {
return normalizeRenderableValue(value.href);
}
if (value.src !== undefined) {
return normalizeRenderableValue(value.src);
}
if (value.content !== undefined) {
return normalizeRenderableValue(value.content);
}
return '';
}
return String(value === undefined || value === null ? '' : value);
}
function renderWebpageRegion(region, regionContent) {
var url = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '').trim();
if (!url) {
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" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe></div>';
}
registry.register('webpage', {
renderRegion: renderWebpageRegion
});
+222 -7
View File
@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const announcementIcons = require('#src/data/announcement-icons');
function mediaKind(mediaPath) {
const ext = path.extname(mediaPath || '').toLowerCase();
@@ -24,6 +25,14 @@ function escapeHtml(value) {
.replace(/'/g, '&#39;');
}
function normalizeStyleAttributeValue(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;quot;/g, '"')
.replace(/&amp;#39;/g, "'");
}
function sanitizeFontFamily(value) {
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
}
@@ -40,7 +49,7 @@ function sanitizeTextColor(value, fallback) {
return fallback || '#000000';
}
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', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
function sanitizeRichTextAttributes(tagName, attrText) {
const allowedAttributes = {
@@ -55,6 +64,10 @@ function sanitizeRichTextAttributes(tagName, attrText) {
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
i: ['class', 'style', 'aria-hidden'],
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
col: ['class', 'style', 'span', 'width'],
colgroup: ['class', 'style', 'span'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
@@ -71,6 +84,14 @@ function sanitizeRichTextAttributes(tagName, attrText) {
return '';
}
if (tagName === 'img') {
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
return '';
}
}
const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
const lowerKey = String(key || '').toLowerCase();
@@ -94,7 +115,7 @@ function sanitizeRichTextAttributes(tagName, attrText) {
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
attrs.push(' ' + lowerKey + '="' + escapeHtml(lowerKey === 'style' ? normalizeStyleAttributeValue(value) : value) + '"');
return '';
});
@@ -235,6 +256,17 @@ function renderEditorJsTable(data) {
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) {
if (value && typeof value === 'object') {
if (Array.isArray(value.blocks)) {
@@ -253,15 +285,48 @@ function renderEditorJsContent(value) {
} catch (_error) {
// fall through to legacy HTML rendering
}
return sanitizeRichText(raw);
return wrapRichTextParagraph(sanitizeRichText(raw));
}
function buildHtmlDocument(html) {
const raw = String(html || '').trim();
if (!raw) {
return '';
}
if (/^<!doctype\b/i.test(raw) || /^<html\b/i.test(raw)) {
return raw;
}
return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + raw + '</body></html>';
}
function renderHtmlRegionContent(value) {
const html = String(value || '').trim();
const html = normalizeRenderableValue(value).trim();
if (!html) {
return '<div class="template-region-placeholder">HTML</div>';
}
return '<iframe class="template-region-html-frame" sandbox="" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager"></iframe>';
return '<iframe class="template-region-html-frame" sandbox="" srcdoc="' + escapeHtml(buildHtmlDocument(html)) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
}
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return normalizeRenderableValue(value.value);
}
if (value.text !== undefined) {
return normalizeRenderableValue(value.text);
}
if (value.html !== undefined) {
return normalizeRenderableValue(value.html);
}
if (value.content !== undefined) {
return normalizeRenderableValue(value.content);
}
return '';
}
return String(value === undefined || value === null ? '' : value);
}
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
@@ -278,12 +343,32 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
const playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html');
const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js');
const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playlist.js');
const playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js');
const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.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 playerOnboardingLandingScriptPath = path.join(__dirname, 'player-onboarding-landing.script.html');
const playerOnboardingFormScriptPath = path.join(__dirname, 'player-onboarding-form.script.html');
const playerRegionScriptDir = path.join(__dirname, 'regions');
const playerOnboardingLandingScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-landing.script.html');
const playerOnboardingFormScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-form.script.html');
let playerPageTemplateCache = null;
let playerClientNameScriptCache = null;
let playerPageOfflineScriptCache = null;
let playerPagePlaylistScriptCache = null;
let playerPageCommandsScriptCache = null;
let playerPageRenderingScriptCache = null;
let playerPagePlaybackScriptCache = null;
let playerAnnouncementTemplatesScriptCache = null;
let playerAnnouncementTemplatesDataScriptCache = null;
let playerPageAnnouncementsScriptCache = null;
let playerPageScriptCache = null;
let playerRegionScriptsCache = null;
let playerOnboardingLandingScriptCache = null;
let playerOnboardingFormScriptCache = null;
@@ -306,10 +391,130 @@ function getPlayerClientNameScript() {
return loadTemplate(playerClientNameScriptPath, playerClientNameScriptCache || (playerClientNameScriptCache = {}));
}
function getPlayerPageOfflineScript() {
return loadTemplate(playerPageOfflineScriptPath, playerPageOfflineScriptCache || (playerPageOfflineScriptCache = {}));
}
function getPlayerPagePlaylistScript() {
return loadTemplate(playerPagePlaylistScriptPath, playerPagePlaylistScriptCache || (playerPagePlaylistScriptCache = {}));
}
function getPlayerPageCommandsScript() {
return loadTemplate(playerPageCommandsScriptPath, playerPageCommandsScriptCache || (playerPageCommandsScriptCache = {}));
}
function getPlayerPageRenderingScript() {
return loadTemplate(playerPageRenderingScriptPath, playerPageRenderingScriptCache || (playerPageRenderingScriptCache = {}));
}
function getPlayerPagePlaybackScript() {
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_CATALOG_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() {
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() {
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;
}).join('|');
if (playerRegionScriptsCache && playerRegionScriptsCache.signature === statSignature) {
return playerRegionScriptsCache.value;
}
const value = scriptPaths.map(function (filePath) {
return fs.readFileSync(filePath, 'utf8').trim();
}).join('\n\n');
playerRegionScriptsCache = {
signature: statSignature,
value: value
};
return value;
}
function getPlayerOnboardingLandingScript() {
return loadTemplate(playerOnboardingLandingScriptPath, playerOnboardingLandingScriptCache || (playerOnboardingLandingScriptCache = {}));
}
@@ -339,7 +544,17 @@ module.exports = {
loadTemplate: loadTemplate,
getPlayerPageTemplate: getPlayerPageTemplate,
getPlayerClientNameScript: getPlayerClientNameScript,
getPlayerPageOfflineScript: getPlayerPageOfflineScript,
getPlayerPagePlaylistScript: getPlayerPagePlaylistScript,
getPlayerPageCommandsScript: getPlayerPageCommandsScript,
getPlayerPageRenderingScript: getPlayerPageRenderingScript,
getPlayerPagePlaybackScript: getPlayerPagePlaybackScript,
getAnnouncementIconsDataScript: getAnnouncementIconsDataScript,
getPlayerAnnouncementTemplatesDataScript: getPlayerAnnouncementTemplatesDataScript,
getPlayerAnnouncementTemplatesScript: getPlayerAnnouncementTemplatesScript,
getPlayerPageAnnouncementsScript: getPlayerPageAnnouncementsScript,
getPlayerPageScript: getPlayerPageScript,
getPlayerRegionScripts: getPlayerRegionScripts,
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript
};
+75 -29
View File
@@ -1,43 +1,66 @@
// Player page rendering and bootstrap script assembly.
const Handlebars = require('handlebars');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageScript, 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 { 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) {
const stylesheetLinks = Array.isArray(options && options.stylesheets) ? options.stylesheets : [];
return template({
TITLE: options.title,
BODY_CLASS: options.bodyClass || '',
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 || '')
});
}
function renderOnboardingLandingBody() {
function getPlayerServiceWorkerRegistrationScript() {
return [
'<main class="onboarding-shell">',
' <section class="onboarding-card onboarding-card--landing">',
' <p class="onboarding-kicker">Pulse Signage</p>',
' <h1>Onboard this player</h1>',
' <p class="onboarding-copy">Choose an existing screen, name the client, and either scan the QR code or finish right here with a keyboard and mouse.</p>',
'<script>',
' if ("serviceWorker" in navigator) {',
' window.addEventListener("load", function () {',
' navigator.serviceWorker.register("/sw.js?v=38").catch(function () {',
' return null;',
' });',
' });',
' }',
'</script>'
].join('');
}
function renderOnboardingLandingBody(options) {
const onboardingCode = String(options && options.pairingCode || '').trim();
const deviceId = String(options && options.deviceId || '').trim();
return [
' <main id="onboarding-shell" class="onboarding-shell">',
' <section class="onboarding-stage onboarding-stage--landing">',
' <div class="onboarding-layout">',
' <div class="onboarding-qr-pane">',
' <div class="onboarding-qr-frame">',
' <img id="onboarding-qr" alt="Onboarding QR code" />',
' <img id="onboarding-qr" alt="Onboarding QR code" src="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E" />',
' </div>',
' <p class="onboarding-qr-caption">Scan this QR code with your phone</p>',
' </div>',
' <div class="onboarding-copy-panel">',
' <p class="onboarding-kicker onboarding-brand-title">Pulse Signage</p>',
' <h1>Quickly set up with your phone</h1>',
' <ol class="onboarding-instructions">',
' <li>Open the camera and scan the QR code.</li>',
' <li>Log in to Pulse Signage and choose a screen.</li>',
' </ol>',
' <div class="onboarding-pin-block">',
' <span class="onboarding-pin-label">Pairing Code</span>',
' <strong id="onboarding-pairing-code" class="onboarding-pin">' + Handlebars.escapeExpression(onboardingCode || 'Loading...') + '</strong>',
' </div>',
' <div id="onboarding-status" class="onboarding-status">Preparing onboarding link...</div>',
' </div>',
' <form id="onboarding-local-form" class="onboarding-form onboarding-form--local">',
' <label>',
' <span>Client name</span>',
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" autocomplete="off" />',
' </label>',
' <label>',
' <span>Screen</span>',
' <select name="screenSlug" id="onboarding-screen-select" required>',
' <option value="">Loading screens...</option>',
' </select>',
' </label>',
' <button type="submit">Save client</button>',
' <div id="onboarding-message" class="onboarding-status"></div>',
' </form>',
' </div>',
' </section>',
'</main>'
@@ -53,6 +76,10 @@ function renderOnboardingFormBody(deviceId) {
' <p class="onboarding-copy">Pick an existing screen and give this player a friendly name that will persist after refreshes.</p>',
' <form id="onboarding-form" class="onboarding-form">',
' <label>',
' <span>Pairing code</span>',
' <input name="pairingCode" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autocomplete="one-time-code" placeholder="Enter the code shown on the kiosk" />',
' </label>',
' <label>',
' <span>Client name</span>',
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" />',
' </label>',
@@ -84,34 +111,53 @@ function renderOnboardingFormScript(deviceId) {
function renderPlayerPage(slug, initialData) {
const onboardingScript = getPlayerClientNameScript()();
const offlineScript = getPlayerPageOfflineScript()();
const playlistScript = getPlayerPagePlaylistScript()();
const commandScript = getPlayerPageCommandsScript()();
const renderingScript = getPlayerPageRenderingScript()();
const playbackScript = getPlayerPagePlaybackScript()();
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript();
const template = getPlayerPageTemplate();
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
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()({
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)),
REGION_SCRIPTS: new Handlebars.SafeString(getPlayerRegionScripts())
});
return renderPage(template, {
title: 'Screen ' + slug,
bodyClass: bodyClass,
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
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(options) {
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding' });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
return renderPage(getPlayerPageTemplate(), {
title: 'Onboard player',
bodyClass: 'onboarding-page',
body: renderOnboardingLandingBody(),
script: renderOnboardingLandingScript()
body: renderOnboardingLandingBody(options),
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript()
});
}
function renderPlayerOnboardingFormPage(deviceId) {
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding', deviceId: String(deviceId || '').trim() });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
return renderPage(getPlayerPageTemplate(), {
title: 'Onboard screen',
bodyClass: 'onboarding-page',
body: renderOnboardingFormBody(deviceId),
script: renderOnboardingFormScript(deviceId)
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingFormScript(deviceId)
});
}
+567 -42
View File
@@ -1,50 +1,306 @@
// Player route registration and filesystem-backed media helpers.
const fs = require('fs');
const express = require('express');
const path = require('path');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { getPlayerPublicBaseUrl } = require('./onboarding');
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
function isTransientDbError(error) {
return Boolean(error && TRANSIENT_DB_ERROR_CODES.indexOf(String(error.code || '').trim()) !== -1);
}
function isBridgeFetchError(error) {
const message = String(error && error.message || '').toLowerCase();
return Boolean(error && (
message.indexOf('fetch failed') !== -1 ||
message.indexOf('network error') !== -1 ||
message.indexOf('econnreset') !== -1 ||
message.indexOf('econnrefused') !== -1 ||
message.indexOf('enotfound') !== -1
));
}
function registerPlayerRoutes(app, options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null;
const uploadDir = options && options.uploadDir ? options.uploadDir : null;
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
const assetDir = options && options.assetDir ? options.assetDir : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
const onPlayerPublicBaseUrl = typeof options.onPlayerPublicBaseUrl === 'function' ? options.onPlayerPublicBaseUrl : null;
if (!app || !pool || !common || !uploadDir || !assetDir || !playerRuntime || !playerPlaylistService) {
throw new Error('registerPlayerRoutes requires app, pool, common, uploadDir, assetDir, playerRuntime, and playerPlaylistService.');
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.');
}
if (!bridgeBaseUrl && (!pool || !playerPlaylistService)) {
throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless bridgeBaseUrl is configured.');
}
const sharedSecret = getSharedSecret();
async function fetchBridge(req, pathname, options) {
if (!bridgeBaseUrl) {
return null;
}
const requestOptions = options && typeof options === 'object' ? options : {};
const method = String(requestOptions.method || req.method || 'GET').trim().toUpperCase();
const body = Object.prototype.hasOwnProperty.call(requestOptions, 'body') ? requestOptions.body : undefined;
const requestPathname = String(pathname || '').split('?')[0];
const headers = Object.assign({}, requestOptions.headers || {}, createRequestAuthHeaders({
method: method,
pathname: requestPathname,
body: body
}));
const requestHeaders = req && req.headers ? req.headers : {};
if (requestHeaders['x-pulse-page-auth']) {
headers['x-pulse-page-auth'] = String(requestHeaders['x-pulse-page-auth']).trim();
}
if (requestHeaders['if-none-match']) {
headers['if-none-match'] = String(requestHeaders['if-none-match']).trim();
}
if (requestHeaders['x-pulse-client-id']) {
headers['x-pulse-client-id'] = String(requestHeaders['x-pulse-client-id']).trim();
}
if (requestOptions.contentType) {
headers['content-type'] = requestOptions.contentType;
}
return fetch(new URL(pathname, bridgeBaseUrl).toString(), {
method: method,
headers: headers,
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
});
}
async function readJsonResponse(response) {
if (!response) {
return null;
}
const contentType = String(response.headers && typeof response.headers.get === 'function' ? response.headers.get('content-type') : '').toLowerCase();
if (contentType.indexOf('application/json') === -1 && contentType.indexOf('+json') === -1) {
return null;
}
try {
return await response.json();
} catch (_error) {
return null;
}
}
async function getBoundScreenSlug(req) {
if (!playerDeviceId) {
return null;
}
const clientId = String(req.query && req.query.clientId || '').trim();
if (!clientId) {
return null;
}
const bindingId = clientId;
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(bindingId), {
method: 'GET'
});
const status = await readJsonResponse(response);
return status && status.screenSlug ? String(status.screenSlug).trim() : null;
}
const [rows] = await pool.query(
`SELECT s.slug
FROM d_onboarding_devices d
JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?`,
[bindingId]
);
return rows[0] && rows[0].slug ? String(rows[0].slug).trim() : null;
}
async function isClientAuthorizedForScreen(req, requestedSlug) {
const clientId = String(req.headers['x-pulse-client-id'] || '').trim();
if (!clientId) {
return false;
}
const boundSlug = await getBoundScreenSlug({ query: { clientId: clientId } });
return boundSlug === String(requestedSlug || '').trim();
}
function isAuthorizedScreenMove(req, requestedSlug) {
const queryToken = String(req.query && req.query.moveToken || '').trim();
const cookieHeader = String(req.headers && req.headers.cookie || '');
const cookieToken = cookieHeader.split(';').map(function (part) {
const separator = part.indexOf('=');
return separator === -1 ? null : [part.slice(0, separator).trim(), part.slice(separator + 1).trim()];
}).filter(Boolean).find(function (entry) { return entry[0] === 'pulse-screen-move'; });
const token = queryToken || (cookieToken ? decodeURIComponent(cookieToken[1]) : '');
if (!token) {
return false;
}
const payload = verifyPageAuthToken(token);
return Boolean(payload
&& String(payload.scope || '').trim() === 'screen-move'
&& String(payload.playerId || '').trim() === String(playerDeviceId || '').trim()
&& String(payload.screenSlug || '').trim() === String(requestedSlug || '').trim());
}
app.post('/api/screen-move-authorize', express.json(), function (req, res) {
const token = String(req.body && req.body.moveToken || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload
|| String(payload.scope || '').trim() !== 'screen-move'
|| String(payload.playerId || '').trim() !== String(playerDeviceId || '').trim()) {
return res.status(401).json({ error: 'Invalid screen move authorization.' });
}
res.setHeader('Set-Cookie', `pulse-screen-move=${encodeURIComponent(token)}; Max-Age=60; Path=/; HttpOnly; SameSite=Lax`);
return res.json({ ok: true });
});
function requirePageAuth(allowedScopes) {
return function (req, res, next) {
if (!sharedSecret) {
return next();
}
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload) {
return res.status(401).json({ error: 'Page authentication required.' });
}
const scopes = Array.isArray(allowedScopes) ? allowedScopes : [];
if (scopes.length && scopes.indexOf(String(payload.scope || '').trim()) === -1) {
return res.status(403).json({ error: 'Page authentication scope is not allowed for this route.' });
}
req.playerPageAuth = payload;
next();
};
}
function requireRequestAuth(req, res, next) {
if (!sharedSecret) {
return next();
}
if (!verifyRequestAuth(req)) {
return res.status(401).json({ error: 'Request authentication required.' });
}
next();
}
function resolveMediaFilePath(fileName) {
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return null;
}
const resolvedMediaDir = path.resolve(mediaDir);
const resolvedFilePath = path.resolve(mediaDir, relativePath);
if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) {
return null;
}
return resolvedFilePath;
}
app.use('/assets', express.static(assetDir));
app.use('/uploads', express.static(uploadDir));
app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons')));
app.use('/media', express.static(mediaDir));
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
app.get('/api/uploads/config', function (_req, res) {
res.json({
uploadDir: uploadDir
});
app.get('/sw.js', function (_req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.type('application/javascript');
res.sendFile(path.join(__dirname, 'public', 'sw.js'));
});
app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
app.post('/api/auth/page', function (req, res, next) {
try {
const filename = require('path').basename(String(req.params.filename || '').trim());
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
if (!sharedSecret) {
return res.status(404).json({ error: 'Page authentication is disabled.' });
}
const filePath = require('path').join(uploadDir, filename);
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
await fs.promises.mkdir(uploadDir, { recursive: true });
await fs.promises.writeFile(filePath, body);
res.json({ ok: true, filename: filename });
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload || ['player', 'onboarding'].indexOf(String(payload.scope || '').trim()) === -1) {
return res.status(401).json({ error: 'Page authentication required.' });
}
const tokenBundle = createPageAuthBundle({
scope: payload.scope,
slug: payload.slug || null,
deviceId: payload.deviceId || null
});
res.json(tokenBundle);
} catch (error) {
next(error);
}
});
app.delete('/api/uploads/:filename', async function (req, res, next) {
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
if (bridgeBaseUrl) {
void fetch(new URL('/api/media/config', bridgeBaseUrl).toString(), {
method: 'GET',
headers: createRequestAuthHeaders({
method: 'GET',
pathname: '/api/media/config'
})
}).then(async function (response) {
res.status(response.status);
const contentType = response.headers.get('content-type');
if (contentType) {
res.type(contentType);
}
res.send(await response.text());
}).catch(function (_error) {
res.status(502).json({ error: 'Thin client unavailable.' });
});
return;
}
res.json({
mediaDir: mediaDir,
uploadDir: path.join(mediaDir, 'uploads')
});
});
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
try {
const filename = require('path').basename(String(req.params.filename || '').trim());
if (!filename) {
const filePath = resolveMediaFilePath(req.params.filename);
if (!filePath) {
return res.status(400).json({ error: 'Filename is required' });
}
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, body);
res.json({ ok: true, filename: req.params.filename });
} catch (error) {
next(error);
}
});
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
try {
const filePath = resolveMediaFilePath(req.params.filename);
if (!filePath) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = require('path').join(uploadDir, filename);
try {
await fs.promises.unlink(filePath);
} catch (error) {
@@ -52,25 +308,196 @@ function registerPlayerRoutes(app, options) {
throw error;
}
}
res.json({ ok: true, filename: filename });
res.json({ ok: true, filename: req.params.filename });
} catch (error) {
next(error);
}
});
app.get('/screen/:slug', function (req, res) {
app.get('/api/rtmp/session', requirePageAuth(['player']), async function (req, res, next) {
try {
const source = String(req.query.source || '').trim();
const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase();
const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on';
const status = await rtmpStreamService.getSessionStatus(source, useMutedOutput);
const session = status.session;
const ready = Boolean(status.ready);
const live = Boolean(status.live);
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
if (!ready || !live) {
return res.status(503).json({
ready: false,
live: false,
timedOut: Boolean(status.timedOut),
stderr: String(status.stderr || '')
});
}
res.json({
key: session.key,
playlistUrl: session.playlistUrl,
disableAudio: session.disableAudio,
ready: true,
live: true
});
} catch (error) {
next(error);
}
});
app.get('/api/rtmp/streams/:key/index.m3u8', async function (req, res, next) {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const manifestPath = await rtmpStreamService.getManifestFilePath(req.params.key);
if (!manifestPath) {
return res.status(404).send('Stream not found');
}
res.sendFile(manifestPath);
} catch (error) {
next(error);
}
});
app.get('/api/rtmp/streams/:key/:fileName', async function (req, res, next) {
try {
const segmentPath = await rtmpStreamService.getSegmentFilePath(req.params.key, req.params.fileName);
if (!segmentPath) {
return res.status(404).send('Stream not found');
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.sendFile(segmentPath);
} catch (error) {
next(error);
}
});
app.get('/screen/:slug', async function (req, res, next) {
if (onPlayerPublicBaseUrl) {
try {
onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null));
} catch (_error) {
}
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
if (bridgeBaseUrl) {
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(req.params.slug || '').trim() }).token;
void fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), {
method: 'GET',
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
}).then(async function (response) {
if (!response || response.status >= 400) {
res.set('X-Player-Offline', '1');
return res.send(common.renderPlayerPage(req.params.slug, null));
}
const data = await readJsonResponse(response);
if (!data) {
res.set('X-Player-Offline', '1');
return res.send(common.renderPlayerPage(req.params.slug, null));
}
res.send(common.renderPlayerPage(req.params.slug, null));
}).catch(function (error) {
if (!isBridgeFetchError(error)) {
console.error(error);
}
res.set('X-Player-Offline', '1');
res.send(common.renderPlayerPage(req.params.slug, null));
});
return;
}
playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) {
res.send(common.renderPlayerPage(req.params.slug, data));
res.send(common.renderPlayerPage(req.params.slug, null));
}).catch(function (error) {
console.error(error);
res.status(500).send('Internal server error');
res.set('X-Player-Offline', '1');
res.send(common.renderPlayerPage(req.params.slug, null));
});
});
app.get('/api/screens/:slug/playlist', async function (req, res, next) {
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
try {
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', {
method: 'GET'
});
if (!response) {
return res.status(502).send('Thin client unavailable');
}
res.status(response.status);
res.set('Cache-Control', response.headers.get('cache-control') || 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.type(response.headers.get('content-type') || 'text/html; charset=utf-8');
return res.send(await response.text());
}
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
}
const data = buildThumbnailPreviewData(slide);
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
const rssData = await common.fetchRssFeedsData(pool);
data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
return Object.assign({}, feed, {
items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
})
});
}));
}
if (typeof common.fetchApiSourcesData === 'function') {
const apiData = await common.fetchApiSourcesData(pool);
data.apiSources = (apiData.apiSources || []).map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
});
}
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.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
} catch (error) {
next(error);
}
});
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
try {
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
}
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
method: 'GET'
});
if (!response) {
return res.status(502).json({ error: 'Thin client unavailable.' });
}
res.status(response.status);
const etag = response.headers.get('etag');
const cacheControl = response.headers.get('cache-control');
if (etag) {
res.set('ETag', etag);
}
if (cacheControl) {
res.set('Cache-Control', cacheControl);
}
if (response.status === 304) {
return res.end();
}
res.type(response.headers.get('content-type') || 'application/json');
return res.send(await response.text());
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
if (!data.screen) {
@@ -89,25 +516,100 @@ function registerPlayerRoutes(app, options) {
}
});
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) {
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
}
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
method: 'GET'
});
if (!response) {
return res.status(502).json({ error: 'Thin client unavailable.' });
}
res.status(response.status);
const etag = response.headers.get('etag');
const cacheControl = response.headers.get('cache-control');
if (etag) {
res.set('ETag', etag);
}
if (cacheControl) {
res.set('Cache-Control', cacheControl);
}
if (response.status === 304) {
return res.end();
}
res.type(response.headers.get('content-type') || 'application/json');
return res.send(await response.text());
}
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();
}
const connections = playerRuntime.snapshotConnections(req.params.slug);
res.json({
screen: screenRows[0],
announcement: announcement,
revision: revision
});
} catch (error) {
if (isTransientDbError(error)) {
return res.status(503).json({ error: 'Announcement state unavailable.' });
}
next(error);
}
});
app.post('/api/screens/:slug/announcements/refresh', requireRequestAuth, async function (req, res, next) {
try {
const sent = typeof playerRuntime.broadcastAnnouncementRefresh === 'function'
? playerRuntime.broadcastAnnouncementRefresh(req.params.slug)
: 0;
res.json({ ok: true, screenSlug: req.params.slug, sent: sent });
} catch (error) {
next(error);
}
});
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], requireRequestAuth, async function (req, res, next) {
try {
const connections = playerRuntime.snapshotConnections(req.params.slug);
let screen = null;
let screenLookupFailed = false;
if (pool && typeof pool.query === 'function') {
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null;
} catch (error) {
screenLookupFailed = isTransientDbError(error);
if (!screenLookupFailed) {
throw error;
}
}
}
res.json({
screen: screen,
screenSlug: req.params.slug,
count: connections.length,
connections: connections
connections: connections,
degraded: screenLookupFailed
});
} catch (error) {
next(error);
}
});
app.post('/api/screens/:slug/commands', async function (req, res, next) {
app.post('/api/screens/:slug/commands', requireRequestAuth, async function (req, res, next) {
try {
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
@@ -117,19 +619,33 @@ function registerPlayerRoutes(app, options) {
if (!command) {
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' });
}
const liveConnections = playerRuntime.snapshotConnections(req.params.slug);
const isRedirectCommand = command === 'redirect';
let screenRows = [];
if (!isRedirectCommand) {
[screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
let screen = null;
let screenLookupFailed = false;
if (!isRedirectCommand && pool && typeof pool.query === 'function') {
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null;
} catch (error) {
screenLookupFailed = isTransientDbError(error);
if (!screenLookupFailed) {
throw error;
}
}
}
if (!screen && liveConnections.length) {
screen = {
name: req.params.slug,
slug: req.params.slug
};
}
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: command;
@@ -141,12 +657,21 @@ function registerPlayerRoutes(app, options) {
? await playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
: await playerRuntime.broadcastCommand(req.params.slug, commandPayload);
if (!screen && !screenLookupFailed && !liveConnections.length) {
return res.status(404).json({ error: 'Screen not found' });
}
if (!screen && screenLookupFailed && !liveConnections.length) {
return res.status(503).json({ error: 'Screen metadata unavailable while the database is down.' });
}
res.json({
screen: screenRows[0] || null,
screen: screen,
screenSlug: req.params.slug,
command: command,
connectionId: connectionId || null,
sent: sent
sent: sent,
degraded: screenLookupFailed
});
} catch (error) {
next(error);
+266 -21
View File
@@ -1,9 +1,27 @@
// Player runtime state, websocket connections, and request-auth enforcement.
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
const { isClientNameAvailable } = require('../client-name-check');
const { isClientNameAvailable } = require('#src/data/client-name-check');
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) {
const pool = options && options.pool ? options.pool : null;
const notifySnapshot = typeof options.notifySnapshot === 'function' ? options.notifySnapshot : null;
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
? options.normalizeDeviceId
: function (value) {
@@ -11,8 +29,24 @@ function createPlayerRuntime(options) {
};
const connectionsBySlug = new Map();
const dashboardListenersBySlug = new Map();
const announcementListenersBySlug = new Map();
const wss = new WebSocketServer({ noServer: true });
function hasActiveClientId(clientId, currentConnection) {
const normalizedClientId = String(clientId || '').trim();
if (!normalizedClientId) {
return false;
}
for (const connections of connectionsBySlug.values()) {
for (const connection of connections.values()) {
if (connection !== currentConnection && String(connection.clientId || '').trim() === normalizedClientId) {
return true;
}
}
}
return false;
}
function normalizeClientIp(value) {
const ip = String(value || '').trim();
if (!ip) {
@@ -26,6 +60,104 @@ function createPlayerRuntime(options) {
return ip;
}
function firstHeaderValue(value) {
return String(value || '').trim().split(',')[0].trim();
}
function isPrivateOrReservedIp(ip) {
const normalized = normalizeClientIp(ip);
if (!normalized) {
return true;
}
const lower = normalized.toLowerCase();
if (lower === 'localhost' || lower === '::1') {
return true;
}
if (/^10\./.test(lower) || /^192\.168\./.test(lower) || /^127\./.test(lower) || /^169\.254\./.test(lower)) {
return true;
}
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(lower)) {
return true;
}
if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80:') || lower.startsWith('::ffff:127.')) {
return true;
}
return false;
}
function pickForwardedIp(candidates) {
const normalizedCandidates = Array.isArray(candidates)
? candidates.map(function (candidate) {
return normalizeClientIp(candidate);
}).filter(Boolean)
: [];
const publicCandidate = normalizedCandidates.find(function (candidate) {
return !isPrivateOrReservedIp(candidate);
});
return publicCandidate || normalizedCandidates[0] || null;
}
function resolveRequestIp(request) {
const forwardedFor = String(request && request.headers && request.headers['x-forwarded-for'] || '').split(',');
const forwardedForIp = pickForwardedIp(forwardedFor);
if (forwardedForIp) {
return forwardedForIp;
}
const realIp = pickForwardedIp([firstHeaderValue(request && request.headers && request.headers['x-real-ip'])]);
if (realIp) {
return realIp;
}
const forwarded = firstHeaderValue(request && request.headers && request.headers.forwarded);
if (forwarded) {
const forwardedMatches = Array.from(forwarded.matchAll(/(?:^|,\s*|;\s*)for=(?:"?\[?)([^"\];,\s]+)/gi)).map(function (match) {
return match[1];
});
const forwardedIp = pickForwardedIp(forwardedMatches);
if (forwardedIp) {
return forwardedIp;
}
}
return normalizeClientIp(request && request.socket && request.socket.remoteAddress);
}
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) {
const key = String(slug || '').trim();
if (!key) {
@@ -71,11 +203,33 @@ function createPlayerRuntime(options) {
}
}
function getAnnouncementListenerBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!announcementListenersBySlug.has(key)) {
announcementListenersBySlug.set(key, new Set());
}
return announcementListenersBySlug.get(key);
}
function removeAnnouncementListener(slug, socket) {
const key = String(slug || '').trim();
const bucket = announcementListenersBySlug.get(key);
if (!bucket) {
return;
}
bucket.delete(socket);
if (!bucket.size) {
announcementListenersBySlug.delete(key);
}
}
function buildClientLabel(connection) {
const clientName = String(connection.clientName || '').trim();
const clientId = String(connection.clientId || '').trim();
const userAgent = String(connection.userAgent || '').trim();
const clientIp = String(connection.clientIp || '').trim();
const viewport = connection.viewport && typeof connection.viewport === 'object'
? connection.viewport
: null;
@@ -91,10 +245,6 @@ function createPlayerRuntime(options) {
labelParts.push(`id ${clientId.slice(-6)}`);
}
if (clientIp) {
labelParts.push(clientIp);
}
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
}
@@ -122,13 +272,12 @@ function createPlayerRuntime(options) {
userAgent: connection.userAgent || null,
viewport: connection.viewport || null,
page: connection.page || null,
playerPublicBaseUrl: connection.playerPublicBaseUrl || null,
currentSlide: connection.currentSlide || null,
paused: Boolean(connection.paused),
blackout: Boolean(connection.blackout),
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null,
clientIp: connection.clientIp || null,
remoteAddress: connection.remoteAddress || null,
connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
};
@@ -152,6 +301,10 @@ function createPlayerRuntime(options) {
return allConnections;
}
function snapshotSlugs() {
return Array.from(connectionsBySlug.keys());
}
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections());
}
@@ -159,6 +312,16 @@ function createPlayerRuntime(options) {
function broadcastConnectionSnapshot(slug) {
const key = String(slug || '').trim();
const bucket = dashboardListenersBySlug.get(key);
const connections = snapshotConnections(slug);
if (notifySnapshot) {
try {
notifySnapshot({
slug: key,
connections: connections
});
} catch (_error) {
}
}
if (!bucket || !bucket.size) {
return;
}
@@ -166,7 +329,7 @@ function createPlayerRuntime(options) {
const payload = JSON.stringify({
type: 'snapshot',
slug: key,
connections: snapshotConnections(slug),
connections: connections,
sentAt: new Date().toISOString()
});
@@ -177,6 +340,30 @@ function createPlayerRuntime(options) {
});
}
function broadcastAnnouncementRefresh(slug) {
const key = String(slug || '').trim();
const bucket = announcementListenersBySlug.get(key);
if (!bucket || !bucket.size) {
return 0;
}
const payload = JSON.stringify({
type: 'announcement-refresh',
slug: key,
sentAt: new Date().toISOString()
});
let sent = 0;
bucket.forEach(function (socket) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(payload);
sent += 1;
}
});
return sent;
}
async function sendCommandToConnection(slug, connectionId, commandOrPayload) {
const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket || !bucket.size) {
@@ -234,14 +421,41 @@ function createPlayerRuntime(options) {
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
if (!dashboardMatch && !playerMatch) {
const announcementMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/announcements$/);
if (!dashboardMatch && !playerMatch && !announcementMatch) {
socket.destroy();
return;
}
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]);
if (dashboardMatch) {
if (!verifyRequestAuth(request)) {
socket.destroy();
return;
}
}
if (playerMatch) {
const authToken = readPageAuthToken(request);
const payload = verifyPageAuthToken(authToken);
if (!payload || String(payload.scope || '').trim() !== 'player') {
socket.destroy();
return;
}
}
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.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player');
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : announcementMatch ? 'announcements' : 'player');
});
}
@@ -271,9 +485,30 @@ function createPlayerRuntime(options) {
return;
}
const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]);
const normalizedRemoteAddress = normalizeClientIp(remoteAddress);
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 connectionId = crypto.randomUUID();
const connection = {
id: connectionId,
@@ -287,9 +522,8 @@ function createPlayerRuntime(options) {
page: null,
paused: false,
blackout: false,
clientIp: forwardedFor || normalizedRemoteAddress,
remoteAddress: normalizedRemoteAddress,
label: forwardedFor || normalizedRemoteAddress || 'connected client',
playerPublicBaseUrl: null,
label: 'connected client',
connectedAt: new Date(),
lastSeenAt: new Date()
};
@@ -311,11 +545,17 @@ function createPlayerRuntime(options) {
return;
}
if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) {
if (!payload || payload.type !== 'state') {
return;
}
connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
const nextClientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
if (nextClientId && hasActiveClientId(nextClientId, connection)) {
socket.send(JSON.stringify({ type: 'client-id-conflict' }));
socket.close(4009, 'Client ID is already in use.');
return;
}
connection.clientId = nextClientId;
connection.clientName = payload.clientName ? String(payload.clientName).trim() : connection.clientName;
connection.deviceId = payload.deviceId ? normalizeDeviceId(payload.deviceId) || connection.deviceId : connection.deviceId;
if (!connection.clientName && connection.clientId) {
@@ -324,9 +564,12 @@ function createPlayerRuntime(options) {
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
connection.page = payload.page ? String(payload.page).trim() : connection.page;
const nextPlayerPublicBaseUrl = normalizePlayerPublicBaseUrl(payload.page);
if (nextPlayerPublicBaseUrl && nextPlayerPublicBaseUrl !== connection.playerPublicBaseUrl) {
connection.playerPublicBaseUrl = nextPlayerPublicBaseUrl;
}
connection.paused = Boolean(payload.paused);
connection.blackout = Boolean(payload.blackout);
connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp;
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
id: payload.currentSlide.id || null,
title: payload.currentSlide.title || '',
@@ -355,8 +598,10 @@ function createPlayerRuntime(options) {
return {
installWebsocket: installWebsocket,
broadcastAnnouncementRefresh: broadcastAnnouncementRefresh,
snapshotConnections: snapshotConnections,
snapshotAllConnections: snapshotAllConnections,
snapshotSlugs: snapshotSlugs,
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
sendCommandToConnection: sendCommandToConnection,
broadcastCommand: broadcastCommand
+30
View File
@@ -0,0 +1,30 @@
// Thumbnail preview data and bootstrap script helpers for slide thumbnails.
function buildThumbnailPreviewData(slide) {
return {
thumbnailPreview: true,
screen: {
id: slide.id,
name: slide.title,
slug: 'slide-thumbnail-preview-' + slide.id,
playlist_id: null
},
playlist: {
fade_between_slides: false
},
slides: [slide],
rssFeeds: [],
apiSources: [],
timetableGroups: [],
revision: String(slide.modified_at || slide.id || Date.now())
};
}
function createThumbnailPreviewBootstrapScript(initialData) {
return initialData && initialData.thumbnailPreview ? '<script>window.__pulseThumbnailPreview = true;</script>' : '';
}
module.exports = {
buildThumbnailPreviewData: buildThumbnailPreviewData,
createThumbnailPreviewBootstrapScript: createThumbnailPreviewBootstrapScript
};

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