Compare commits

..
30 Commits
Author SHA1 Message Date
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
146 changed files with 8563 additions and 2013 deletions
+2
View File
@@ -1,5 +1,7 @@
* *
!package.json !package.json
!build/
!build/**
!src/ !src/
!src/** !src/**
!scripts/ !scripts/
+1
View File
@@ -3,6 +3,7 @@
## Versioning and releases ## Versioning and releases
- Treat `package.json` as the source of truth for the application version. - 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.
- When the app version changes, update `CHANGELOG.md` in the same change. - When the app version changes, update `CHANGELOG.md` in the same change.
- Keep database migration versions aligned with the release they actually belong to. - 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. - 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.
+13 -3
View File
@@ -10,6 +10,16 @@ jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: web
image: git.lzstealth.com/lzstealth/pulse-signage-web
dockerfile: ./build/Dockerfile
- name: player
image: git.lzstealth.com/lzstealth/pulse-signage-player
dockerfile: ./build/Dockerfile.player
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -28,18 +38,18 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: git.lzstealth.com/LZStealth/pulse-signage images: ${{ matrix.image }}
tags: | tags: |
type=raw,value=latest type=raw,value=latest
type=ref,event=tag type=ref,event=tag
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}} type=semver,pattern=v{{major}}
type=semver,pattern=v{{major}}.{{minor}}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ${{ matrix.dockerfile }}
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
+185
View File
@@ -2,6 +2,191 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.7.2 - 2026-08-14
### Fixed
- HTML and webpage region previews now normalize object-shaped content before rendering, so the player, thumbnails, and popup preview show the intended iframe content instead of leaking raw objects.
- HTML and webpage preview iframes now size explicitly to the full region bounds in the player, thumbnails, and popup preview.
## 2.7.1 - 2026-08-14
### Changed
- The player page now supports keyboard navigation with arrow keys to move between slides.
## 2.7.0 - 2026-08-14
### Added
- The WYSIWYG editor now supports adding small images.
### Changed
- The WYSIWYG image insertion flow also received a small code cleanup to simplify the related helper logic.
- The default table formatting has been applied.
- Timetable regions now use the renamed helpers end to end in the editor and player, including timezone-aware rendering for timetable entry placeholders.
## 2.6.27 - 2026-08-14
### Fixed
- Scheduled task intervals now display the most appropriate exact unit, such as seconds, minutes, hours, or days, while keeping the sort order numeric.
## 2.6.26 - 2026-08-10
### Changed
- API sources and RSS feeds now accept hours as an update interval unit, and the list and background task scheduling paths now format and convert that unit correctly.
## 2.6.25 - 2026-08-10
### Fixed
- Screen group edit now keeps the slug locked after creation, so existing screen group URLs remain stable.
## 2.6.24 - 2026-08-10
### Fixed
- Deferred playlist updates now keep the current slide index when the next playlist snapshot is applied, so playback no longer jumps back to the first slide mid-cycle.
## 2.6.23 - 2026-08-09
### Fixed
- Dashboard quick-action and kiosk-launcher cards now use row spacing instead of extra card padding, so the layout stays consistent at large widths.
- API and RSS refresh jobs now notify affected player screens only when the refreshed data actually changes, so unchanged polls no longer trigger redundant player refreshes.
## 2.6.22 - 2026-08-09
### Changed
- Startup now logs the previously detected schema version, the current app version, and whether pending migrations exist.
### Fixed
- Direct-to-URL player sessions now generate and persist a stable onboarding device id in session storage, so connected screens can still be moved and renamed independently without going through the onboarding flow first.
- The connected-clients move action now tolerates rows that only have a live connection id, which keeps move operations working for screens that skipped onboarding.
## 2.6.21 - 2026-08-09
### Changed
- Startup now logs the previously detected schema version, the current app version, and whether migrations are required.
- The schema documentation now reflects the timetable table rename, the new `o_app_state` table, and startup version tracking.
- Timetable timezone placeholders now use `tz` for the short zone name and `tz_long` for the full IANA zone.
## 2.6.20 - 2026-08-09
### Changed
- Startup now logs the previously detected schema version, the current app version, and whether migrations are required.
## 2.6.19 - 2026-08-09
### Changed
- Timetable editor helpers, routes, and table layout now use timetable-specific naming and tighter card/table styling.
- Connected clients now sort by client identity fields instead of the old IP-based ordering assumption.
## 2.6.18 - 2026-08-09
### Changed
- Timetable tables were renamed from the old `schedule` names to `timetable` names, and existing databases now rename those tables during migration.
- The timetable group editor now uses timetable-specific naming in its shared helpers and keeps the entries table aligned with the standard admin card/table layout.
## 2.6.17 - 2026-08-09
### Changed
- Existing timetable groups and entries are now migrated to Europe/London, and timetable dates are rewritten to UTC using that source timezone so the wall-clock meaning stays intact.
### Fixed
- New timetable groups now default to Europe/London so the timetable editor and saved data start from the same timezone assumption as the migrated rows.
## 2.6.16 - 2026-08-09
### Changed
- Timetable groups now store an IANA time zone and render their entry datetimes in that timetable time zone, so schedules keep the same wall-clock meaning when they are edited from another country.
### Fixed
- Timetable entry date inputs now round-trip through the timetable time zone instead of the browser locale, so saving from Florida while targeting Germany keeps the intended local times.
## 2.6.15 - 2026-08-08
### Fixed
- Slide update media sync on the player now removes uploads that were removed from the slide, so player storage stays aligned with the current slide content.
- Routine player and player-bridge media upload/delete logs were removed to keep remote player and bridge operation quieter.
- Background task descriptions no longer repeat the player slug when the task key already ends with that player name.
## 2.6.14 - 2026-08-08
### Fixed
- Slide updates and template deletes now fan out media sync work across all live players instead of targeting only one player.
## 2.6.13 - 2026-08-08
### Fixed
- The player bundle now keeps the browser-only QR export dependencies lazy-loaded, so the remote player image no longer needs `puppeteer-core` or `@sparticuz/chromium` at startup.
- Remote player startup sync now falls back to `WEB_INTERNAL_URL` when it is configured, which avoids 404s when the bridge is not the correct sync target.
- The player startup sync and media-write info logs were removed to keep normal remote-player operation quieter.
## 2.6.12 - 2026-08-08
### Fixed
- The Docker build inputs now live under `build/`, with separate web and player package manifests so the player image no longer carries the web browser tooling bundle.
- The Docker publish workflow now restores `v2` and `v2.2` style image tags alongside `latest` and the full release tag.
- Remote player sync and upload routing now carry the exact connected player device id through the bridge, so startup media and font jobs target the correct player instance in split-device deployments.
- Player websocket registration now learns the public base URL from the actual screen request origin, so localhost and 127.0.0.1 aliases both keep websocket commands working.
## 2.6.11 - 2026-08-08
### Fixed
- Role edits now save selected permissions from the shared RBAC form, so changes on the role page persist when you submit the form.
- RBAC permission sections now stay open independently in the accordion, so opening one section no longer closes the others.
## 2.6.10 - 2026-08-08
### Fixed
- The onboarding landing page and form no longer restore a previously selected screen, so the screen picker always starts clean while still keeping the saved client name.
## 2.6.9 - 2026-08-08
### Fixed
- The connected-clients view no longer exposes or sorts by client IP, so the table stays focused on the player identity, screen, and playback state.
- The connected-clients dashboard row renderer now keeps the actions column aligned after removing the IP column, so row updates no longer append a duplicate actions cell.
## 2.6.8 - 2026-08-08
### Fixed
- The screen-group command buttons stay disabled until a real target group is selected, so the placeholder "Select Screen Group" state cannot send commands.
## 2.6.7 - 2026-08-08
### Fixed
- Admin client commands now stay on the bridge for remote players, so screen control no longer depends on a public player address.
- The connected-clients screen-group controls now use the same async bulk-command path as the dashboard, including the All Screens option and live pause/blackout toggles.
## 2.6.6 - 2026-08-07
### Fixed
- Timetable entry editing now validates end times locally, requires the end to be at least one minute after the start, and highlights the end field when the value is invalid.
## 2.6.5 - 2026-08-07 ## 2.6.5 - 2026-08-07
### Added ### Added
-20
View File
@@ -1,20 +0,0 @@
FROM node:24-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg chromium nss freetype harfbuzz ttf-freefont
COPY package*.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
COPY scripts ./scripts
RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css
RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css
RUN mkdir -p /app/media
EXPOSE 3000
CMD ["npm", "run", "start:web"]
+1
View File
@@ -32,6 +32,7 @@ If you want the details, start with the [Compose guide](docker-compose/README.md
- [Database schema](docs/schema.md) - the tables and data model the app maintains. - [Database schema](docs/schema.md) - the tables and data model the app maintains.
- [WebSocket reference](docs/websocket.md) - the live player and snapshot channels. - [WebSocket reference](docs/websocket.md) - the live player and snapshot channels.
- [Compose guide](docker-compose/README.md) - deployment options and service layout. - [Compose guide](docker-compose/README.md) - deployment options and service layout.
- [Changelog](CHANGELOG.md) - release history and notable changes.
## Explore The Docs ## Explore The Docs
+17
View File
@@ -0,0 +1,17 @@
FROM node:24-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 src ./src
COPY scripts ./scripts
RUN mkdir -p /app/media
EXPOSE 3000
CMD ["npm", "run", "start:web"]
+17
View File
@@ -0,0 +1,17 @@
FROM node:24-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 src ./src
COPY scripts ./scripts
RUN mkdir -p /app/media
EXPOSE 3000
CMD ["npm", "run", "start:player"]
+19
View File
@@ -0,0 +1,19 @@
{
"name": "pulse-signage-player",
"version": "2.7.2",
"private": false,
"description": "Pulse Signage player application bundle",
"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": "^4.21.2",
"handlebars": "^4.7.8",
"hls.js": "^1.5.15",
"mysql2": "^3.14.3",
"ws": "^8.21.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "pulse-signage-web",
"version": "2.7.2",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"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": "^137.0.0",
"dotenv": "^17.4.2",
"express": "^4.21.2",
"handlebars": "^4.7.8",
"multer": "^2.2.0",
"mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0",
"sharp": "^0.35.3",
"ws": "^8.21.0"
}
}
+7 -6
View File
@@ -1,5 +1,6 @@
# Shared application settings # Shared application settings
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest" 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="" PULSE_SIGNAGE_SHARED_SECRET=""
# Database settings for the web, player, and bridge services # Database settings for the web, player, and bridge services
@@ -12,15 +13,15 @@ MYSQL_ROOT_PASSWORD="root_password"
# Player settings # Player settings
PLAYER_IDENTIFIER="player-local" PLAYER_IDENTIFIER="player-local"
PLAYER_PUBLIC_BASE_URL="http://localhost:8081" PLAYER_PUBLIC_URL="http://localhost:8081"
PLAYER_INTERNAL_BASE_URL="http://player:8081" PLAYER_INTERNAL_URL="http://player:8081"
# Web app bootstrap settings # Web app bootstrap settings
SESSION_MAX_AGE_DAYS=14 SESSION_MAX_AGE_DAYS=14
DEFAULT_ADMIN_USERNAME="admin" DEFAULT_ADMIN_USERNAME="admin"
DEFAULT_ADMIN_NAME="Admin" DEFAULT_ADMIN_NAME="Admin"
DEFAULT_ADMIN_PASSWORD="admin" DEFAULT_ADMIN_PASSWORD="password123"
PASSWORD_HASH_ITERATIONS=310000
# Bridge settings for the player-bridge service # Bridge settings for the player-bridge service
WEB_BASE_URL="http://web:8080" WEB_INTERNAL_URL="http://web:8080"
BRIDGE_INTERNAL_URL="http://player-bridge:8090"
+3 -3
View File
@@ -1,11 +1,11 @@
# Shared application settings # Shared application settings
PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest" PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:latest"
PULSE_SIGNAGE_SHARED_SECRET="" PULSE_SIGNAGE_SHARED_SECRET=""
# Player settings # Player settings
PLAYER_IDENTIFIER="player-remote" PLAYER_IDENTIFIER="player-remote"
PLAYER_PUBLIC_BASE_URL="http://localhost:8081" PLAYER_PUBLIC_URL="http://localhost:8081"
PLAYER_AGENT_RECONNECT_DELAY_MS=5000 PLAYER_AGENT_RECONNECT_DELAY_MS=5000
# Remote player connectivity settings # Remote player connectivity settings
THIN_CLIENT_BASE_URL="http://192.168.0.80:8090" BRIDGE_PUBLIC_URL="http://player-bridge.example.com:8090"
+28 -17
View File
@@ -59,15 +59,16 @@ Responsibilities:
- serves the player UI on port `8081` - serves the player UI on port `8081`
- connects to MySQL in local mode - connects to MySQL in local mode
- connects to the bridge in remote mode through `THIN_CLIENT_BASE_URL` - connects to the bridge in remote mode through `BRIDGE_PUBLIC_URL`
- registers live connections and accepts control commands - registers live connections and accepts control commands
Key configuration: Key configuration:
- `PLAYER_PUBLIC_BASE_URL` - `PLAYER_PUBLIC_URL`
- `PLAYER_INTERNAL_BASE_URL` - `PLAYER_INTERNAL_URL`
- `BRIDGE_INTERNAL_URL`
- `PLAYER_IDENTIFIER` - `PLAYER_IDENTIFIER`
- `THIN_CLIENT_BASE_URL` in remote mode - `BRIDGE_PUBLIC_URL` in remote mode
- `PULSE_SIGNAGE_SHARED_SECRET` - `PULSE_SIGNAGE_SHARED_SECRET`
- database settings in local mode - database settings in local mode
@@ -85,7 +86,7 @@ Responsibilities:
Key configuration: Key configuration:
- `PULSE_SIGNAGE_SHARED_SECRET` - `PULSE_SIGNAGE_SHARED_SECRET`
- `WEB_BASE_URL` for the bridge when it should call the web app directly instead of inferring from request headers - `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` - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
### `mysql` ### `mysql`
@@ -112,15 +113,19 @@ Use this file as a starting point for the public compose stack.
Important values: Important values:
- `PULSE_SIGNAGE_IMAGE` - image to run for all app services - `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 - `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests
- `PLAYER_IDENTIFIER` - unique local player identifier - `PLAYER_IDENTIFIER` - unique local player identifier
- `DB_*` - MySQL credentials and database name for the stack - `DB_*` - MySQL credentials and database name for the stack
- `PLAYER_PUBLIC_BASE_URL` - public URL the player advertises - `PLAYER_PUBLIC_URL` - public URL the player advertises
- `PLAYER_INTERNAL_BASE_URL` - internal URL the web app uses for local player calls - `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
- `SESSION_MAX_AGE_DAYS` - dashboard session lifetime - `SESSION_MAX_AGE_DAYS` - dashboard session lifetime
- `DEFAULT_ADMIN_*` - bootstrap admin account values - `DEFAULT_ADMIN_*` - bootstrap admin account values
- `PASSWORD_HASH_ITERATIONS` - password hashing cost - `PASSWORD_HASH_ITERATIONS` - password hashing cost
- `MYSQL_ROOT_PASSWORD` - root password for the local MySQL container
### `.env.remote.example` ### `.env.remote.example`
@@ -128,11 +133,11 @@ Use this file on a remote player device.
Important values: Important values:
- `PULSE_SIGNAGE_IMAGE` - image to run on the device - `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 - `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_IDENTIFIER` - unique remote player identifier
- `PLAYER_PUBLIC_BASE_URL` - public URL for the remote player - `PLAYER_PUBLIC_URL` - public URL for the remote player
- `THIN_CLIENT_BASE_URL` - bridge URL the player connects back to - `BRIDGE_PUBLIC_URL` - bridge URL the player connects back to
- `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent - `PLAYER_AGENT_RECONNECT_DELAY_MS` - reconnect delay for the player agent
### `PULSE_SIGNAGE_SHARED_SECRET` ### `PULSE_SIGNAGE_SHARED_SECRET`
@@ -153,7 +158,8 @@ Leave it blank only if you intentionally want to run without request signing in
| Variable | Used By | Purpose | | Variable | Used By | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `PULSE_SIGNAGE_IMAGE` | web, player, bridge, remote player | Docker image to run for the app services. | | `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. | | `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_HOST` | web, player, bridge | Database host name. |
| `DB_PORT` | web, player, bridge | Database port. | | `DB_PORT` | web, player, bridge | Database port. |
@@ -166,11 +172,16 @@ Leave it blank only if you intentionally want to run without request signing in
| `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. | | `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. |
| `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. | | `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. |
| `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. | | `PASSWORD_HASH_ITERATIONS` | web | Password hashing cost. |
| `PLAYER_INTERNAL_BASE_URL` | web, player | Internal player URL used by the dashboard and player runtime. | | `PLAYER_INTERNAL_URL` | web, player | Internal player URL used by the dashboard and player runtime. |
| `THIN_CLIENT_BASE_URL` | web, player, remote player | URL of the bridge service. | | `BRIDGE_INTERNAL_URL` | web | Bridge URL used by the web app for player snapshot and command forwarding. |
| `PLAYER_PUBLIC_BASE_URL` | player, remote player | Public URL advertised by the player. | | `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_IDENTIFIER` | player | Stable player identifier. |
| `PLAYER_AGENT_RECONNECT_DELAY_MS` | remote player | Delay before reconnecting to the bridge. | | `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 ## Ports
@@ -208,8 +219,8 @@ Each compose file creates its own named network:
- The public stack expects the app services and MySQL to share the same `PULSE_SIGNAGE_SHARED_SECRET`. - 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. - 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 bridge service is the dashboard-facing command path for connected remote players.
- The remote player should point `THIN_CLIENT_BASE_URL` at the bridge, not at the public web endpoint. - The remote player should point `BRIDGE_PUBLIC_URL` at the bridge, not at the public web endpoint.
- The `PULSE_SIGNAGE_IMAGE` tag defaults to the published image, but it can be overridden for local builds or custom releases. - 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 ## Recommended Setup
+7 -7
View File
@@ -3,21 +3,21 @@ name: pulse-signage-remote
services: services:
player: player:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest}
restart: unless-stopped restart: unless-stopped
networks:
- pulse_signage
ports: ports:
- "8081:8081" - "8081:8081"
environment: environment:
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-remote}
THIN_CLIENT_BASE_URL: ${THIN_CLIENT_BASE_URL:-} PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
BRIDGE_PUBLIC_URL: ${BRIDGE_PUBLIC_URL:-}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
PLAYER_AGENT_RECONNECT_DELAY_MS: ${PLAYER_AGENT_RECONNECT_DELAY_MS:-5000}
volumes: volumes:
- pulse-signage:/app/media - pulse-signage:/app/media
command: ["node", "src/player.js"] command: ["node", "src/player.js"]
networks:
- pulse_signage
volumes: volumes:
pulse-signage: pulse-signage:
+14 -14
View File
@@ -2,8 +2,10 @@ name: pulse-signage
services: services:
web: web:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest}
restart: unless-stopped restart: unless-stopped
networks:
- pulse_signage
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
@@ -13,28 +15,28 @@ services:
DB_USER: ${DB_USER:-pulse-signage} DB_USER: ${DB_USER:-pulse-signage}
DB_PASSWORD: ${DB_PASSWORD:-signage_password} DB_PASSWORD: ${DB_PASSWORD:-signage_password}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090}
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14} SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin} DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin} DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin} DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-password123}
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
volumes: volumes:
- pulse-signage:/app/media - pulse-signage:/app/media
command: ["node", "src/web.js"] command: ["node", "src/web.js"]
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
networks:
- pulse_signage
player: player:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest}
restart: unless-stopped restart: unless-stopped
networks:
- pulse_signage
ports: ports:
- "8081:8081" - "8081:8081"
environment: environment:
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} PLAYER_PUBLIC_URL: ${PLAYER_PUBLIC_URL:-http://localhost:8081}
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081} PLAYER_INTERNAL_URL: ${PLAYER_INTERNAL_URL:-http://player:8081}
PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local} PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql} DB_HOST: ${DB_HOST:-mysql}
@@ -48,16 +50,16 @@ services:
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
networks:
- pulse_signage
player-bridge: player-bridge:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest}
restart: unless-stopped restart: unless-stopped
networks:
- pulse_signage
ports: ports:
- "8090:8090" - "8090:8090"
environment: environment:
WEB_BASE_URL: ${WEB_BASE_URL:-http://web:8080} WEB_INTERNAL_URL: ${WEB_INTERNAL_URL:-http://web:8080}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql} DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306} DB_PORT: ${DB_PORT:-3306}
@@ -68,8 +70,6 @@ services:
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
networks:
- pulse_signage
mysql: mysql:
image: mysql:8.4 image: mysql:8.4
+20 -10
View File
@@ -165,8 +165,8 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
- `i_rss_feeds` - RSS feed definitions and refresh cadence. - `i_rss_feeds` - RSS feed definitions and refresh cadence.
- `i_rss_feed_items` - cached RSS feed items. - `i_rss_feed_items` - cached RSS feed items.
- `i_api_sources` - API source definitions and last response snapshot. - `i_api_sources` - API source definitions and last response snapshot.
- `i_schedule_groups` - grouped schedule definitions used by the schedule region. - `i_timetable_groups` - grouped timetable definitions used by the timetable region.
- `i_schedule_entries` - dated entries that belong to a schedule group. - `i_timetable_entries` - dated entries that belong to a timetable group.
### `i_rss_feeds` ### `i_rss_feeds`
@@ -184,32 +184,40 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
- `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` - `id`, `name`, `api_url`, `auth_method`, `auth_username`, `auth_password`, `auth_bearer_token`, `auth_header_name`, `auth_header_value`, `items_path`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
### `i_schedule_groups` ### `i_timetable_groups`
- `id`, `name`, `short_description`, `created_at`, `created_by`, `modified_at`, `modified_by` - `id`, `name`, `short_description`, `timezone`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `timezone` defaults to `Europe/London`.
### `i_schedule_entries` ### `i_timetable_entries`
- `id`, `schedule_group_id`, `title`, `short_description`, `start_datetime`, `end_datetime`, `created_at`, `created_by`, `modified_at`, `modified_by` - `id`, `schedule_group_id`, `title`, `short_description`, `start_datetime`, `end_datetime`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key: - Foreign key:
- `schedule_group_id` -> `i_schedule_groups.id` with `ON DELETE CASCADE` - `schedule_group_id` -> `i_timetable_groups.id` with `ON DELETE CASCADE`
- Index: - Index:
- `(schedule_group_id, start_datetime)` - `(schedule_group_id, start_datetime)`
## Operations ## Operations
- `o_background_tasks` - queue and history for background jobs. - `o_background_tasks` - queue and history for background jobs.
- `o_app_state` - generic app state and version markers stored as key/value pairs.
### `o_background_tasks` ### `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` - `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`. - Indexed by `status`, `task_key`, and `task_type`.
### `o_app_state`
- `state_key`, `state_value`, `created_at`, `modified_at`
- `state_key` is the primary key.
- `schema_version` is stored here so startup can detect the previously recorded schema version before deciding whether migrations need to run.
## Notes ## Notes
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database. - 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/bootstrap.js` seeds the canvas size defaults, default permissions, and the default administrator role.
- The migration module stays in place for future releases, but this version treats the current schema as the install baseline. - `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 ```mermaid
erDiagram erDiagram
@@ -255,9 +263,11 @@ erDiagram
} }
I_API_SOURCES { I_API_SOURCES {
} }
I_SCHEDULE_GROUPS { I_TIMETABLE_GROUPS {
} }
I_SCHEDULE_ENTRIES { I_TIMETABLE_ENTRIES {
}
O_APP_STATE {
} }
O_BACKGROUND_TASKS { O_BACKGROUND_TASKS {
} }
@@ -282,5 +292,5 @@ erDiagram
D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
I_SCHEDULE_GROUPS ||--o{ I_SCHEDULE_ENTRIES : contains I_TIMETABLE_GROUPS ||--o{ I_TIMETABLE_ENTRIES : contains
``` ```
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.5.9", "version": "2.6.15",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.5.9", "version": "2.6.15",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^137.0.0", "@sparticuz/chromium": "^137.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.6.5", "version": "2.7.2",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
+4 -1
View File
@@ -11,7 +11,10 @@ const ITEMS_PATH_MAX_LENGTH = 255;
function normalizeUpdateIntervalUnit(value) { function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase(); const unit = String(value || '').trim().toLowerCase();
return unit === 'seconds' ? 'seconds' : 'minutes'; if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') {
return unit;
}
return 'minutes';
} }
function normalizeAuthMethod(value) { function normalizeAuthMethod(value) {
+1 -1
View File
@@ -4,7 +4,7 @@ const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage,
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements'); 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 { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
const { fetchPlaylistById } = require('./playlists'); const { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules'); const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./timetables');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens'); const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens');
+7 -7
View File
@@ -1,8 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js')); const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
const puppeteer = require('puppeteer-core');
const chromiumModule = require('@sparticuz/chromium');
const QR_PNG_WIDTH = 2048; const QR_PNG_WIDTH = 2048;
const QR_STYLING_SCRIPT_PATH = path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'); const QR_STYLING_SCRIPT_PATH = path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js');
const SYSTEM_CHROMIUM_PATHS = [ const SYSTEM_CHROMIUM_PATHS = [
@@ -13,11 +11,6 @@ const SYSTEM_CHROMIUM_PATHS = [
'/usr/local/bin/chromium', '/usr/local/bin/chromium',
'/snap/bin/chromium' '/snap/bin/chromium'
].filter(Boolean); ].filter(Boolean);
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
? chromiumModule
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
? chromiumModule.default
: chromiumModule;
let qrBrowserPromise = null; let qrBrowserPromise = null;
function escapeXml(value) { function escapeXml(value) {
@@ -353,6 +346,13 @@ function getQrBrowser() {
} }
qrBrowserPromise = (async function () { qrBrowserPromise = (async function () {
const puppeteer = require('puppeteer-core');
const chromiumModule = require('@sparticuz/chromium');
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
? chromiumModule
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
? chromiumModule.default
: chromiumModule;
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) { let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
return fs.existsSync(candidate); return fs.existsSync(candidate);
}) || ''; }) || '';
+4 -1
View File
@@ -9,7 +9,10 @@ const URL_MAX_LENGTH = 1024;
function normalizeUpdateIntervalUnit(value) { function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase(); const unit = String(value || '').trim().toLowerCase();
return unit === 'seconds' ? 'seconds' : 'minutes'; if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') {
return unit;
}
return 'minutes';
} }
async function fetchRssFeedsData(pool) { async function fetchRssFeedsData(pool) {
+112 -6
View File
@@ -6,27 +6,90 @@ const { parseJsonSafe, validateMaxLength } = require('./utils');
const TITLE_MAX_LENGTH = 255; const TITLE_MAX_LENGTH = 255;
const { buildQrCodeContent } = require('./qr-code'); const { buildQrCodeContent } = require('./qr-code');
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
const DEFAULT_FONT_SIZE = 32; const DEFAULT_FONT_SIZE = 32;
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) { function sanitizeRichText(html) {
let output = String(html || ''); let output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, ''); output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, ''); output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, (tag) => { 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) { if (!match) {
return ''; return '';
} }
const closing = Boolean(match[1]); const closing = Boolean(match[1]);
const name = String(match[2] || '').toLowerCase(); const name = String(match[2] || '').toLowerCase();
const attrText = String(match[3] || '');
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) { if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
return ''; return '';
} }
if (name === 'br') { if (closing) {
return '<br>'; return `</${name}>`;
} }
return closing ? `</${name}>` : `<${name}>`; return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`;
}); });
} }
@@ -308,6 +371,49 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
value: submitted === undefined ? String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '') : String(submitted || ''), 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() 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') { } else if (region.region_type === 'rss') {
const submitted = body[`region_text_${region.id}`]; const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
@@ -387,7 +493,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
const style = getTextRegionStyle(body, region, existingContent); const style = getTextRegionStyle(body, region, existingContent);
content[region.region_key] = { content[region.region_key] = {
type: 'text', type: 'text',
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || ''))), value: sanitizeRichText(stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || '')))),
font_family: style.font_family, font_family: style.font_family,
font_size: style.font_size, font_size: style.font_size,
font_color: style.font_color font_color: style.font_color
@@ -4,6 +4,23 @@ const { fetchPagedRows, validateMaxLength } = require('./utils');
const NAME_MAX_LENGTH = 255; const NAME_MAX_LENGTH = 255;
const DESCRIPTION_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) { function normalizeDisplayMode(value) {
const mode = String(value || 'upcoming').trim().toLowerCase(); const mode = String(value || 'upcoming').trim().toLowerCase();
@@ -15,15 +32,15 @@ function normalizeDisplayMode(value) {
async function fetchTimetablesData(pool) { async function fetchTimetablesData(pool) {
const [timetableGroups] = await pool.query(` const [timetableGroups] = await pool.query(`
SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by, SELECT 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_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count, (SELECT COUNT(*) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime (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_schedule_groups g FROM i_timetable_groups g
ORDER BY g.modified_at DESC, g.id DESC ORDER BY g.modified_at DESC, g.id DESC
`); `);
const [timetableEntries] = await pool.query(` 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 SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
FROM i_schedule_entries FROM i_timetable_entries
ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC
`); `);
@@ -50,17 +67,18 @@ async function fetchTimetablesData(pool) {
async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by, 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_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count, (SELECT COUNT(*) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime (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_schedule_groups g FROM i_timetable_groups g
ORDER BY g.modified_at DESC, g.id DESC`, ORDER BY g.modified_at DESC, g.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM i_schedule_groups', countSql: 'SELECT COUNT(*) AS count FROM i_timetable_groups',
searchColumns: ['g.name', 'g.short_description'], searchColumns: ['g.name', 'g.short_description'],
searchTerm: searchTerm, searchTerm: searchTerm,
sortColumns: { sortColumns: {
name: 'g.name', name: 'g.name',
description: 'g.short_description', description: 'g.short_description',
timezone: 'g.timezone',
entries: 'entry_count', entries: 'entry_count',
next_start: 'next_start_datetime', next_start: 'next_start_datetime',
created: 'g.created_at', created: 'g.created_at',
@@ -77,7 +95,7 @@ async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKe
async function fetchTimetableGroupById(pool, id) { async function fetchTimetableGroupById(pool, id) {
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT id, name, short_description, created_at, modified_at, created_by, modified_by FROM i_schedule_groups WHERE id = ?', 'SELECT id, name, short_description, timezone, created_at, modified_at, created_by, modified_by FROM i_timetable_groups WHERE id = ?',
[id] [id]
); );
@@ -87,7 +105,7 @@ async function fetchTimetableGroupById(pool, id) {
async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) { async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) {
const [rows] = await pool.query( 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 `SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
FROM i_schedule_entries FROM i_timetable_entries
WHERE schedule_group_id = ? WHERE schedule_group_id = ?
ORDER BY start_datetime ASC, id ASC`, ORDER BY start_datetime ASC, id ASC`,
[timetableGroupId] [timetableGroupId]
@@ -100,6 +118,7 @@ function buildTimetableGroupPayload(req, existingTimetableGroup) {
const fallback = existingTimetableGroup || {}; const fallback = existingTimetableGroup || {};
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'Timetable group name'); 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 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) { if (!name) {
const error = new Error('Timetable group name is required.'); const error = new Error('Timetable group name is required.');
@@ -109,7 +128,8 @@ function buildTimetableGroupPayload(req, existingTimetableGroup) {
return { return {
name: name, name: name,
shortDescription: shortDescription shortDescription: shortDescription,
timezone: timezone
}; };
} }
@@ -119,5 +139,6 @@ module.exports = {
fetchTimetableGroupsPage, fetchTimetableGroupsPage,
fetchTimetableGroupById, fetchTimetableGroupById,
fetchTimetableEntriesByGroupId, fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload buildTimetableGroupPayload,
normalizeTimeZone
}; };
+16 -6
View File
@@ -1,3 +1,6 @@
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. // Snapshot only: keep this file aligned with the current schema state.
async function ensureSchema(pool, options) { async function ensureSchema(pool, options) {
const schemaLockName = 'pulse_signage_schema_lock'; const schemaLockName = 'pulse_signage_schema_lock';
@@ -12,6 +15,12 @@ async function ensureSchema(pool, options) {
} }
try { 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(` await pool.query(`
CREATE TABLE IF NOT EXISTS c_canvas_sizes ( CREATE TABLE IF NOT EXISTS c_canvas_sizes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -238,10 +247,11 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS i_schedule_groups ( CREATE TABLE IF NOT EXISTS i_timetable_groups (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
short_description VARCHAR(255) NULL, short_description VARCHAR(255) NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'Europe/London',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -250,7 +260,7 @@ async function ensureSchema(pool, options) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS i_schedule_entries ( CREATE TABLE IF NOT EXISTS i_timetable_entries (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
schedule_group_id INT NOT NULL, schedule_group_id INT NOT NULL,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
@@ -261,8 +271,8 @@ async function ensureSchema(pool, options) {
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL, modified_by INT NULL,
CONSTRAINT fk_schedule_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_schedule_groups(id) ON DELETE CASCADE, CONSTRAINT fk_timetable_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_timetable_groups(id) ON DELETE CASCADE,
INDEX idx_schedule_entries_group_start (schedule_group_id, start_datetime) INDEX idx_timetable_entries_group_start (schedule_group_id, start_datetime)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
@@ -383,8 +393,8 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
const { runMigrations } = require('./migrations'); await runMigrations(pool, Object.assign({}, options, { currentVersion: currentVersion }));
await runMigrations(pool, options); await recordSchemaVersion(pool, appVersion);
} finally { } finally {
await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () { await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () {
}); });
+290 -30
View File
@@ -1,4 +1,7 @@
const { version: appVersion } = require('#root/package.json'); const { version: appVersion } = require('#root/package.json');
const TIMETABLE_TIME_ZONE = 'Europe/London';
const APP_STATE_TABLE = 'o_app_state';
const APP_STATE_SCHEMA_VERSION_KEY = 'schema_version';
const VERSIONED_MIGRATIONS = [ const VERSIONED_MIGRATIONS = [
{ {
@@ -22,32 +25,32 @@ const VERSIONED_MIGRATIONS = [
// Store the player pointer on screens so we can resolve the player without needing a player-side screen_id. // Store the player pointer on screens so we can resolve the player without needing a player-side screen_id.
// This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'. // This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'.
if (!(await columnExists(pool, 'd_screens', 'player_id'))) { if (!(await columnExists(pool, 'd_screens', 'player_id'))) {
await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id"); await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
} else { } else {
await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'"); await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'");
const [playerColumnNullableRows] = await pool.query( const [playerColumnNullableRows] = await pool.query(
`SELECT COUNT(*) AS nullable_count `SELECT COUNT(*) AS nullable_count
FROM information_schema.COLUMNS FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'd_screens'
AND COLUMN_NAME = 'player_id'
AND IS_NULLABLE = 'YES'`
);
if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) {
await pool.query("ALTER TABLE d_screens MODIFY COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
}
}
const [screenPlayerUniqueRows] = await pool.query(
`SELECT COUNT(*) AS index_count
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'd_screens' AND TABLE_NAME = 'd_screens'
AND INDEX_NAME = 'uq_screens_player_id'` AND COLUMN_NAME = 'player_id'
AND IS_NULLABLE = 'YES'`
); );
if (Number(screenPlayerUniqueRows && screenPlayerUniqueRows[0] && screenPlayerUniqueRows[0].index_count) > 0) { if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) {
await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id'); await pool.query("ALTER TABLE d_screens MODIFY COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
}
}
const [screenPlayerUniqueRows] = await pool.query(
`SELECT COUNT(*) AS index_count
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'd_screens'
AND INDEX_NAME = 'uq_screens_player_id'`
);
if (Number(screenPlayerUniqueRows && screenPlayerUniqueRows[0] && screenPlayerUniqueRows[0].index_count) > 0) {
await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id');
} }
// Recreate the screen-to-player foreign key after the column exists and legacy data is copied over. // Recreate the screen-to-player foreign key after the column exists and legacy data is copied over.
@@ -56,7 +59,6 @@ const VERSIONED_MIGRATIONS = [
if (await columnExists(pool, 'c_template_regions', 'font_family')) { if (await columnExists(pool, 'c_template_regions', 'font_family')) {
await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family'); await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family');
} }
} }
}, },
{ {
@@ -276,6 +278,7 @@ const VERSIONED_MIGRATIONS = [
await pool.query('RENAME TABLE d_players_rebuild TO d_players'); await pool.query('RENAME TABLE d_players_rebuild TO d_players');
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id'); await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id');
return;
} }
}, },
{ {
@@ -295,6 +298,93 @@ const VERSIONED_MIGRATIONS = [
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id'); await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
await dropColumnIfExists(pool, 'd_screens', 'player_id'); await dropColumnIfExists(pool, 'd_screens', 'player_id');
} }
},
{
version: '2.6.16',
label: 'v2.6.16 timetable timezone schema',
run: async function (pool) {
const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups');
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
const tableName = timetableGroupsExists ? 'i_timetable_groups' : scheduleGroupsExists ? 'i_schedule_groups' : null;
if (!tableName) {
return;
}
await ensureColumn(pool, tableName, 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description');
}
},
{
version: '2.6.17',
label: 'v2.6.17 timetable europe/london conversion',
run: async function (pool) {
const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups');
const timetableEntriesExists = await tableExists(pool, 'i_timetable_entries');
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries');
const groupTableName = timetableGroupsExists ? 'i_timetable_groups' : scheduleGroupsExists ? 'i_schedule_groups' : null;
const entryTableName = timetableEntriesExists ? 'i_timetable_entries' : scheduleEntriesExists ? 'i_schedule_entries' : null;
if (!groupTableName || !entryTableName) {
return;
}
await ensureColumn(pool, groupTableName, 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description');
await pool.query('UPDATE ' + groupTableName + ' SET timezone = ?', [TIMETABLE_TIME_ZONE]);
const [rows] = await pool.query('SELECT id, start_datetime, end_datetime FROM ' + entryTableName + ' ORDER BY id ASC');
for (const row of rows) {
const startDate = convertMigrationDateTimeFromTimeZone(row.start_datetime, TIMETABLE_TIME_ZONE);
const endDate = row.end_datetime ? convertMigrationDateTimeFromTimeZone(row.end_datetime, TIMETABLE_TIME_ZONE) : null;
await pool.query(
'UPDATE ' + entryTableName + ' SET start_datetime = ?, end_datetime = ? WHERE id = ?',
[formatMigrationDateTimeUtc(startDate), endDate ? formatMigrationDateTimeUtc(endDate) : null, row.id]
);
}
}
},
{
version: '2.6.18',
label: 'v2.6.18 timetable table rename',
run: async function (pool) {
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups');
if (scheduleGroupsExists) {
if (!timetableGroupsExists) {
await pool.query('RENAME TABLE i_schedule_groups TO i_timetable_groups, i_schedule_entries TO i_timetable_entries');
await ensureColumn(pool, 'i_timetable_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description');
return;
}
await ensureColumn(pool, 'i_timetable_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description');
await pool.query(`
INSERT IGNORE INTO i_timetable_groups (id, name, short_description, timezone, created_at, created_by, modified_at, modified_by)
SELECT id, name, short_description, 'Europe/London' AS timezone, created_at, created_by, modified_at, modified_by
FROM i_schedule_groups
ORDER BY id ASC
`);
await pool.query(`
INSERT IGNORE INTO i_timetable_entries (id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by)
SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by
FROM i_schedule_entries
ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC
`);
const [groupRows] = await pool.query('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_groups');
const [entryRows] = await pool.query('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_entries');
const nextGroupId = Number(groupRows && groupRows[0] && groupRows[0].max_id) + 1;
const nextEntryId = Number(entryRows && entryRows[0] && entryRows[0].max_id) + 1;
await pool.query('ALTER TABLE i_timetable_groups AUTO_INCREMENT = ' + nextGroupId);
await pool.query('ALTER TABLE i_timetable_entries AUTO_INCREMENT = ' + nextEntryId);
await pool.query('DROP TABLE i_schedule_entries');
await pool.query('DROP TABLE i_schedule_groups');
}
}
} }
]; ];
@@ -311,6 +401,59 @@ async function columnExists(pool, tableName, columnName) {
return Number(rows && rows[0] && rows[0].column_count) > 0; return Number(rows && rows[0] && rows[0].column_count) > 0;
} }
async function tableExists(pool, tableName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS table_count
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?`,
[tableName]
);
return Number(rows && rows[0] && rows[0].table_count) > 0;
}
async function detectSchemaVersion(pool) {
if (await tableExists(pool, APP_STATE_TABLE)) {
const [rows] = await pool.query(
'SELECT state_value FROM ' + APP_STATE_TABLE + ' WHERE state_key = ? LIMIT 1',
[APP_STATE_SCHEMA_VERSION_KEY]
);
const storedVersion = String(rows && rows[0] && rows[0].state_value || '').trim();
if (storedVersion) {
return storedVersion;
}
}
const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups');
const timetableEntriesExists = await tableExists(pool, 'i_timetable_entries');
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries');
if ((timetableGroupsExists || timetableEntriesExists) && !scheduleGroupsExists && !scheduleEntriesExists) {
return '2.6.18';
}
return '0.0.0';
}
async function recordSchemaVersion(pool, version) {
await pool.query(
`CREATE TABLE IF NOT EXISTS ${APP_STATE_TABLE} (
state_key VARCHAR(191) NOT NULL PRIMARY KEY,
state_value MEDIUMTEXT 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(
'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE state_value = VALUES(state_value)',
[APP_STATE_SCHEMA_VERSION_KEY, String(version || appVersion || '0.0.0').trim()]
);
}
async function columnIsAutoIncrement(pool, tableName, columnName) { async function columnIsAutoIncrement(pool, tableName, columnName) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT COUNT(*) AS auto_increment_count `SELECT COUNT(*) AS auto_increment_count
@@ -512,6 +655,107 @@ function formatMigrationDateTime(value) {
return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes; return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes;
} }
function parseMigrationDateTimeParts(value) {
if (!value) {
return null;
}
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) {
return null;
}
return {
year: value.getUTCFullYear(),
month: value.getUTCMonth() + 1,
day: value.getUTCDate(),
hour: value.getUTCHours(),
minute: value.getUTCMinutes(),
second: value.getUTCSeconds()
};
}
const raw = String(value || '').trim();
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/);
if (!match) {
return null;
}
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4] || 0),
minute: Number(match[5] || 0),
second: Number(match[6] || 0)
};
}
function getMigrationTimeZoneOffsetMillis(date, timeZone) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return 0;
}
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: timeZone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).formatToParts(date).reduce(function (acc, part) {
if (part && part.type && part.type !== 'literal') {
acc[part.type] = part.value;
}
return acc;
}, Object.create(null));
const localAsUtc = Date.UTC(
Number(parts.year) || 0,
(Number(parts.month) || 1) - 1,
Number(parts.day) || 1,
Number(parts.hour) || 0,
Number(parts.minute) || 0,
Number(parts.second) || 0,
0
);
return localAsUtc - date.getTime();
}
function convertMigrationDateTimeFromTimeZone(value, timeZone) {
const parts = parseMigrationDateTimeParts(value);
if (!parts) {
return null;
}
const utcMillis = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0);
let adjusted = new Date(utcMillis - getMigrationTimeZoneOffsetMillis(new Date(utcMillis), timeZone));
const adjustedOffset = getMigrationTimeZoneOffsetMillis(adjusted, timeZone);
if (adjustedOffset !== getMigrationTimeZoneOffsetMillis(new Date(utcMillis), timeZone)) {
adjusted = new Date(utcMillis - adjustedOffset);
}
return adjusted;
}
function formatMigrationDateTimeUtc(value) {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return null;
}
const year = value.getUTCFullYear();
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
const day = String(value.getUTCDate()).padStart(2, '0');
const hours = String(value.getUTCHours()).padStart(2, '0');
const minutes = String(value.getUTCMinutes()).padStart(2, '0');
const seconds = String(value.getUTCSeconds()).padStart(2, '0');
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds;
}
function formatMigrationTime(value) { function formatMigrationTime(value) {
if (!value) { if (!value) {
return null; return null;
@@ -562,12 +806,13 @@ function compareVersions(leftVersion, rightVersion) {
return 0; return 0;
} }
async function runMigrations(pool, options) { async function getPendingMigrations(pool, options) {
// Only run migrations that are newer than the installed schema version and not beyond the app version.
const targetVersion = String(appVersion || '0.0.0').trim(); const targetVersion = String(appVersion || '0.0.0').trim();
const currentVersion = String(options && options.currentVersion || '0.0.0').trim(); const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id'); const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id');
const screenPlayerColumnPresent = await columnExists(pool, 'd_screens', 'player_id'); const screenPlayerColumnPresent = await columnExists(pool, 'd_screens', 'player_id');
const legacyTimetableGroupsPresent = await tableExists(pool, 'i_schedule_groups');
const legacyTimetableEntriesPresent = await tableExists(pool, 'i_schedule_entries');
let effectiveCurrentVersion = currentVersion; let effectiveCurrentVersion = currentVersion;
if (!legacyPlayerSchemaPresent && compareVersions(effectiveCurrentVersion, '2.1.0') < 0) { if (!legacyPlayerSchemaPresent && compareVersions(effectiveCurrentVersion, '2.1.0') < 0) {
@@ -578,14 +823,29 @@ async function runMigrations(pool, options) {
effectiveCurrentVersion = '2.6.3'; effectiveCurrentVersion = '2.6.3';
} }
for (const migration of VERSIONED_MIGRATIONS) { if (legacyTimetableGroupsPresent || legacyTimetableEntriesPresent) {
if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) { effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17';
await migration.run(pool); }
}
return VERSIONED_MIGRATIONS.filter(function (migration) {
return compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0;
});
}
async function runMigrations(pool, options) {
// Only run migrations that are newer than the installed schema version and not beyond the app version.
const pendingMigrations = await getPendingMigrations(pool, options);
for (const migration of pendingMigrations) {
await migration.run(pool);
} }
} }
module.exports = { module.exports = {
appVersion: appVersion, appVersion: appVersion,
runMigrations: runMigrations getPendingMigrations: getPendingMigrations,
runMigrations: runMigrations,
detectSchemaVersion: detectSchemaVersion,
recordSchemaVersion: recordSchemaVersion,
compareVersions: compareVersions
}; };
+342 -72
View File
@@ -40,8 +40,70 @@ function normalizeRemoteAddress(value) {
function formatPlayerConnectionLabel(deviceId, remoteAddress) { function formatPlayerConnectionLabel(deviceId, remoteAddress) {
const normalizedDeviceId = String(deviceId || '').trim() || 'unknown-player'; const normalizedDeviceId = String(deviceId || '').trim() || 'unknown-player';
const normalizedRemoteAddress = normalizeRemoteAddress(remoteAddress); return normalizedDeviceId;
return normalizedRemoteAddress ? `${normalizedDeviceId} (ip ${normalizedRemoteAddress})` : normalizedDeviceId; }
function normalizeProxyBaseUrl(value) {
const normalized = String(value || '').trim().replace(/\/$/, '');
if (!normalized) {
return '';
}
try {
const url = new URL(normalized);
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') {
url.hostname = 'host.docker.internal';
}
return url.toString().replace(/\/$/, '');
} catch (_error) {
return normalized;
}
}
function isLocalLikeBaseUrl(value) {
let host = '';
try {
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
} catch (_error) {
return false;
}
return host === 'localhost'
|| host === '127.0.0.1'
|| host === '::1'
|| host === 'host.docker.internal'
|| host === 'player'
|| host === 'player-dev'
|| host === 'player-local'
|| host === 'player-bridge-dev'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
function resolveSnapshotUpstreamBaseUrl(player) {
const internalBaseUrl = normalizeProxyBaseUrl(player && player.internal_base_url);
if (internalBaseUrl && isLocalLikeBaseUrl(internalBaseUrl)) {
return internalBaseUrl;
}
return normalizeProxyBaseUrl(player && player.public_base_url) || null;
}
function resolvePlayerSocketForDeviceId(playerSockets, deviceId) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
if (!normalizedDeviceId || !playerSockets || typeof playerSockets.get !== 'function') {
return null;
}
const socket = playerSockets.get(normalizedDeviceId);
if (!socket || socket.readyState !== WebSocket.OPEN) {
return null;
}
return socket;
} }
function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) { function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) {
@@ -51,24 +113,38 @@ function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds)
} }
const deviceIds = screenPlayerDeviceIds.get(key); const deviceIds = screenPlayerDeviceIds.get(key);
if (!Array.isArray(deviceIds) || !deviceIds.length) { const targets = Array.isArray(deviceIds)
return []; ? Array.from(new Set(deviceIds.map(function (value) {
return normalizeDeviceId(value);
}).filter(Boolean))).map(function (deviceId) {
const socket = playerSockets.get(deviceId);
if (!socket || socket.readyState !== WebSocket.OPEN) {
return null;
}
return { deviceId: deviceId, socket: socket };
}).filter(Boolean)
: [];
if (targets.length) {
return targets;
} }
return Array.from(new Set(deviceIds.map(function (value) { const fallbackTargets = Array.from(playerSockets.values()).filter(function (socket) {
return normalizeDeviceId(value); return socket && socket.readyState === WebSocket.OPEN;
}).filter(Boolean))).map(function (deviceId) { }).map(function (socket) {
const socket = playerSockets.get(deviceId); return {
if (!socket || socket.readyState !== WebSocket.OPEN) { deviceId: String(socket.playerDeviceId || '').trim(),
return null; socket: socket
} };
}).filter(function (target) {
return Boolean(target.deviceId);
});
return { deviceId: deviceId, socket: socket }; return fallbackTargets.length === 1 ? fallbackTargets : [];
}).filter(Boolean);
} }
function resolveWebBaseUrl(req) { function resolveWebBaseUrl(req) {
const configuredWebBaseUrl = String(process.env.WEB_BASE_URL || '').trim().replace(/\/$/, ''); const configuredWebBaseUrl = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, '');
if (configuredWebBaseUrl) { if (configuredWebBaseUrl) {
return configuredWebBaseUrl; return configuredWebBaseUrl;
} }
@@ -118,24 +194,130 @@ async function start() {
const screenSnapshotsWs = new WebSocketServer({ noServer: true }); const screenSnapshotsWs = new WebSocketServer({ noServer: true });
const playerSockets = new Map(); const playerSockets = new Map();
const screenSnapshotCache = new Map(); const screenSnapshotCache = new Map();
const screenSnapshotSourcesBySlug = new Map();
const screenSnapshotSubscribersBySlug = new Map();
const screenPlayerDeviceIds = new Map(); const screenPlayerDeviceIds = new Map();
const pendingPlayerCommands = new Map(); const pendingPlayerCommands = new Map();
function normalizeProxyBaseUrl(value) { function getScreenSnapshotSourceBucket(slug) {
const normalized = String(value || '').trim().replace(/\/$/, ''); const key = String(slug || '').trim();
if (!normalized) { if (!key) {
return ''; return null;
} }
try { if (!screenSnapshotSourcesBySlug.has(key)) {
const url = new URL(normalized); screenSnapshotSourcesBySlug.set(key, new Map());
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') {
url.hostname = 'host.docker.internal';
}
return url.toString().replace(/\/$/, '');
} catch (_error) {
return normalized;
} }
return screenSnapshotSourcesBySlug.get(key);
}
function getScreenSnapshotSubscriberBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!screenSnapshotSubscribersBySlug.has(key)) {
screenSnapshotSubscribersBySlug.set(key, new Set());
}
return screenSnapshotSubscribersBySlug.get(key);
}
function buildMergedScreenSnapshot(slug) {
const key = String(slug || '').trim();
const sourceBucket = screenSnapshotSourcesBySlug.get(key);
const connections = [];
const deviceIds = [];
if (sourceBucket && typeof sourceBucket.forEach === 'function') {
sourceBucket.forEach(function (payload) {
if (payload && Array.isArray(payload.connections)) {
connections.push.apply(connections, payload.connections);
}
});
sourceBucket.forEach(function (_payload, sourceKey) {
deviceIds.push(sourceKey);
});
}
return {
slug: key,
count: connections.length,
connections: connections,
deviceIds: deviceIds
};
}
function broadcastScreenSnapshot(slug) {
const key = String(slug || '').trim();
const snapshot = buildMergedScreenSnapshot(key);
storeScreenSnapshot(key, snapshot.connections, snapshot.deviceIds);
const bucket = screenSnapshotSubscribersBySlug.get(key);
if (!bucket || !bucket.size) {
return;
}
const payload = JSON.stringify({
type: 'snapshot',
slug: key,
connections: snapshot.connections,
sentAt: new Date().toISOString()
});
bucket.forEach(function (socket) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(payload);
}
});
}
function setScreenSnapshotSource(slug, sourceKey, connections) {
const key = String(slug || '').trim();
const normalizedSourceKey = String(sourceKey || '').trim();
if (!key || !normalizedSourceKey) {
return;
}
const bucket = getScreenSnapshotSourceBucket(key);
if (!bucket) {
return;
}
bucket.set(normalizedSourceKey, {
slug: key,
connections: Array.isArray(connections) ? connections : []
});
broadcastScreenSnapshot(key);
}
function clearScreenSnapshotSource(slug, sourceKey) {
const key = String(slug || '').trim();
const normalizedSourceKey = String(sourceKey || '').trim();
const bucket = screenSnapshotSourcesBySlug.get(key);
if (!bucket || !normalizedSourceKey || !bucket.has(normalizedSourceKey)) {
return;
}
bucket.delete(normalizedSourceKey);
if (!bucket.size) {
screenSnapshotSourcesBySlug.delete(key);
}
broadcastScreenSnapshot(key);
}
function clearPlayerSnapshotSources(sourceKey) {
const normalizedSourceKey = String(sourceKey || '').trim();
if (!normalizedSourceKey) {
return;
}
Array.from(screenSnapshotSourcesBySlug.keys()).forEach(function (slug) {
clearScreenSnapshotSource(slug, normalizedSourceKey);
});
} }
async function fetchPlayerSnapshotRegistrations() { async function fetchPlayerSnapshotRegistrations() {
@@ -190,7 +372,7 @@ async function start() {
return; return;
} }
logBridge(`Player ${formatPlayerConnectionLabel(socket.playerDeviceId, socket.bridgeRemoteAddress)} has disconnected`); logBridge(`Player ${formatPlayerConnectionLabel(socket.playerDeviceId)} has disconnected`);
} }
function resolveMediaPath(fileName) { function resolveMediaPath(fileName) {
@@ -201,8 +383,8 @@ async function start() {
return relativePath; return relativePath;
} }
function sendPlayerCommand(commandPayload) { function sendPlayerCommand(commandPayload, deviceId) {
const socket = getConnectedPlayerSocket(); const socket = resolvePlayerSocketForDeviceId(playerSockets, deviceId);
if (!socket) { if (!socket) {
return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' }); return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' });
} }
@@ -345,15 +527,24 @@ async function start() {
return res.status(400).json({ error: 'Filename is required' }); return res.status(400).json({ error: 'Filename is required' });
} }
const deviceId = normalizeDeviceId(req.query.deviceId || req.query.playerIdentifier || req.headers['x-pulse-player-device-id']);
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required.' });
}
const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
const response = await sendPlayerCommand({ const response = await sendPlayerCommand({
command: 'media-put', command: 'media-put',
relativePath: relativePath, relativePath: relativePath,
bodyBase64: bodyBuffer.toString('base64') bodyBase64: bodyBuffer.toString('base64')
}); }, deviceId);
res.status(response.status || (response.ok ? 200 : 502)).json(response); res.status(response.status || (response.ok ? 200 : 502)).json(response);
} catch (error) { } catch (error) {
logBridge('Player media upload failed', {
relativePath: req.params && req.params.filename ? String(req.params.filename).trim() : '',
error: error && error.message ? error.message : String(error)
});
next(error); next(error);
} }
}); });
@@ -365,13 +556,22 @@ async function start() {
return res.status(400).json({ error: 'Filename is required' }); return res.status(400).json({ error: 'Filename is required' });
} }
const deviceId = normalizeDeviceId(req.query.deviceId || req.query.playerIdentifier || req.headers['x-pulse-player-device-id']);
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required.' });
}
const response = await sendPlayerCommand({ const response = await sendPlayerCommand({
command: 'media-delete', command: 'media-delete',
relativePath: relativePath relativePath: relativePath
}); }, deviceId);
res.status(response.status || (response.ok ? 200 : 502)).json(response); res.status(response.status || (response.ok ? 200 : 502)).json(response);
} catch (error) { } catch (error) {
logBridge('Player media delete failed', {
relativePath: req.params && req.params.filename ? String(req.params.filename).trim() : '',
error: error && error.message ? error.message : String(error)
});
next(error); next(error);
} }
}); });
@@ -641,6 +841,16 @@ async function start() {
socket.playerDeviceId = deviceId; socket.playerDeviceId = deviceId;
if (messageType === 'snapshot') {
const slug = String(payload.slug || '').trim();
if (!slug) {
return;
}
setScreenSnapshotSource(slug, deviceId, Array.isArray(payload.connections) ? payload.connections : []);
return;
}
if (messageType === 'register') { if (messageType === 'register') {
const player = await upsertPlayerRegistration(pool, { const player = await upsertPlayerRegistration(pool, {
deviceId: deviceId, deviceId: deviceId,
@@ -649,7 +859,7 @@ async function start() {
}); });
playerSockets.set(deviceId, socket); playerSockets.set(deviceId, socket);
logBridge(`Player ${formatPlayerConnectionLabel(deviceId, socket.bridgeRemoteAddress)} has connected`); logBridge(`Player ${formatPlayerConnectionLabel(deviceId)} has connected`);
socket.send(JSON.stringify({ type: 'registered', ok: true, player: player })); socket.send(JSON.stringify({ type: 'registered', ok: true, player: player }));
return; return;
} }
@@ -696,14 +906,12 @@ async function start() {
} }
if (!verifyRequestAuth(request)) { if (!verifyRequestAuth(request)) {
const remoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress); logBridge('Player denied with wrong shared secret');
logBridge(remoteAddress ? `Player (ip ${remoteAddress}) denied with wrong shared secret` : 'Player denied with wrong shared secret');
socket.destroy(); socket.destroy();
return; return;
} }
playersWs.handleUpgrade(request, socket, head, function (ws) { playersWs.handleUpgrade(request, socket, head, function (ws) {
ws.bridgeRemoteAddress = normalizeRemoteAddress(request && request.socket && request.socket.remoteAddress);
playersWs.emit('connection', ws, request); playersWs.emit('connection', ws, request);
}); });
}); });
@@ -719,12 +927,14 @@ async function start() {
}); });
socket.on('close', function () { socket.on('close', function () {
clearPlayerSnapshotSources(socket.playerDeviceId);
if (removeConnectedPlayerSocket(socket)) { if (removeConnectedPlayerSocket(socket)) {
logPlayerDisconnect(socket); logPlayerDisconnect(socket);
} }
}); });
socket.on('error', function () { socket.on('error', function () {
clearPlayerSnapshotSources(socket.playerDeviceId);
if (removeConnectedPlayerSocket(socket)) { if (removeConnectedPlayerSocket(socket)) {
logPlayerDisconnect(socket); logPlayerDisconnect(socket);
} }
@@ -734,33 +944,18 @@ async function start() {
screenSnapshotsWs.on('connection', function (socket, request, slug) { screenSnapshotsWs.on('connection', function (socket, request, slug) {
const normalizedSlug = String(slug || '').trim(); const normalizedSlug = String(slug || '').trim();
const upstreamSockets = new Map(); const upstreamSockets = new Map();
const upstreamSnapshots = new Map();
const upstreamDeviceIds = new Set(); const upstreamDeviceIds = new Set();
let refreshTimer = null; let refreshTimer = null;
let closed = false; let closed = false;
function sendMergedSnapshot() { const subscriberBucket = getScreenSnapshotSubscriberBucket(normalizedSlug);
if (!normalizedSlug || socket.readyState !== WebSocket.OPEN) { if (!subscriberBucket) {
return; socket.close();
} return;
const connections = [];
upstreamSnapshots.forEach(function (payload) {
if (payload && Array.isArray(payload.connections)) {
connections.push.apply(connections, payload.connections);
}
});
storeScreenSnapshot(normalizedSlug, connections, Array.from(upstreamDeviceIds));
socket.send(JSON.stringify({
type: 'snapshot',
slug: normalizedSlug,
connections: connections,
sentAt: new Date().toISOString()
}));
} }
subscriberBucket.add(socket);
function closeUpstreamSockets() { function closeUpstreamSockets() {
upstreamSockets.forEach(function (upstreamSocket) { upstreamSockets.forEach(function (upstreamSocket) {
try { try {
@@ -769,7 +964,6 @@ async function start() {
} }
}); });
upstreamSockets.clear(); upstreamSockets.clear();
upstreamSnapshots.clear();
} }
async function refreshUpstreams() { async function refreshUpstreams() {
@@ -786,12 +980,19 @@ async function start() {
const seenKeys = new Set(); const seenKeys = new Set();
players.forEach(function (player) { players.forEach(function (player) {
const baseUrl = normalizeProxyBaseUrl(player && player.public_base_url); const sourceKey = String(player && player.identifier || player && player.id || '').trim();
const connectedSocket = sourceKey ? playerSockets.get(sourceKey) : null;
if (connectedSocket && connectedSocket.readyState === WebSocket.OPEN) {
seenKeys.add(sourceKey);
upstreamDeviceIds.add(sourceKey);
return;
}
const baseUrl = resolveSnapshotUpstreamBaseUrl(player);
if (!baseUrl) { if (!baseUrl) {
return; return;
} }
const sourceKey = String(player && player.identifier || player && player.id || baseUrl);
seenKeys.add(sourceKey); seenKeys.add(sourceKey);
upstreamDeviceIds.add(sourceKey); upstreamDeviceIds.add(sourceKey);
if (upstreamSockets.has(sourceKey)) { if (upstreamSockets.has(sourceKey)) {
@@ -814,20 +1015,15 @@ async function start() {
if (!payload || payload.type !== 'snapshot' || String(payload.slug || '').trim() !== normalizedSlug) { if (!payload || payload.type !== 'snapshot' || String(payload.slug || '').trim() !== normalizedSlug) {
return; return;
} }
upstreamSnapshots.set(sourceKey, { setScreenSnapshotSource(normalizedSlug, sourceKey, Array.isArray(payload.connections) ? payload.connections : []);
slug: normalizedSlug,
connections: Array.isArray(payload.connections) ? payload.connections : []
});
sendMergedSnapshot();
} catch (_error) { } catch (_error) {
} }
}; };
upstreamSocket.onclose = function () { upstreamSocket.onclose = function () {
upstreamSockets.delete(sourceKey); upstreamSockets.delete(sourceKey);
upstreamSnapshots.delete(sourceKey); if (!closed && !(playerSockets.get(sourceKey) && playerSockets.get(sourceKey).readyState === WebSocket.OPEN)) {
if (!closed) { clearScreenSnapshotSource(normalizedSlug, sourceKey);
sendMergedSnapshot();
} }
}; };
@@ -852,7 +1048,7 @@ async function start() {
} }
}); });
sendMergedSnapshot(); broadcastScreenSnapshot(normalizedSlug);
} }
refreshUpstreams(); refreshUpstreams();
@@ -863,6 +1059,10 @@ async function start() {
socket.on('close', function () { socket.on('close', function () {
closed = true; closed = true;
subscriberBucket.delete(socket);
if (!subscriberBucket.size) {
screenSnapshotSubscribersBySlug.delete(normalizedSlug);
}
if (refreshTimer) { if (refreshTimer) {
clearInterval(refreshTimer); clearInterval(refreshTimer);
refreshTimer = null; refreshTimer = null;
@@ -872,6 +1072,10 @@ async function start() {
socket.on('error', function () { socket.on('error', function () {
closed = true; closed = true;
subscriberBucket.delete(socket);
if (!subscriberBucket.size) {
screenSnapshotSubscribersBySlug.delete(normalizedSlug);
}
if (refreshTimer) { if (refreshTimer) {
clearInterval(refreshTimer); clearInterval(refreshTimer);
refreshTimer = null; refreshTimer = null;
@@ -882,19 +1086,32 @@ async function start() {
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) { app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
try { try {
logBridge('Relaying player media sync request to web');
const webBaseUrl = resolveWebBaseUrl(_req); const webBaseUrl = resolveWebBaseUrl(_req);
if (!webBaseUrl) { if (!webBaseUrl) {
return res.status(502).json({ error: 'Web base URL is not configured.' }); return res.status(502).json({ error: 'Web base URL is not configured.' });
} }
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
? Object.assign({}, _req.body)
: {};
const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, { const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, {
method: 'POST', method: 'POST',
headers: Object.assign({ headers: Object.assign({
Accept: 'application/json' Accept: 'application/json',
'Content-Type': 'application/json'
}, createRequestAuthHeaders({ }, createRequestAuthHeaders({
method: 'POST', method: 'POST',
pathname: '/api/internal/sync/player-media' pathname: '/api/internal/sync/player-media',
})) body: requestBody
})),
body: JSON.stringify(requestBody)
});
logBridge('Web player media sync response received', {
ok: Boolean(response && response.ok),
status: response && response.status ? response.status : null
}); });
res.status(response.status); res.status(response.status);
@@ -904,6 +1121,53 @@ async function start() {
} }
res.send(await response.text()); res.send(await response.text());
} catch (error) { } catch (error) {
logBridge('Player media sync relay failed', {
error: error && error.message ? error.message : String(error)
});
next(error);
}
});
app.post('/api/internal/sync/player-font', requireRequestAuth, async function (_req, res, next) {
try {
logBridge('Relaying player font sync request to web');
const webBaseUrl = resolveWebBaseUrl(_req);
if (!webBaseUrl) {
return res.status(502).json({ error: 'Web base URL is not configured.' });
}
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
? Object.assign({}, _req.body)
: {};
const response = await fetch(`${webBaseUrl}/api/internal/sync/player-font`, {
method: 'POST',
headers: Object.assign({
Accept: 'application/json',
'Content-Type': 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/internal/sync/player-font',
body: requestBody
})),
body: JSON.stringify(requestBody)
});
logBridge('Web player font sync response received', {
ok: Boolean(response && response.ok),
status: response && response.status ? response.status : null
});
res.status(response.status);
const contentType = response.headers.get('content-type');
if (contentType) {
res.type(contentType);
}
res.send(await response.text());
} catch (error) {
logBridge('Player font sync relay failed', {
error: error && error.message ? error.message : String(error)
});
next(error); next(error);
} }
}); });
@@ -915,7 +1179,13 @@ async function start() {
}); });
} }
module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets }; module.exports = {
start: start,
resolveWebBaseUrl: resolveWebBaseUrl,
resolveScreenCommandTargets: resolveScreenCommandTargets,
resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl,
resolvePlayerSocketForDeviceId: resolvePlayerSocketForDeviceId
};
if (require.main === module) { if (require.main === module) {
start().catch(function (error) { start().catch(function (error) {
+231 -55
View File
@@ -18,21 +18,43 @@ const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/d
// Player runtime, media API, and websocket wiring. // Player runtime, media API, and websocket wiring.
async function start() { async function start() {
const app = express(); const app = express();
const pool = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '') ? null : common.createPool(); const pool = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '') ? null : common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 8081); const PORT = Number(process.env.PLAYER_PORT || 8081);
const PLAYER_PUBLIC_BASE_URL = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const PLAYER_PUBLIC_URL = String(process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const THIN_CLIENT_BASE_URL = String(process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); const BRIDGE_PUBLIC_URL = String(process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const isRemotePlayer = Boolean(THIN_CLIENT_BASE_URL); const WEB_INTERNAL_URL = String(process.env.WEB_INTERNAL_URL || '').trim().replace(/\/$/, '');
const PLAYER_INTERNAL_BASE_URL = String(isRemotePlayer ? THIN_CLIENT_BASE_URL : (process.env.PLAYER_INTERNAL_BASE_URL || PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_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_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 ASSET_DIR = path.join(__dirname, 'player', 'public');
const MEDIA_DIR = path.join(__dirname, '..', 'media'); const MEDIA_DIR = path.join(__dirname, '..', 'media');
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json'); const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000); 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); const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
let thinClientSocket = null;
let lastDisconnectAt = 0;
let playerPublicBaseUrl = PLAYER_PUBLIC_URL || null;
let refreshThinClientRegistration = null;
const playerRuntime = createPlayerRuntime({ const playerRuntime = createPlayerRuntime({
pool: pool, 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 = isRemotePlayer const playerPlaylistService = isRemotePlayer
? null ? null
@@ -50,6 +72,35 @@ async function start() {
let hasLoggedPlayerStartup = false; 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) { function logPlayerStartup(connectionState) {
if (hasLoggedPlayerStartup) { if (hasLoggedPlayerStartup) {
return; return;
@@ -59,9 +110,9 @@ async function start() {
console.info('[player] startup', { console.info('[player] startup', {
mode: isRemotePlayer ? 'bridge client' : 'local', mode: isRemotePlayer ? 'bridge client' : 'local',
connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false, connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL || null, publicBaseUrl: getPlayerPublicBaseUrl(),
bridgeBaseUrl: PLAYER_INTERNAL_BASE_URL || null, bridgeBaseUrl: PLAYER_INTERNAL_URL || null,
bridgeWebSocketUrl: THIN_CLIENT_BASE_URL ? createThinClientWebSocketUrl() : null bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null
}); });
} }
@@ -83,28 +134,84 @@ async function start() {
} }
async function triggerWebMediaSync() { async function triggerWebMediaSync() {
if (!isRemotePlayer || !THIN_CLIENT_BASE_URL) { const syncBaseUrl = WEB_INTERNAL_URL || BRIDGE_PUBLIC_URL;
if (!isRemotePlayer || !syncBaseUrl) {
return false; return false;
} }
const requestBody = {
playerIdentifier: PLAYER_DEVICE_ID,
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL
};
try { try {
const authHeaders = createRequestAuthHeaders({ const authHeaders = createRequestAuthHeaders({
method: 'POST', method: 'POST',
pathname: '/api/internal/sync/player-media' pathname: '/api/internal/sync/player-media',
body: requestBody
}); });
const response = await fetch(`${THIN_CLIENT_BASE_URL}/api/internal/sync/player-media`, { const response = await fetch(`${syncBaseUrl}/api/internal/sync/player-media`, {
method: 'POST', method: 'POST',
headers: Object.assign({ headers: Object.assign({
Accept: 'application/json' Accept: 'application/json',
}, authHeaders) 'Content-Type': 'application/json'
}, authHeaders),
body: JSON.stringify(requestBody)
}); });
return Boolean(response && response.ok); return Boolean(response && response.ok);
} catch (_error) { } 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; return false;
} }
} }
let webMediaSyncCompleted = 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) { async function handleThinClientCommand(socket, rawMessage) {
let payload = null; let payload = null;
@@ -194,9 +301,9 @@ async function start() {
common: common, common: common,
playerRuntime: playerRuntime, playerRuntime: playerRuntime,
onboardingStore: onboardingStore, onboardingStore: onboardingStore,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, playerInternalBaseUrl: PLAYER_INTERNAL_URL,
thinClientBaseUrl: THIN_CLIENT_BASE_URL, bridgeBaseUrl: BRIDGE_PUBLIC_URL,
playerDeviceId: PLAYER_DEVICE_ID playerDeviceId: PLAYER_DEVICE_ID
}); });
registerPlayerRoutes(app, { registerPlayerRoutes(app, {
@@ -207,18 +314,18 @@ async function start() {
playerRuntime: playerRuntime, playerRuntime: playerRuntime,
playerPlaylistService: playerPlaylistService, playerPlaylistService: playerPlaylistService,
rtmpStreamService: rtmpStreamService, rtmpStreamService: rtmpStreamService,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, playerInternalBaseUrl: PLAYER_INTERNAL_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, bridgeBaseUrl: BRIDGE_PUBLIC_URL,
thinClientBaseUrl: THIN_CLIENT_BASE_URL, playerDeviceId: PLAYER_DEVICE_ID,
playerDeviceId: PLAYER_DEVICE_ID onPlayerPublicBaseUrl: setPlayerPublicBaseUrl
}); });
function createThinClientWebSocketUrl() { function createThinClientWebSocketUrl() {
if (!THIN_CLIENT_BASE_URL) { if (!BRIDGE_PUBLIC_URL) {
return null; return null;
} }
return THIN_CLIENT_BASE_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players'; return BRIDGE_PUBLIC_URL.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:') + '/ws/players';
} }
function startThinClientRegistration() { function startThinClientRegistration() {
@@ -256,6 +363,71 @@ async function start() {
'x-pulse-request-timestamp': timestamp 'x-pulse-request-timestamp': timestamp
}, authHeaders) }, 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
}));
}
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 () { socket.on('open', function () {
logPlayerStartup({ logPlayerStartup({
@@ -265,42 +437,40 @@ async function start() {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'register', type: 'register',
deviceId: PLAYER_DEVICE_ID, deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL, publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_BASE_URL internalBaseUrl: PLAYER_INTERNAL_URL
})); }));
if (!webMediaSyncCompleted) { playerRuntime.snapshotSlugs().forEach(function (slug) {
triggerWebMediaSync().then(function (success) { sendSnapshot(slug);
webMediaSyncTriggered = Boolean(success); });
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () {
webMediaSyncTriggered = false;
});
}
heartbeatTimer = setInterval(function () { heartbeatTimer = setInterval(function () {
if (!socket || socket.readyState !== WebSocket.OPEN) { sendHeartbeat();
return;
}
socket.send(JSON.stringify({
type: 'heartbeat',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL,
internalBaseUrl: PLAYER_INTERNAL_BASE_URL
}));
if (!webMediaSyncTriggered && !webMediaSyncCompleted) {
triggerWebMediaSync().then(function (success) {
webMediaSyncTriggered = Boolean(success);
webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted;
}).catch(function () {
webMediaSyncTriggered = false;
});
}
}, DB_SYNC_INTERVAL_MS); }, DB_SYNC_INTERVAL_MS);
}); });
socket.on('message', function (rawMessage) { 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) { handleThinClientCommand(socket, rawMessage).catch(function (error) {
try { try {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
@@ -316,8 +486,14 @@ async function start() {
}); });
socket.on('close', function () { socket.on('close', function () {
lastDisconnectAt = Date.now();
webFontSyncTriggered = false;
webFontSyncCompleted = false;
webMediaSyncCompleted = false;
clearTimers(); clearTimers();
reconnectTimer = setTimeout(connect, 5000); thinClientSocket = null;
refreshThinClientRegistration = null;
reconnectTimer = setTimeout(connect, PLAYER_AGENT_RECONNECT_DELAY_MS);
}); });
socket.on('error', function () { socket.on('error', function () {
@@ -363,8 +539,8 @@ async function start() {
try { try {
await recordPlayerHeartbeat(pool, { await recordPlayerHeartbeat(pool, {
deviceId: PLAYER_DEVICE_ID, deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: PLAYER_PUBLIC_BASE_URL, publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_BASE_URL internalBaseUrl: PLAYER_INTERNAL_URL
}).catch(function (error) { }).catch(function (error) {
console.error(error); console.error(error);
}); });
@@ -392,7 +568,7 @@ async function start() {
if (PLAYER_DEVICE_ID && !isRemotePlayer) { if (PLAYER_DEVICE_ID && !isRemotePlayer) {
const { upsertPlayerRegistration } = require('./player/onboarding'); const { upsertPlayerRegistration } = require('./player/onboarding');
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_BASE_URL, PLAYER_INTERNAL_BASE_URL).catch(function (error) { await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) {
console.error(error); console.error(error);
}); });
} }
+22 -21
View File
@@ -16,15 +16,16 @@ function normalizeDeviceId(value) {
} }
function getPublicBaseUrl(req, configuredUrl) { function getPublicBaseUrl(req, configuredUrl) {
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
if (configured) {
return configured;
}
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0]; const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http'); const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0]; const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0];
const host = forwardedHost || String(req.headers.host || '').trim(); const host = forwardedHost || String(req.headers.host || '').trim();
return `${protocol}://${host}`.replace(/\/$/, ''); 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) { function getRequestIp(req) {
@@ -50,7 +51,7 @@ function getPlayerPublicBaseUrl(req, configuredUrl) {
} }
function getPlayerInternalBaseUrl(configuredUrl) { function getPlayerInternalBaseUrl(configuredUrl) {
const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
return configured || null; return configured || null;
} }
@@ -203,22 +204,22 @@ function registerPlayerOnboardingRoutes(app, options) {
const common = options && options.common ? options.common : null; const common = options && options.common ? options.common : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null; const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId); const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
if (!app || !common) { if (!app || !common) {
throw new Error('registerPlayerOnboardingRoutes requires app and common.'); throw new Error('registerPlayerOnboardingRoutes requires app and common.');
} }
if (!thinClientBaseUrl && (!pool || !playerRuntime)) { if (!bridgeBaseUrl && (!pool || !playerRuntime)) {
throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless thinClientBaseUrl is configured.'); throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless bridgeBaseUrl is configured.');
} }
const sharedSecret = getSharedSecret(); const sharedSecret = getSharedSecret();
async function fetchThinClient(req, pathname, options) { async function fetchThinClient(req, pathname, options) {
if (!thinClientBaseUrl) { if (!bridgeBaseUrl) {
return null; return null;
} }
@@ -239,7 +240,7 @@ function registerPlayerOnboardingRoutes(app, options) {
headers['content-type'] = requestOptions.contentType; headers['content-type'] = requestOptions.contentType;
} }
return fetch(new URL(pathname, thinClientBaseUrl).toString(), { return fetch(new URL(pathname, bridgeBaseUrl).toString(), {
method: method, method: method,
headers: headers, headers: headers,
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
@@ -305,7 +306,7 @@ function registerPlayerOnboardingRoutes(app, options) {
}, async function (req, res, next) { }, async function (req, res, next) {
try { try {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId; const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
if (thinClientBaseUrl) { if (bridgeBaseUrl) {
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), { const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), {
method: 'GET' method: 'GET'
}); });
@@ -317,7 +318,7 @@ function registerPlayerOnboardingRoutes(app, options) {
if (!payload) { if (!payload) {
return res.status(502).json({ error: 'Player bridge returned an invalid response.' }); return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
} }
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null; payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null;
return res.json(payload); return res.json(payload);
} }
@@ -329,7 +330,7 @@ function registerPlayerOnboardingRoutes(app, options) {
screenId: status ? status.screen_id : null, screenId: status ? status.screen_id : null,
screenSlug: status ? status.screen_slug : null, screenSlug: status ? status.screen_slug : null,
screenName: status ? status.screen_name : null, screenName: status ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null
}); });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -338,7 +339,7 @@ function registerPlayerOnboardingRoutes(app, options) {
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) { app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try { try {
if (thinClientBaseUrl) { if (bridgeBaseUrl) {
const response = await fetchThinClient(_req, '/api/onboarding/screens', { const response = await fetchThinClient(_req, '/api/onboarding/screens', {
method: 'GET' method: 'GET'
}); });
@@ -361,7 +362,7 @@ function registerPlayerOnboardingRoutes(app, options) {
if (!deviceId) { if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' }); return res.status(400).json({ error: 'Device ID is required' });
} }
const onboardingUrl = `${getPublicBaseUrl(req, playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; const onboardingUrl = `${getPublicBaseUrl(req, playerPublicUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 }); const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
res.set('Content-Type', 'image/svg+xml; charset=utf-8'); res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
@@ -382,11 +383,11 @@ function registerPlayerOnboardingRoutes(app, options) {
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' }); return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
} }
if (thinClientBaseUrl) { if (bridgeBaseUrl) {
const forwardedBody = Object.assign({}, req.body || {}, { const forwardedBody = Object.assign({}, req.body || {}, {
deviceId: deviceId deviceId: deviceId
}); });
const response = await fetch(new URL('/api/onboarding', thinClientBaseUrl).toString(), { const response = await fetch(new URL('/api/onboarding', bridgeBaseUrl).toString(), {
method: 'POST', method: 'POST',
headers: Object.assign({ headers: Object.assign({
'content-type': 'application/json' 'content-type': 'application/json'
@@ -402,7 +403,7 @@ function registerPlayerOnboardingRoutes(app, options) {
if (!payload) { if (!payload) {
return res.status(502).json({ error: 'Player bridge returned an invalid response.' }); return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
} }
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`; payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`;
return res.json(payload); return res.json(payload);
} }
if (!deviceId) { if (!deviceId) {
@@ -423,7 +424,7 @@ function registerPlayerOnboardingRoutes(app, options) {
screenId: status && status.screen_id ? status.screen_id : null, screenId: status && status.screen_id ? status.screen_id : null,
screenSlug: status ? status.screen_slug : screenSlug, screenSlug: status ? status.screen_slug : screenSlug,
screenName: status && status.screen_name ? status.screen_name : null, screenName: status && status.screen_name ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`, playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`,
queued: Boolean(status && status.queued) queued: Boolean(status && status.queued)
}); });
} catch (error) { } catch (error) {
@@ -7,6 +7,12 @@
var form = document.getElementById("onboarding-form"); var form = document.getElementById("onboarding-form");
var message = document.getElementById("onboarding-message"); var message = document.getElementById("onboarding-message");
var screenSelect = document.getElementById("onboarding-screen-select"); 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 setMessage(value) { if (message) { message.textContent = value || ""; } }
function parseResponseError(response) { function parseResponseError(response) {
return response.text().then(function (text) { return response.text().then(function (text) {
@@ -41,19 +47,17 @@
} }
try { try {
if (!deviceId) { if (!deviceId) {
deviceId = window.localStorage.getItem(deviceKey) || ""; deviceId = window.sessionStorage.getItem(deviceKey) || "";
} }
if (deviceId) { if (deviceId) {
window.localStorage.setItem(deviceKey, deviceId); window.sessionStorage.setItem(deviceKey, deviceId);
} }
} catch (_error) {} } catch (_error) {}
loadScreens().then(function () { loadScreens().then(function () {
try { try {
var storedClientName = window.localStorage.getItem(clientNameKey) || ""; var storedClientName = getSessionStorageItem(clientNameKey) || "";
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
var clientNameInput = form.querySelector("input[name=\"clientName\"]"); var clientNameInput = form.querySelector("input[name=\"clientName\"]");
if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; } if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; }
if (screenSelect && storedScreenSlug) { screenSelect.value = storedScreenSlug; }
} catch (_error) {} } catch (_error) {}
}); });
form.addEventListener("submit", function (event) { form.addEventListener("submit", function (event) {
@@ -83,7 +87,7 @@
}) })
.then(function (payload) { .then(function (payload) {
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); } 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) {} try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
setMessage("Onboarding complete."); setMessage("Onboarding complete.");
if (form) { if (form) {
@@ -2,6 +2,12 @@
(function () { (function () {
var deviceKey = "pulse-signage-player-device-id"; var deviceKey = "pulse-signage-player-device-id";
var clientNameKey = "pulse-signage-player-client-name"; 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) { function getClientNameStorageKey(_screenSlug) {
return clientNameKey; return clientNameKey;
} }
@@ -25,10 +31,10 @@
} }
function getDeviceId() { function getDeviceId() {
var stored = ""; var stored = "";
try { stored = window.localStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; } try { stored = window.sessionStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
if (stored) { return stored; } if (stored) { return stored; }
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "device-" + Date.now() + "-" + Math.random().toString(16).slice(2)); 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) {} try { window.sessionStorage.setItem(deviceKey, next); } catch (_error2) {}
return next; return next;
} }
function setStatus(message) { if (status) { status.textContent = message; } } function setStatus(message) { if (status) { status.textContent = message; } }
@@ -83,8 +89,8 @@
}) })
.then(function (payload) { .then(function (payload) {
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); } 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); }
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} } if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {} try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
setLocalMessage("Onboarding complete."); setLocalMessage("Onboarding complete.");
if (localForm) { if (localForm) {
@@ -99,8 +105,8 @@
.then(function (response) { return response.ok ? response.json() : null; }) .then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) { .then(function (payload) {
if (payload && payload.onboarded && payload.screenSlug) { if (payload && payload.onboarded && payload.screenSlug) {
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} } if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} } if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {} try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug)); window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
return true; return true;
@@ -126,14 +132,11 @@
} }
loadScreens().then(function () { loadScreens().then(function () {
try { try {
var storedScreenSlug = window.localStorage.getItem(screenKey) || ""; var storedClientName = getSessionStorageItem(clientNameKey) || "";
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
if (storedClientName && localForm) { if (storedClientName && localForm) {
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]"); var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
if (clientNameInput) { clientNameInput.value = storedClientName; } if (clientNameInput) { clientNameInput.value = storedClientName; }
} }
if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; }
} catch (_error) {} } catch (_error) {}
}); });
redirectIfOnboarded(deviceId).then(function (redirected) { redirectIfOnboarded(deviceId).then(function (redirected) {
+27 -20
View File
@@ -4,10 +4,31 @@
const onboardingClientNameStorageKey = 'pulse-signage-player-client-name'; const onboardingClientNameStorageKey = 'pulse-signage-player-client-name';
const onboardingDeviceIdStorageKey = 'pulse-signage-player-device-id'; 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() { function getOnboardingDeviceId() {
try { try {
var storedDeviceId = window.localStorage.getItem(onboardingDeviceIdStorageKey) || ''; var storedDeviceId = getSessionStorageItem(onboardingDeviceIdStorageKey);
return String(storedDeviceId || '').trim(); 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) { } catch (_error) {
return ''; return '';
} }
@@ -19,24 +40,14 @@
return onboardingClientName; return onboardingClientName;
} }
try { try {
var storedClientName = window.localStorage.getItem(onboardingClientNameStorageKey); var storedClientName = getSessionStorageItem(onboardingClientNameStorageKey);
if (storedClientName) { if (storedClientName) {
onboardingClientName = storedClientName; onboardingClientName = storedClientName;
try {
window.localStorage.setItem('pulse-signage-player-client-name', storedClientName);
} catch (_mirrorError) {
// ignore storage errors
}
return onboardingClientName; return onboardingClientName;
} }
var genericClientName = window.localStorage.getItem('pulse-signage-player-client-name'); var genericClientName = getSessionStorageItem('pulse-signage-player-client-name');
if (genericClientName) { if (genericClientName) {
onboardingClientName = genericClientName; onboardingClientName = genericClientName;
try {
window.localStorage.setItem(onboardingClientNameStorageKey, genericClientName);
} catch (_error) {
// ignore storage errors
}
return onboardingClientName; return onboardingClientName;
} }
} catch (_error) { } catch (_error) {
@@ -51,12 +62,8 @@
return; return;
} }
onboardingClientName = normalizedName; onboardingClientName = normalizedName;
try { setSessionStorageItem('pulse-signage-player-client-name', normalizedName);
window.localStorage.setItem('pulse-signage-player-client-name', normalizedName); setSessionStorageItem(onboardingClientNameStorageKey, normalizedName);
window.localStorage.setItem(onboardingClientNameStorageKey, normalizedName);
} catch (_error) {
// ignore storage errors
}
if (socket && socket.readyState === WebSocket.OPEN) { if (socket && socket.readyState === WebSocket.OPEN) {
sendCommandState(socket); sendCommandState(socket);
} }
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="icon" type="image/png" href="/assets/favicon.png" /> <link rel="icon" type="image/png" href="/assets/favicon.png" />
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.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/vendor/animate.css/animate.min.css" />
<link rel="stylesheet" href="/assets/css/player.css?v=36" /> <link rel="stylesheet" href="/assets/css/player.css?v=37" />
{{{STYLESHEETS}}} {{{STYLESHEETS}}}
</head> </head>
<body class="{{BODY_CLASS}}"> <body class="{{BODY_CLASS}}">
+2 -11
View File
@@ -4,7 +4,7 @@ body {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: #111; background: #0a0a0a;
color: #fff; color: #fff;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
} }
@@ -43,7 +43,7 @@ body.thumbnail-preview .player-offline-banner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #111; background: #0a0a0a;
position: relative; position: relative;
} }
@@ -294,15 +294,6 @@ body.screen-blackout #app {
display: block; display: block;
} }
.slide img,
.slide video,
.slide iframe {
width: 100%;
height: 100%;
object-fit: contain;
border: 0;
}
.body { .body {
position: absolute; position: absolute;
left: 5%; left: 5%;
@@ -427,6 +427,42 @@ function normalizeBoolean(value) {
return null; 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;
}
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);
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
navigateSlides(1);
}
}
window.addEventListener('keydown', handlePlayerKeydown);
// Move to the previous or next active slide. // Move to the previous or next active slide.
function navigateSlides(offset) { function navigateSlides(offset) {
const manualSlides = getCurrentActiveSlides(); const manualSlides = getCurrentActiveSlides();
+15 -1
View File
@@ -70,6 +70,7 @@ function applyPendingPlaylistUpdate() {
if (!pendingPlaylistUpdate) { if (!pendingPlaylistUpdate) {
return false; return false;
} }
var nextIndex = Number(index || 0);
slides = pendingPlaylistUpdate.slides; slides = pendingPlaylistUpdate.slides;
currentPlaylistSignature = pendingPlaylistUpdate.signature; currentPlaylistSignature = pendingPlaylistUpdate.signature;
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides; currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
@@ -80,7 +81,11 @@ function applyPendingPlaylistUpdate() {
templateLayoutCache = Object.create(null); templateLayoutCache = Object.create(null);
templateRenderPlanCache = Object.create(null); templateRenderPlanCache = Object.create(null);
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight; renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
index = 0; 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.'); logDebug('Applied updated playlist on slide transition.');
return true; return true;
} }
@@ -184,6 +189,15 @@ function refresh() {
const nextActiveSlides = getActiveSlidesFrom(nextSlides); const nextActiveSlides = getActiveSlidesFrom(nextSlides);
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides); const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp); const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
if (window.initialData && typeof window.initialData === 'object') {
window.initialData.screen = data.screen || window.initialData.screen || null;
window.initialData.playlist = data.playlist || null;
window.initialData.slides = nextSlides;
window.initialData.rssFeeds = Array.isArray(data.rssFeeds) ? data.rssFeeds : [];
window.initialData.apiSources = Array.isArray(data.apiSources) ? data.apiSources : [];
window.initialData.timetableGroups = Array.isArray(data.timetableGroups) ? data.timetableGroups : [];
window.initialData.revision = nextSignature;
}
savePlaylistSnapshot({ savePlaylistSnapshot({
slides: nextSlides, slides: nextSlides,
signature: nextSignature, signature: nextSignature,
+3 -3
View File
@@ -188,13 +188,13 @@ function syncWebpagePreloads(sourceSlides, targetIndex) {
preloadSignature = signature; preloadSignature = signature;
} }
// Return a stable client id for this browser session. // Return a stable client id for this screen session.
function getCommandClientId() { function getCommandClientId() {
if (commandClientId) { if (commandClientId) {
return commandClientId; return commandClientId;
} }
try { try {
var storedClientId = window.localStorage.getItem(commandClientStorageKey); var storedClientId = window.sessionStorage.getItem(commandClientStorageKey);
if (storedClientId) { if (storedClientId) {
commandClientId = storedClientId; commandClientId = storedClientId;
return commandClientId; return commandClientId;
@@ -204,7 +204,7 @@ function getCommandClientId() {
} }
commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2)); commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2));
try { try {
window.localStorage.setItem(commandClientStorageKey, commandClientId); window.sessionStorage.setItem(commandClientStorageKey, commandClientId);
} catch (_error2) { } catch (_error2) {
// ignore storage errors // ignore storage errors
} }
+14 -1
View File
@@ -334,7 +334,7 @@ function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
document.documentElement.style.setProperty('--player-canvas-height', height + 'px'); document.documentElement.style.setProperty('--player-canvas-height', height + 'px');
} }
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', '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) { function sanitizeRichTextAttributes(tagName, attrText) {
const allowedAttributes = { const allowedAttributes = {
@@ -349,14 +349,19 @@ function sanitizeRichTextAttributes(tagName, attrText) {
h4: ['class', 'style'], h4: ['class', 'style'],
h5: ['class', 'style'], h5: ['class', 'style'],
h6: ['class', 'style'], h6: ['class', 'style'],
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
col: ['class', 'style', 'span', 'width'],
colgroup: ['class', 'style', 'span'],
li: ['class', 'style'], li: ['class', 'style'],
ol: ['class', 'style', 'start'], ol: ['class', 'style', 'start'],
p: ['class', 'style'], p: ['class', 'style'],
pre: ['class', 'style'], pre: ['class', 'style'],
span: ['class', 'style'], span: ['class', 'style'],
table: ['class', 'style'], table: ['class', 'style'],
tbody: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'], td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'], th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
thead: ['class', 'style'],
tr: ['class', 'style'], tr: ['class', 'style'],
ul: ['class', 'style'] ul: ['class', 'style']
}; };
@@ -365,6 +370,14 @@ function sanitizeRichTextAttributes(tagName, attrText) {
return ''; 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 = []; const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => { String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
const lowerKey = String(key || '').toLowerCase(); const lowerKey = String(key || '').toLowerCase();
+36 -3
View File
@@ -2,16 +2,49 @@
var registry = window.pulsePlayerRegionTypes; 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;}</style></head><body>' + raw + '</body></html>';
}
function renderHtmlRegionContent(value) { function renderHtmlRegionContent(value) {
var html = String(value || '').trim(); var html = normalizeRenderableValue(value).trim();
if (!html) { if (!html) {
return ''; return '';
} }
return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML region" loading="eager"></iframe>'; return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="' + escapeHtml(buildHtmlDocument(html)) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
} }
function renderHtmlRegion(region, regionContent) { function renderHtmlRegion(region, regionContent) {
return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent.value || '') + '</div>'; return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent && regionContent.value !== undefined ? regionContent.value : '') + '</div>';
} }
registry.register('html', { registry.register('html', {
+21 -28
View File
@@ -1,6 +1,7 @@
// Time/date region rendering and live updates. // Time/date region rendering and live updates.
var registry = window.pulsePlayerRegionTypes; var registry = window.pulsePlayerRegionTypes;
var placeholderUtils = window.placeholderUtils || {};
var DEFAULT_FORMAT = '{{hh}}:{{mm}}'; var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
var DEFAULT_STYLE = { var DEFAULT_STYLE = {
font_family: 'Arial', font_family: 'Arial',
@@ -9,15 +10,6 @@ var DEFAULT_STYLE = {
}; };
var timeDateFormatterCache = Object.create(null); var timeDateFormatterCache = Object.create(null);
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeTagAttributes(tagName, attrText) { function sanitizeTagAttributes(tagName, attrText) {
var allowedAttributes = { var allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'], a: ['href', 'title', 'target', 'rel', 'class', 'style'],
@@ -133,7 +125,7 @@ function resolveTimeZone(value) {
} }
} }
function getFormatter(key, options) { function getTimeDateFormatter(key, options) {
if (!timeDateFormatterCache[key]) { if (!timeDateFormatterCache[key]) {
timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options); timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options);
} }
@@ -141,10 +133,10 @@ function getFormatter(key, options) {
return timeDateFormatterCache[key]; return timeDateFormatterCache[key];
} }
function getFormattedParts(timeZone, date) { function getTimeDateFormattedParts(timeZone, date) {
var targetDate = date instanceof Date ? date : new Date(); var targetDate = date instanceof Date ? date : new Date();
var resolvedTimeZone = resolveTimeZone(timeZone); var resolvedTimeZone = resolveTimeZone(timeZone);
var numericParts = getFormatter('numeric:' + resolvedTimeZone, { var numericParts = getTimeDateFormatter('numeric:' + resolvedTimeZone, {
timeZone: resolvedTimeZone, timeZone: resolvedTimeZone,
hour12: false, hour12: false,
hour: '2-digit', hour: '2-digit',
@@ -154,29 +146,29 @@ function getFormattedParts(timeZone, date) {
month: '2-digit', month: '2-digit',
year: 'numeric' year: 'numeric'
}).formatToParts(targetDate); }).formatToParts(targetDate);
var weekdayLong = getFormatter('weekday-long:' + resolvedTimeZone, { var weekdayLong = getTimeDateFormatter('weekday-long:' + resolvedTimeZone, {
timeZone: resolvedTimeZone, timeZone: resolvedTimeZone,
weekday: 'long' weekday: 'long'
}).formatToParts(targetDate); }).formatToParts(targetDate);
var weekdayShort = getFormatter('weekday-short:' + resolvedTimeZone, { var weekdayShort = getTimeDateFormatter('weekday-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone, timeZone: resolvedTimeZone,
weekday: 'short' weekday: 'short'
}).formatToParts(targetDate); }).formatToParts(targetDate);
var monthLong = getFormatter('month-long:' + resolvedTimeZone, { var monthLong = getTimeDateFormatter('month-long:' + resolvedTimeZone, {
timeZone: resolvedTimeZone, timeZone: resolvedTimeZone,
month: 'long' month: 'long'
}).formatToParts(targetDate); }).formatToParts(targetDate);
var monthShort = getFormatter('month-short:' + resolvedTimeZone, { var monthShort = getTimeDateFormatter('month-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone, timeZone: resolvedTimeZone,
month: 'short' month: 'short'
}).formatToParts(targetDate); }).formatToParts(targetDate);
var ampm = getFormatter('ampm:' + resolvedTimeZone, { var ampm = getTimeDateFormatter('ampm:' + resolvedTimeZone, {
timeZone: resolvedTimeZone, timeZone: resolvedTimeZone,
hour12: true, hour12: true,
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
}).formatToParts(targetDate); }).formatToParts(targetDate);
var timezoneShort = getFormatter('tz-short:' + resolvedTimeZone, { var timezoneShort = getTimeDateFormatter('tz-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone, timeZone: resolvedTimeZone,
timeZoneName: 'short' timeZoneName: 'short'
}).formatToParts(targetDate); }).formatToParts(targetDate);
@@ -213,27 +205,28 @@ function getFormattedParts(timeZone, date) {
MMM: toTitleCase(getPart(monthShort, 'month')), MMM: toTitleCase(getPart(monthShort, 'month')),
MMMM: toTitleCase(getPart(monthLong, 'month')), MMMM: toTitleCase(getPart(monthLong, 'month')),
a: toTitleCase(getPart(ampm, 'dayPeriod')), a: toTitleCase(getPart(ampm, 'dayPeriod')),
tz: resolvedTimeZone, tz: getPart(timezoneShort, 'timeZoneName'),
tz_short: getPart(timezoneShort, 'timeZoneName'), tz_long: resolvedTimeZone,
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'), date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second') time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
}; };
} }
function resolveTimeDatePlaceholder(values, expression) { function resolveTimeDateTemplatePlaceholder(values, expression) {
if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') { var currentPlaceholderUtils = window.placeholderUtils || placeholderUtils || {};
return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression)); if (typeof currentPlaceholderUtils.resolvePlaceholderExpression === 'function' && typeof currentPlaceholderUtils.formatPlaceholderValue === 'function') {
return currentPlaceholderUtils.formatPlaceholderValue(currentPlaceholderUtils.resolvePlaceholderExpression(values, expression));
} }
var parsed = String(expression || '').trim(); var parsed = String(expression || '').trim();
return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : ''; return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : '';
} }
function renderTemplate(format, timeZone, date) { function renderTimeDateTemplate(format, timeZone, date) {
var template = String(format || '').trim() || DEFAULT_FORMAT; var template = String(format || '').trim() || DEFAULT_FORMAT;
var values = getFormattedParts(timeZone, date); var values = getTimeDateFormattedParts(timeZone, date);
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) { return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
return String(resolveTimeDatePlaceholder(values, key) || ''); return String(resolveTimeDateTemplatePlaceholder(values, key, { timeZone: timeZone }) || '');
}); });
} }
@@ -257,7 +250,7 @@ function renderTimeDateRegion(region, regionContent) {
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : ''; 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 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 contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? fontFamily : '') + fontSize + fontColor + 'white-space:pre-wrap;line-height:1.1;';
var renderedText = renderTemplate(format, timeZone, new Date()); 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>'; 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>';
} }
@@ -273,7 +266,7 @@ function updateTimeDateRegion(element) {
return; return;
} }
scaleWrapper.innerHTML = renderEditorJsContent(renderTemplate(format, timeZone, new Date())); scaleWrapper.innerHTML = renderEditorJsContent(renderTimeDateTemplate(format, timeZone, new Date()));
} }
function scheduleTimeDateRegionUpdate(element) { function scheduleTimeDateRegionUpdate(element) {
@@ -2,101 +2,6 @@
var registry = window.pulsePlayerRegionTypes; var registry = window.pulsePlayerRegionTypes;
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeRichTextAttributes(tagName, attrText) {
var allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
col: ['class', 'style', 'span', 'width'],
colgroup: ['class', 'style', 'span'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
tbody: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
thead: ['class', 'style'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
var allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
var attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
var lowerKey = String(key || '').toLowerCase();
if (allowed.indexOf(lowerKey) === -1) {
return '';
}
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target') {
var targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
return '';
});
return attrs.join('');
}
function sanitizeRichText(html) {
var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, function (tag) {
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
var closing = Boolean(match[1]);
var name = String(match[2] || '').toLowerCase();
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
if (allowed.indexOf(name) === -1) {
return '';
}
if (closing) {
return '</' + name + '>';
}
return '<' + name + sanitizeRichTextAttributes(name, String(match[3] || '')) + '>';
});
}
function substituteTimetableVariables(html, entry) { function substituteTimetableVariables(html, entry) {
var source = String(html || ''); var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
@@ -228,6 +133,7 @@ function renderRegion(region, regionContent) {
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups); var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, 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 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 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 canvasScale = Number(region && region.canvasScale ? region.canvasScale : 1) || 1;
@@ -241,6 +147,7 @@ function renderRegion(region, regionContent) {
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(substituteTimetableVariables(value, Object.assign({}, entry || {}, { 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 : '', start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '', end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
timeZone: timeZone,
group: group || {}, group: group || {},
entries: entries, entries: entries,
index: index + 1 index: index + 1
+28 -2
View File
@@ -2,12 +2,38 @@
var registry = window.pulsePlayerRegionTypes; 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) { function renderWebpageRegion(region, regionContent) {
var url = String(regionContent.value || '').trim(); var url = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '').trim();
if (!url) { if (!url) {
return ''; return '';
} }
return '<div class="template-region webpage" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>'; 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', { registry.register('webpage', {
+47 -3
View File
@@ -41,7 +41,7 @@ function sanitizeTextColor(value, fallback) {
return fallback || '#000000'; 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) { function sanitizeRichTextAttributes(tagName, attrText) {
const allowedAttributes = { const allowedAttributes = {
@@ -56,6 +56,9 @@ function sanitizeRichTextAttributes(tagName, attrText) {
h4: ['class', 'style'], h4: ['class', 'style'],
h5: ['class', 'style'], h5: ['class', 'style'],
h6: ['class', 'style'], h6: ['class', 'style'],
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
col: ['class', 'style', 'span', 'width'],
colgroup: ['class', 'style', 'span'],
li: ['class', 'style'], li: ['class', 'style'],
ol: ['class', 'style', 'start'], ol: ['class', 'style', 'start'],
p: ['class', 'style'], p: ['class', 'style'],
@@ -72,6 +75,14 @@ function sanitizeRichTextAttributes(tagName, attrText) {
return ''; 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 = []; const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => { String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
const lowerKey = String(key || '').toLowerCase(); const lowerKey = String(key || '').toLowerCase();
@@ -268,12 +279,45 @@ function renderEditorJsContent(value) {
return wrapRichTextParagraph(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) { function renderHtmlRegionContent(value) {
const html = String(value || '').trim(); const html = normalizeRenderableValue(value).trim();
if (!html) { if (!html) {
return '<div class="template-region-placeholder">HTML</div>'; 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) { function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
+26 -18
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth'); const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { getPlayerPublicBaseUrl } = require('./onboarding');
const { buildThumbnailPreviewData } = require('./thumbnail-preview'); const { buildThumbnailPreviewData } = require('./thumbnail-preview');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED']; const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
@@ -31,23 +32,23 @@ function registerPlayerRoutes(app, options) {
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null; const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null; const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null; const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null;
const onPlayerPublicBaseUrl = typeof options.onPlayerPublicBaseUrl === 'function' ? options.onPlayerPublicBaseUrl : null;
if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) { if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) {
throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.'); throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.');
} }
if (!thinClientBaseUrl && (!pool || !playerPlaylistService)) { if (!bridgeBaseUrl && (!pool || !playerPlaylistService)) {
throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless thinClientBaseUrl is configured.'); throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless bridgeBaseUrl is configured.');
} }
const sharedSecret = getSharedSecret(); const sharedSecret = getSharedSecret();
async function fetchThinClient(req, pathname, options) { async function fetchBridge(req, pathname, options) {
if (!thinClientBaseUrl) { if (!bridgeBaseUrl) {
return null; return null;
} }
@@ -71,7 +72,7 @@ function registerPlayerRoutes(app, options) {
headers['content-type'] = requestOptions.contentType; headers['content-type'] = requestOptions.contentType;
} }
return fetch(new URL(pathname, thinClientBaseUrl).toString(), { return fetch(new URL(pathname, bridgeBaseUrl).toString(), {
method: method, method: method,
headers: headers, headers: headers,
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
@@ -179,8 +180,8 @@ function registerPlayerRoutes(app, options) {
}); });
app.get('/api/media/config', requireRequestAuth, function (_req, res) { app.get('/api/media/config', requireRequestAuth, function (_req, res) {
if (thinClientBaseUrl) { if (bridgeBaseUrl) {
void fetch(new URL('/api/media/config', thinClientBaseUrl).toString(), { void fetch(new URL('/api/media/config', bridgeBaseUrl).toString(), {
method: 'GET', method: 'GET',
headers: createRequestAuthHeaders({ headers: createRequestAuthHeaders({
method: 'GET', method: 'GET',
@@ -296,11 +297,18 @@ function registerPlayerRoutes(app, options) {
}); });
app.get('/screen/:slug', function (req, res) { app.get('/screen/:slug', function (req, res) {
if (onPlayerPublicBaseUrl) {
try {
onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null));
} catch (_error) {
}
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache'); res.set('Pragma', 'no-cache');
if (thinClientBaseUrl) { if (bridgeBaseUrl) {
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(req.params.slug || '').trim() }).token; const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(req.params.slug || '').trim() }).token;
void fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), { void fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), {
method: 'GET', method: 'GET',
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {} headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
}).then(async function (response) { }).then(async function (response) {
@@ -342,8 +350,8 @@ function registerPlayerRoutes(app, options) {
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) { app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
try { try {
if (thinClientBaseUrl) { if (bridgeBaseUrl) {
const response = await fetchThinClient(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', { const response = await fetchBridge(req, '/api/internal/slide-thumbnails/' + encodeURIComponent(req.params.id) + '/preview', {
method: 'GET' method: 'GET'
}); });
if (!response) { if (!response) {
@@ -397,8 +405,8 @@ function registerPlayerRoutes(app, options) {
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) { app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
try { try {
if (thinClientBaseUrl) { if (bridgeBaseUrl) {
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', { const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
method: 'GET' method: 'GET'
}); });
if (!response) { if (!response) {
@@ -440,8 +448,8 @@ function registerPlayerRoutes(app, options) {
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) { app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
try { try {
if (thinClientBaseUrl) { if (bridgeBaseUrl) {
const response = await fetchThinClient(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', { const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
method: 'GET' method: 'GET'
}); });
if (!response) { if (!response) {
+88 -15
View File
@@ -21,6 +21,7 @@ function normalizePlayerPublicBaseUrl(pageUrl) {
function createPlayerRuntime(options) { function createPlayerRuntime(options) {
const pool = options && options.pool ? options.pool : null; const pool = options && options.pool ? options.pool : null;
const notifySnapshot = typeof options.notifySnapshot === 'function' ? options.notifySnapshot : null;
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function' const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
? options.normalizeDeviceId ? options.normalizeDeviceId
: function (value) { : function (value) {
@@ -44,6 +45,76 @@ function createPlayerRuntime(options) {
return ip; 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) { function parseCookies(cookieHeader) {
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) { return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
if (!pair) { if (!pair) {
@@ -144,7 +215,6 @@ function createPlayerRuntime(options) {
const clientName = String(connection.clientName || '').trim(); const clientName = String(connection.clientName || '').trim();
const clientId = String(connection.clientId || '').trim(); const clientId = String(connection.clientId || '').trim();
const userAgent = String(connection.userAgent || '').trim(); const userAgent = String(connection.userAgent || '').trim();
const clientIp = String(connection.clientIp || '').trim();
const viewport = connection.viewport && typeof connection.viewport === 'object' const viewport = connection.viewport && typeof connection.viewport === 'object'
? connection.viewport ? connection.viewport
: null; : null;
@@ -160,10 +230,6 @@ function createPlayerRuntime(options) {
labelParts.push(`id ${clientId.slice(-6)}`); labelParts.push(`id ${clientId.slice(-6)}`);
} }
if (clientIp) {
labelParts.push(clientIp);
}
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) { if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`); labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
} }
@@ -197,8 +263,6 @@ function createPlayerRuntime(options) {
blackout: Boolean(connection.blackout), blackout: Boolean(connection.blackout),
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null, currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : 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, connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
}; };
@@ -222,6 +286,10 @@ function createPlayerRuntime(options) {
return allConnections; return allConnections;
} }
function snapshotSlugs() {
return Array.from(connectionsBySlug.keys());
}
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) { async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections()); return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections());
} }
@@ -229,6 +297,16 @@ function createPlayerRuntime(options) {
function broadcastConnectionSnapshot(slug) { function broadcastConnectionSnapshot(slug) {
const key = String(slug || '').trim(); const key = String(slug || '').trim();
const bucket = dashboardListenersBySlug.get(key); const bucket = dashboardListenersBySlug.get(key);
const connections = snapshotConnections(slug);
if (notifySnapshot) {
try {
notifySnapshot({
slug: key,
connections: connections
});
} catch (_error) {
}
}
if (!bucket || !bucket.size) { if (!bucket || !bucket.size) {
return; return;
} }
@@ -236,7 +314,7 @@ function createPlayerRuntime(options) {
const payload = JSON.stringify({ const payload = JSON.stringify({
type: 'snapshot', type: 'snapshot',
slug: key, slug: key,
connections: snapshotConnections(slug), connections: connections,
sentAt: new Date().toISOString() sentAt: new Date().toISOString()
}); });
@@ -416,9 +494,6 @@ function createPlayerRuntime(options) {
return; 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);
const connectionId = crypto.randomUUID(); const connectionId = crypto.randomUUID();
const connection = { const connection = {
id: connectionId, id: connectionId,
@@ -433,9 +508,7 @@ function createPlayerRuntime(options) {
paused: false, paused: false,
blackout: false, blackout: false,
playerPublicBaseUrl: null, playerPublicBaseUrl: null,
clientIp: forwardedFor || normalizedRemoteAddress, label: 'connected client',
remoteAddress: normalizedRemoteAddress,
label: forwardedFor || normalizedRemoteAddress || 'connected client',
connectedAt: new Date(), connectedAt: new Date(),
lastSeenAt: new Date() lastSeenAt: new Date()
}; };
@@ -476,7 +549,6 @@ function createPlayerRuntime(options) {
} }
connection.paused = Boolean(payload.paused); connection.paused = Boolean(payload.paused);
connection.blackout = Boolean(payload.blackout); connection.blackout = Boolean(payload.blackout);
connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp;
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? { connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
id: payload.currentSlide.id || null, id: payload.currentSlide.id || null,
title: payload.currentSlide.title || '', title: payload.currentSlide.title || '',
@@ -508,6 +580,7 @@ function createPlayerRuntime(options) {
broadcastAnnouncementRefresh: broadcastAnnouncementRefresh, broadcastAnnouncementRefresh: broadcastAnnouncementRefresh,
snapshotConnections: snapshotConnections, snapshotConnections: snapshotConnections,
snapshotAllConnections: snapshotAllConnections, snapshotAllConnections: snapshotAllConnections,
snapshotSlugs: snapshotSlugs,
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen, isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
sendCommandToConnection: sendCommandToConnection, sendCommandToConnection: sendCommandToConnection,
broadcastCommand: broadcastCommand broadcastCommand: broadcastCommand
+7 -4
View File
@@ -194,18 +194,21 @@ const PERMISSION_SECTIONS = [
} }
]; ];
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) { const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section, sectionIndex) {
return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource) { return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource, resourceIndex) {
return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, index) { return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, actionIndex) {
return { return {
key: `${resource.key}.${action.key}`, key: `${resource.key}.${action.key}`,
name: resource.name, name: resource.name,
sectionOrder: section.order, sectionOrder: section.order,
sectionIndex: sectionIndex,
actionName: action.name, actionName: action.name,
permissionOrder: index + 1, permissionOrder: actionIndex + 1,
permissionIndex: actionIndex,
sectionName: section.sectionName, sectionName: section.sectionName,
resourceKey: resource.key, resourceKey: resource.key,
resourceOrder: resource.order, resourceOrder: resource.order,
resourceIndex: resourceIndex,
resourceName: resource.name, resourceName: resource.name,
actionKey: action.key, actionKey: action.key,
description: action.description description: action.description
+6 -4
View File
@@ -62,7 +62,8 @@ async function start() {
const playerActionService = createPlayerActionService({ const playerActionService = createPlayerActionService({
pool: pool, pool: pool,
common: common, common: common,
playerInternalBaseUrl: webConfig.thinClientBaseUrl playerInternalBaseUrl: webConfig.playerInternalUrl,
bridgeInternalBaseUrl: webConfig.bridgeInternalUrl
}); });
const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand); const notifyPlayerScreens = createNotifyPlayerScreens(playerActionService.forwardPlayerCommand);
@@ -70,8 +71,8 @@ async function start() {
const webBootstrap = createWebBootstrap({ const webBootstrap = createWebBootstrap({
pool: pool, pool: pool,
common: common, common: common,
playerInternalBaseUrl: webConfig.playerInternalBaseUrl, playerInternalBaseUrl: webConfig.playerInternalUrl,
thinClientBaseUrl: webConfig.thinClientBaseUrl, bridgeInternalBaseUrl: webConfig.bridgeInternalUrl,
uploadDir: webConfig.uploadsDir, uploadDir: webConfig.uploadsDir,
formatDashboardDate: formatDashboardDate, formatDashboardDate: formatDashboardDate,
notifyPlayerScreens: notifyPlayerScreens, notifyPlayerScreens: notifyPlayerScreens,
@@ -206,11 +207,12 @@ async function start() {
mediaDir: webConfig.mediaDir, mediaDir: webConfig.mediaDir,
backgroundTaskQueue: backgroundTaskQueue, backgroundTaskQueue: backgroundTaskQueue,
webBootstrap: webBootstrap, webBootstrap: webBootstrap,
notifyPlayerScreens: notifyPlayerScreens,
loadCurrentUser: loadCurrentUser, loadCurrentUser: loadCurrentUser,
initializeBackgroundTasks: initializeBackgroundTasks, initializeBackgroundTasks: initializeBackgroundTasks,
captureSlideThumbnail: captureSlideThumbnail, captureSlideThumbnail: captureSlideThumbnail,
server: server, server: server,
webBaseUrl: webConfig.webBaseUrl, webBaseUrl: webConfig.webInternalUrl,
dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs dataSourceStartupRefreshStaggerMs: webConfig.dataSourceStartupRefreshStaggerMs
}); });
+6 -6
View File
@@ -8,8 +8,8 @@ const { createRequestAuthHeaders } = require('#src/request-auth');
function createWebBootstrap(options) { function createWebBootstrap(options) {
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; const common = options && options.common;
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); const configuredPlayerInternalUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const configuredThinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, ''); const configuredBridgeInternalUrl = String(options && options.bridgeInternalBaseUrl || process.env.BRIDGE_INTERNAL_URL || '').trim().replace(/\/$/, '');
const uploadDir = String(options && options.uploadDir || '').trim(); const uploadDir = String(options && options.uploadDir || '').trim();
const dashboardRefreshIntervalMs = 5000; const dashboardRefreshIntervalMs = 5000;
const formatDashboardDate = options && options.formatDashboardDate; const formatDashboardDate = options && options.formatDashboardDate;
@@ -27,7 +27,7 @@ function createWebBootstrap(options) {
let dashboardRefreshInFlight = null; let dashboardRefreshInFlight = null;
let broadcastDashboardState = null; let broadcastDashboardState = null;
function getPlayerSnapshotSocketUrl(slug) { function getPlayerSnapshotSocketUrl(slug) {
const resolvedPlayerInternalBaseUrl = configuredThinClientBaseUrl || configuredPlayerInternalBaseUrl; const resolvedPlayerInternalBaseUrl = configuredBridgeInternalUrl || configuredPlayerInternalUrl;
if (!resolvedPlayerInternalBaseUrl) { if (!resolvedPlayerInternalBaseUrl) {
throw new Error('Unable to resolve the player internal base URL.'); throw new Error('Unable to resolve the player internal base URL.');
} }
@@ -109,7 +109,7 @@ function createWebBootstrap(options) {
const dashboardStateService = createDashboardStateService({ const dashboardStateService = createDashboardStateService({
pool: pool, pool: pool,
common: common, common: common,
thinClientBaseUrl: configuredThinClientBaseUrl, thinClientBaseUrl: configuredBridgeInternalUrl,
playerSnapshotCache: playerSnapshotCache, playerSnapshotCache: playerSnapshotCache,
playerSnapshotSockets: playerSnapshotSockets, playerSnapshotSockets: playerSnapshotSockets,
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription, ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
@@ -120,7 +120,7 @@ function createWebBootstrap(options) {
const uploadSyncService = createUploadSyncService({ const uploadSyncService = createUploadSyncService({
pool: pool, pool: pool,
common: common, common: common,
playerInternalBaseUrl: configuredPlayerInternalBaseUrl, playerInternalBaseUrl: configuredPlayerInternalUrl,
playerSnapshotCache: playerSnapshotCache, playerSnapshotCache: playerSnapshotCache,
notifyPlayerScreens: notifyPlayerScreens, notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue backgroundTaskQueue: backgroundTaskQueue
@@ -253,7 +253,7 @@ function createWebBootstrap(options) {
return { return {
upload: upload, upload: upload,
uploadSyncService: uploadSyncService, uploadSyncService: uploadSyncService,
playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null, playerInternalBaseUrl: configuredPlayerInternalUrl || null,
buildDashboardState: buildDashboardState, buildDashboardState: buildDashboardState,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
+7 -1
View File
@@ -52,7 +52,13 @@ function registerStartupTasks(options) {
} }
async function initialize() { async function initialize() {
await loadTaskModules(backgroundTaskDirectory, options); const startupTaskFile = path.join(backgroundTaskDirectory, 'data-source-refresh.js');
const taskModule = require(startupTaskFile);
const exported = getTaskExport(taskModule);
if (typeof exported === 'function') {
await exported(options);
}
} }
return { return {
+3
View File
@@ -19,6 +19,9 @@ function normalizeIntervalMs(value, unit) {
if (normalizedUnit === 'seconds') { if (normalizedUnit === 'seconds') {
return numericValue * 1000; return numericValue * 1000;
} }
if (normalizedUnit === 'hours') {
return numericValue * 60 * 60 * 1000;
}
return numericValue * 60 * 1000; return numericValue * 60 * 1000;
} }
@@ -24,7 +24,7 @@ function registerDataSourceRefreshTask(options) {
if (!apiSource) { if (!apiSource) {
throw new Error('API source not found.'); throw new Error('API source not found.');
} }
return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null); return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null, options.notifyPlayerScreens);
} }
if (sourceType === 'rss-feed') { if (sourceType === 'rss-feed') {
@@ -32,7 +32,7 @@ function registerDataSourceRefreshTask(options) {
if (!rssFeed) { if (!rssFeed) {
throw new Error('RSS feed not found.'); throw new Error('RSS feed not found.');
} }
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null); return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null, options.notifyPlayerScreens);
} }
throw new Error('Unsupported data source refresh task.'); throw new Error('Unsupported data source refresh task.');
@@ -9,6 +9,7 @@ function registerFontSyncTask(options) {
backgroundTaskQueue.setTaskHandler('font-sync', async function (task) { backgroundTaskQueue.setTaskHandler('font-sync', async function (task) {
const payload = task && task.payload ? task.payload : task || {}; const payload = task && task.payload ? task.payload : task || {};
const uploadDir = String(payload.uploadDir || '').trim(); const uploadDir = String(payload.uploadDir || '').trim();
const playerIdentifier = String(payload.playerIdentifier || payload.deviceId || '').trim();
const operations = Array.isArray(payload.operations) const operations = Array.isArray(payload.operations)
? payload.operations ? payload.operations
: Array.isArray(payload.uploadPaths) : Array.isArray(payload.uploadPaths)
@@ -28,9 +29,9 @@ function registerFontSyncTask(options) {
continue; continue;
} }
if (String(operation.type || '').trim().toLowerCase() === 'delete') { if (String(operation.type || '').trim().toLowerCase() === 'delete') {
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir); await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
} else { } else {
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir); await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier);
} }
} }
}); });
@@ -34,7 +34,7 @@ function registerRecurringDataSourceRefreshes(options) {
sourceName: apiSource.name sourceName: apiSource.name
}, },
run: function () { run: function () {
return refreshApiSource(pool, common, apiSource, null); return refreshApiSource(pool, common, apiSource, null, options.notifyPlayerScreens);
} }
}); });
}); });
@@ -52,7 +52,7 @@ function registerRecurringDataSourceRefreshes(options) {
sourceName: rssFeed.name sourceName: rssFeed.name
}, },
run: function () { run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null, options.notifyPlayerScreens);
} }
}); });
}); });
@@ -115,11 +115,11 @@ function createDataSourceTaskService(options) {
} }
async function refreshApiSourceInBackground(apiSourceId, actorId) { async function refreshApiSourceInBackground(apiSourceId, actorId) {
return refreshApiSource(pool, common, apiSourceId, actorId); return refreshApiSource(pool, common, apiSourceId, actorId, options.notifyPlayerScreens);
} }
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) { async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId); return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, options.notifyPlayerScreens);
} }
return { return {
@@ -18,57 +18,50 @@ function registerFontSweepTask(options) {
const uploadSyncService = options && options.uploadSyncService; const uploadSyncService = options && options.uploadSyncService;
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer; const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer; const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
const getPlayerTaskMetadata = uploadSyncService && uploadSyncService.getPlayerTaskMetadata;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) { if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
throw new Error('registerFontSweepTask requires the font sweep dependencies.'); throw new Error('registerFontSweepTask requires the font sweep dependencies.');
} }
const metadataPromise = typeof getPlayerTaskMetadata === 'function' backgroundTaskQueue.registerRecurringTask({
? Promise.resolve(getPlayerTaskMetadata()) key: TASK.key,
: Promise.resolve({}); title: TASK.title,
category: TASK.category,
intervalMs: TASK.intervalMs,
metadata: {
mediaDir: mediaDir
},
run: async function () {
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
return operation && operation.uploadPath ? operation.uploadPath : '';
}).filter(Boolean));
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
return metadataPromise.then(function (metadata) { for (let i = 0; i < desiredOperations.length; i += 1) {
backgroundTaskQueue.registerRecurringTask({ const operation = desiredOperations[i] || {};
key: TASK.key, const uploadPath = String(operation.uploadPath || '').trim();
title: TASK.title, if (!uploadPath) {
category: TASK.category, continue;
intervalMs: TASK.intervalMs,
metadata: Object.assign({
mediaDir: mediaDir
}, metadata || {}),
run: async function () {
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
return operation && operation.uploadPath ? operation.uploadPath : '';
}).filter(Boolean));
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
for (let i = 0; i < desiredOperations.length; i += 1) {
const operation = desiredOperations[i] || {};
const uploadPath = String(operation.uploadPath || '').trim();
if (!uploadPath) {
continue;
}
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
await removeUploadFileFromPlayer(uploadPath, mediaDir);
} else {
await pushUploadFileToPlayer(uploadPath, mediaDir);
}
} }
for (let i = 0; i < currentUploadPaths.length; i += 1) { if (String(operation.type || '').trim().toLowerCase() === 'delete') {
const uploadPath = String(currentUploadPaths[i] || '').trim();
if (!uploadPath || desiredUploadPaths.has(uploadPath)) {
continue;
}
await removeUploadFileFromPlayer(uploadPath, mediaDir); await removeUploadFileFromPlayer(uploadPath, mediaDir);
} else {
await pushUploadFileToPlayer(uploadPath, mediaDir);
} }
} }
});
for (let i = 0; i < currentUploadPaths.length; i += 1) {
const uploadPath = String(currentUploadPaths[i] || '').trim();
if (!uploadPath || desiredUploadPaths.has(uploadPath)) {
continue;
}
await removeUploadFileFromPlayer(uploadPath, mediaDir);
}
}
}); });
} }
@@ -0,0 +1,32 @@
const TASK = {
key: 'onboarding-device-prune',
title: 'Onboarding device prune',
category: 'cleanup',
trigger: 'scheduled recurring task, hourly',
purpose: 'remove stale onboarding device bindings that have been idle for more than one minute.',
taskType: 'recurring-run',
intervalMs: 60 * 60 * 1000
};
function registerOnboardingDevicePruneTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const pool = options && options.pool;
const common = options && options.common;
if (!backgroundTaskQueue || !pool || !common || typeof common.pruneStaleOnboardingDevices !== 'function') {
throw new Error('registerOnboardingDevicePruneTask requires the onboarding prune dependencies.');
}
backgroundTaskQueue.registerRecurringTask({
key: TASK.key,
title: TASK.title,
category: TASK.category,
intervalMs: TASK.intervalMs,
metadata: {},
run: async function () {
await common.pruneStaleOnboardingDevices(pool);
}
});
}
module.exports = { registerOnboardingDevicePruneTask };
@@ -33,13 +33,13 @@ function scheduleStartupDataSourceRefreshes(options) {
(apiSourcesData.apiSources || []).forEach(function (apiSource) { (apiSourcesData.apiSources || []).forEach(function (apiSource) {
startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () { startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () {
return refreshApiSource(pool, common, apiSource, null); return refreshApiSource(pool, common, apiSource, null, options.notifyPlayerScreens);
})); }));
}); });
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) { (rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () { startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null); return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null, options.notifyPlayerScreens);
})); }));
}); });
@@ -1,16 +1,40 @@
const { collectFontLibrarySyncOperations } = require('../../media/font-library'); const { collectFontLibrarySyncOperations } = require('../../media/font-library');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const TASK = { const TASK = {
key: 'initial-font-sync', key: 'initial-font-sync',
category: 'fonts' category: 'fonts'
}; };
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
function normalizePlayerMetadata(player) {
const playerIdentifier = String(player && player.identifier || '').trim();
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim();
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim();
return {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null,
playerActive: true
};
}
function registerInitialFontSyncTask(options) { function registerInitialFontSyncTask(options) {
const pool = options && options.pool;
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService; const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) { if (!pool || !backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.'); throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
} }
@@ -18,20 +42,45 @@ function registerInitialFontSyncTask(options) {
? uploadSyncService.getPlayerTaskMetadata() ? uploadSyncService.getPlayerTaskMetadata()
: Promise.resolve({}); : Promise.resolve({});
return Promise.resolve(metadataPromise).then(function (metadata) { return Promise.resolve(metadataPromise).then(async function () {
return backgroundTaskQueue.enqueueTask({ let players = [];
key: TASK.key, try {
title: 'Initial font sync', players = await fetchPlayerRegistrations(pool);
category: TASK.category, } catch (error) {
taskType: 'font-sync', console.warn('Unable to fetch player registrations for initial font sync:', error);
metadata: Object.assign({}, metadata || {}), return null;
payload: { }
mode: 'initial',
uploadDir: mediaDir, const livePlayers = Array.isArray(players)
operations: collectFontLibrarySyncOperations(mediaDir) ? players.filter(function (player) {
}, return isRecentPlayerRegistration(player, 60);
persist: true })
}); : [];
if (!livePlayers.length) {
return null;
}
const operations = collectFontLibrarySyncOperations(mediaDir);
return Promise.all(livePlayers.map(function (player) {
const metadata = normalizePlayerMetadata(player);
return backgroundTaskQueue.enqueueTask({
key: TASK.key,
title: 'Initial font sync',
category: TASK.category,
taskType: 'font-sync',
metadata: metadata,
payload: {
mode: 'initial',
uploadDir: mediaDir,
operations: operations,
playerIdentifier: metadata.playerIdentifier,
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
playerInternalBaseUrl: metadata.playerInternalBaseUrl
},
persist: true
});
}));
}).catch(function (error) { }).catch(function (error) {
console.warn('Unable to queue initial font sync:', error); console.warn('Unable to queue initial font sync:', error);
}); });
@@ -3,12 +3,37 @@ const TASK = {
category: 'media-sync', category: 'media-sync',
}; };
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
function normalizePlayerMetadata(player) {
const playerIdentifier = String(player && player.identifier || '').trim();
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim();
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim();
return {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null,
playerActive: true
};
}
function registerInitialMediaSyncTask(options) { function registerInitialMediaSyncTask(options) {
const pool = options && options.pool;
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
const mediaDir = String(options && options.mediaDir || '').trim(); const mediaDir = String(options && options.mediaDir || '').trim();
const uploadSyncService = options && options.uploadSyncService; const uploadSyncService = options && options.uploadSyncService;
if (!backgroundTaskQueue || !mediaDir) { if (!pool || !backgroundTaskQueue || !mediaDir) {
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.'); throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
} }
@@ -16,19 +41,43 @@ function registerInitialMediaSyncTask(options) {
? uploadSyncService.getPlayerTaskMetadata() ? uploadSyncService.getPlayerTaskMetadata()
: Promise.resolve({}); : Promise.resolve({});
return Promise.resolve(metadataPromise).then(function (metadata) { return Promise.resolve(metadataPromise).then(async function () {
return backgroundTaskQueue.enqueueTask({ let players = [];
key: TASK.key, try {
title: 'Initial media sync', players = await fetchPlayerRegistrations(pool);
category: TASK.category, } catch (error) {
taskType: 'media-sync', console.warn('Unable to fetch player registrations for initial media sync:', error);
metadata: Object.assign({}, metadata || {}), return null;
payload: { }
mode: 'initial',
uploadDir: mediaDir const livePlayers = Array.isArray(players)
}, ? players.filter(function (player) {
persist: true return isRecentPlayerRegistration(player, 60);
}); })
: [];
if (!livePlayers.length) {
return null;
}
return Promise.all(livePlayers.map(function (player) {
const metadata = normalizePlayerMetadata(player);
return backgroundTaskQueue.enqueueTask({
key: TASK.key,
title: 'Initial media sync',
category: TASK.category,
taskType: 'media-sync',
metadata: metadata,
payload: {
mode: 'initial',
uploadDir: mediaDir,
playerIdentifier: metadata.playerIdentifier,
playerPublicBaseUrl: metadata.playerPublicBaseUrl,
playerInternalBaseUrl: metadata.playerInternalBaseUrl
},
persist: true
});
}));
}).catch(function (error) { }).catch(function (error) {
console.warn('Unable to queue initial media sync:', error); console.warn('Unable to queue initial media sync:', error);
}); });
+6 -6
View File
@@ -5,9 +5,9 @@ function createWebConfig() {
const uploadsDir = path.join(mediaDir, 'uploads'); const uploadsDir = path.join(mediaDir, 'uploads');
const thumbnailsDir = path.join(mediaDir, 'thumbnails'); const thumbnailsDir = path.join(mediaDir, 'thumbnails');
const assetDir = path.join(__dirname, '..', 'public'); const assetDir = path.join(__dirname, '..', 'public');
const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, ''); const playerInternalUrl = (process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
const thinClientBaseUrl = (process.env.THIN_CLIENT_BASE_URL || 'http://player-bridge:8090').replace(/\/$/, ''); const bridgeInternalUrl = (process.env.BRIDGE_INTERNAL_URL || 'http://player-bridge:8090').replace(/\/$/, '');
const webBaseUrl = (process.env.WEB_BASE_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, ''); const webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, '');
const sessionCookieName = 'digital_signage_session'; const sessionCookieName = 'digital_signage_session';
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14); const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000; const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
@@ -20,9 +20,9 @@ function createWebConfig() {
uploadsDir: uploadsDir, uploadsDir: uploadsDir,
thumbnailsDir: thumbnailsDir, thumbnailsDir: thumbnailsDir,
assetDir: assetDir, assetDir: assetDir,
playerInternalBaseUrl: playerInternalBaseUrl, playerInternalUrl: playerInternalUrl,
thinClientBaseUrl: thinClientBaseUrl, bridgeInternalUrl: bridgeInternalUrl,
webBaseUrl: webBaseUrl, webInternalUrl: webInternalUrl,
sessionCookieName: sessionCookieName, sessionCookieName: sessionCookieName,
sessionMaxAgeMs: sessionMaxAgeMs, sessionMaxAgeMs: sessionMaxAgeMs,
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
+127 -2
View File
@@ -1,4 +1,112 @@
async function refreshApiSource(pool, common, apiSourceOrId, actorId) { async function getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId) {
const [slideRows] = await connection.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
const slideIds = [];
const seenSlideIds = new Set();
slideRows.forEach(function (row) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
if (!content || typeof content !== 'object') {
return;
}
const stack = [content];
while (stack.length) {
const value = stack.pop();
if (!value || typeof value !== 'object') {
continue;
}
if (Array.isArray(value)) {
value.forEach(function (item) {
stack.push(item);
});
continue;
}
if (Object.prototype.hasOwnProperty.call(value, slideMatchKey) && Number(value[slideMatchKey]) === Number(sourceId)) {
const slideId = Number(row.id);
if (Number.isFinite(slideId) && slideId > 0 && !seenSlideIds.has(slideId)) {
seenSlideIds.add(slideId);
slideIds.push(slideId);
}
break;
}
Object.keys(value).forEach(function (key) {
stack.push(value[key]);
});
}
});
if (!slideIds.length) {
return [];
}
const [screenRows] = await connection.query(
`SELECT DISTINCT s.slug
FROM d_screens s
JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
WHERE ps.slide_id IN (?)
AND s.slug IS NOT NULL`,
[slideIds]
);
return screenRows.map(function (row) {
return String(row.slug || '').trim();
}).filter(Boolean);
}
async function notifyAffectedScreens(connection, common, notifyPlayerScreens, slideMatchKey, sourceId) {
if (typeof notifyPlayerScreens !== 'function') {
return;
}
const slugs = await getAffectedScreenSlugs(connection, common, slideMatchKey, sourceId);
if (!slugs.length) {
return;
}
await notifyPlayerScreens(slugs, 'refresh');
}
function normalizeSnapshotValue(value) {
return String(value || '').trim();
}
async function hasRssFeedChanged(connection, rssFeedId, items) {
const [rows] = await connection.query(
`SELECT item_json
FROM i_rss_feed_items
WHERE rss_feed_id = ?
ORDER BY position ASC, id ASC`,
[rssFeedId]
);
const currentSnapshots = (rows || []).map(function (row) {
return normalizeSnapshotValue(row && row.item_json);
});
const nextSnapshots = (Array.isArray(items) ? items : []).map(function (item) {
return normalizeSnapshotValue(JSON.stringify(item || {}));
});
if (currentSnapshots.length !== nextSnapshots.length) {
return true;
}
for (let index = 0; index < currentSnapshots.length; index += 1) {
if (currentSnapshots[index] !== nextSnapshots[index]) {
return true;
}
}
return false;
}
function hasApiSourceChanged(apiSource, responseDetails) {
return normalizeSnapshotValue(apiSource && apiSource.last_response_json) !== normalizeSnapshotValue(responseDetails && responseDetails.responseJson);
}
async function refreshApiSource(pool, common, apiSourceOrId, actorId, notifyPlayerScreens) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object' const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object'
@@ -25,6 +133,14 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id] [new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id]
); );
await connection.commit(); await connection.commit();
if (!pullError && hasApiSourceChanged(apiSource, responseDetails)) {
try {
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'source_id', apiSource.id);
} catch (notifyError) {
console.warn('[data-source-refresh] Unable to notify players after API source refresh ' + apiSource.id + ':', notifyError);
}
}
} catch (error) { } catch (error) {
try { try {
await connection.rollback(); await connection.rollback();
@@ -37,7 +153,7 @@ async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
} }
} }
async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId) { async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId, notifyPlayerScreens) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
let updatedItems = []; let updatedItems = [];
@@ -50,11 +166,20 @@ async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actor
} }
await connection.beginTransaction(); await connection.beginTransaction();
const rssFeedChanged = await hasRssFeedChanged(connection, rssFeedId, updatedItems);
if (typeof common.replaceRssFeedItems === 'function') { if (typeof common.replaceRssFeedItems === 'function') {
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems); await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
} }
await connection.commit(); await connection.commit();
if (!pullError && rssFeedChanged) {
try {
await notifyAffectedScreens(connection, common, notifyPlayerScreens, 'feed_id', rssFeedId);
} catch (notifyError) {
console.warn('[data-source-refresh] Unable to notify players after RSS feed refresh ' + rssFeedId + ':', notifyError);
}
}
if (pullError) { if (pullError) {
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError); console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
} }
+42 -4
View File
@@ -30,6 +30,35 @@ function resolveAssetUrl(baseUrl, value) {
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, ''); return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
} }
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.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 getThumbnailCanvasSize(slide) { function getThumbnailCanvasSize(slide) {
const template = slide && slide.template ? slide.template : null; const template = slide && slide.template ? slide.template : null;
return { return {
@@ -53,7 +82,16 @@ function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
} }
function hasVisibleContent(html) { function hasVisibleContent(html) {
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim()); var raw = String(html || '').trim();
if (!raw) {
return false;
}
if (/<img\b/i.test(raw)) {
return true;
}
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
} }
function buildTextRegionMarkup(region, regionContent) { function buildTextRegionMarkup(region, regionContent) {
@@ -70,7 +108,7 @@ function buildTextRegionMarkup(region, regionContent) {
function buildRegionInnerHtml(region, regionContent, baseUrl) { function buildRegionInnerHtml(region, regionContent, baseUrl) {
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase(); const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : ''; const rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
if (regionType === 'image') { if (regionType === 'image') {
const src = resolveAssetUrl(baseUrl, rawValue); const src = resolveAssetUrl(baseUrl, rawValue);
@@ -89,7 +127,7 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
if (regionType === 'webpage') { if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue); const src = resolveAssetUrl(baseUrl, rawValue);
return src return src
? '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>' ? '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe></div>'
: ''; : '';
} }
@@ -105,7 +143,7 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
if (regionType === 'html') { if (regionType === 'html') {
const html = String(rawValue || '').trim(); const html = String(rawValue || '').trim();
return html return html
? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe></div>' ? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" allowtransparency="true" srcdoc="' + escapeHtml('<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + html + '</body></html>') + '" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe></div>'
: ''; : '';
} }
+42 -4
View File
@@ -52,6 +52,35 @@ function resolveAssetUrl(baseUrl, value) {
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, ''); return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
} }
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.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 getCanvasSize(slide) { function getCanvasSize(slide) {
const template = slide && slide.template ? slide.template : null; const template = slide && slide.template ? slide.template : null;
return { return {
@@ -83,7 +112,16 @@ function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
} }
function hasVisibleContent(html) { function hasVisibleContent(html) {
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim()); var raw = String(html || '').trim();
if (!raw) {
return false;
}
if (/<img\b/i.test(raw)) {
return true;
}
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
} }
function buildTextRegionMarkup(region, regionContent) { function buildTextRegionMarkup(region, regionContent) {
@@ -100,7 +138,7 @@ function buildTextRegionMarkup(region, regionContent) {
function buildRegionInnerHtml(region, regionContent, baseUrl) { function buildRegionInnerHtml(region, regionContent, baseUrl) {
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase(); const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : ''; const rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
if (regionType === 'image') { if (regionType === 'image') {
const src = resolveAssetUrl(baseUrl, rawValue); const src = resolveAssetUrl(baseUrl, rawValue);
@@ -119,7 +157,7 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
if (regionType === 'webpage') { if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue); const src = resolveAssetUrl(baseUrl, rawValue);
return src return src
? '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>' ? '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe></div>'
: ''; : '';
} }
@@ -135,7 +173,7 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
if (regionType === 'html') { if (regionType === 'html') {
const html = String(rawValue || '').trim(); const html = String(rawValue || '').trim();
return html return html
? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe></div>' ? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" allowtransparency="true" srcdoc="' + escapeHtml('<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + html + '</body></html>') + '" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe></div>'
: ''; : '';
} }
+195 -42
View File
@@ -36,6 +36,22 @@ function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, ''); return String(value || '').trim().replace(/\/$/, '');
} }
function appendPlayerDeviceIdToUrl(baseUrl, playerIdentifier) {
const targetBaseUrl = normalizeBaseUrl(baseUrl);
const deviceId = String(playerIdentifier || '').trim();
if (!targetBaseUrl || !deviceId) {
return targetBaseUrl;
}
try {
const url = new URL(targetBaseUrl);
url.searchParams.set('deviceId', deviceId);
return url.toString().replace(/\/$/, '');
} catch (_error) {
return targetBaseUrl;
}
}
function normalizePlayerRowBaseUrl(player) { function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url); return normalizeBaseUrl(player && player.internal_base_url);
} }
@@ -74,8 +90,8 @@ function createUploadSyncService(options) {
throw new Error('createUploadSyncService requires the upload dependencies.'); throw new Error('createUploadSyncService requires the upload dependencies.');
} }
async function getPlayerInternalBaseUrl() { async function getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
const metadata = await getPlayerTaskMetadata(); const metadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl);
if (!metadata || metadata.playerActive === false) { if (!metadata || metadata.playerActive === false) {
return null; return null;
} }
@@ -83,11 +99,44 @@ function createUploadSyncService(options) {
return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null; return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null;
} }
async function getPlayerTaskMetadata() { async function getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
if (playerTaskMetadata) { const normalizedPreferredPlayerIdentifier = String(preferredPlayerIdentifier || '').trim();
const normalizedPreferredPlayerInternalBaseUrl = String(preferredPlayerInternalBaseUrl || '').trim().replace(/\/$/, '');
if (normalizedPreferredPlayerIdentifier && pool && typeof fetchPlayerRegistrations === 'function') {
try {
const players = await fetchPlayerRegistrations(pool);
const registeredPlayers = Array.isArray(players) ? players : [];
const exactPlayer = registeredPlayers.find(function (player) {
return String(player && player.identifier || '').trim() === normalizedPreferredPlayerIdentifier;
}) || null;
if (exactPlayer) {
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(exactPlayer) || normalizedPreferredPlayerInternalBaseUrl || null;
const resolvedPublicBaseUrl = normalizeBaseUrl(exactPlayer && exactPlayer.public_base_url);
const resolvedIdentifier = String(exactPlayer && exactPlayer.identifier || '').trim();
playerInternalBaseUrl = resolvedInternalBaseUrl || null;
playerTaskMetadata = {
playerIdentifier: resolvedIdentifier || normalizedPreferredPlayerIdentifier || null,
playerPublicBaseUrl: resolvedPublicBaseUrl || null,
playerInternalBaseUrl: resolvedInternalBaseUrl || null,
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null,
playerActive: true
};
return playerTaskMetadata;
}
} catch (_error) {
}
}
if (playerTaskMetadata && playerTaskMetadata.playerActive !== false) {
return playerTaskMetadata; return playerTaskMetadata;
} }
if (playerTaskMetadata && playerTaskMetadata.playerActive === false) {
playerTaskMetadata = null;
playerInternalBaseUrl = null;
}
if (playerTaskMetadataPromise) { if (playerTaskMetadataPromise) {
return playerTaskMetadataPromise; return playerTaskMetadataPromise;
} }
@@ -404,6 +453,23 @@ function createUploadSyncService(options) {
return Boolean(localUploadDir); return Boolean(localUploadDir);
} }
async function fetchLivePlayerRegistrations() {
if (!pool || typeof fetchPlayerRegistrations !== 'function') {
return [];
}
try {
const players = await fetchPlayerRegistrations(pool);
return Array.isArray(players)
? players.filter(function (player) {
return isRecentPlayerRegistration(player, 60);
})
: [];
} catch (_error) {
return [];
}
}
function isPlayerUnavailableError(error) { function isPlayerUnavailableError(error) {
const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase(); const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase();
return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT'; return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT';
@@ -413,15 +479,33 @@ function createUploadSyncService(options) {
return Boolean(response) && Number(response.status) === 503; return Boolean(response) && Number(response.status) === 503;
} }
function buildPendingPlayerUploadSyncKey(operation) {
const uploadPath = normalizeUploadReference(operation && operation.uploadPath);
const metadata = operation && operation.metadata && typeof operation.metadata === 'object'
? operation.metadata
: null;
const playerIdentifier = String((operation && operation.playerIdentifier) || (metadata && metadata.playerIdentifier) || '').trim();
const playerInternalBaseUrl = normalizeBaseUrl((operation && operation.playerInternalBaseUrl) || (metadata && metadata.playerInternalBaseUrl) || '');
return [uploadPath, playerIdentifier, playerInternalBaseUrl].filter(Boolean).join('|');
}
function queuePlayerUploadSync(operation) { function queuePlayerUploadSync(operation) {
if (!operation || !operation.uploadPath) { if (!operation || !operation.uploadPath) {
return; return;
} }
pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), { const metadata = operation.metadata && typeof operation.metadata === 'object' ? operation.metadata : null;
const playerIdentifier = String((operation && operation.playerIdentifier) || (metadata && metadata.playerIdentifier) || '').trim();
const playerInternalBaseUrl = normalizeBaseUrl((operation && operation.playerInternalBaseUrl) || (metadata && metadata.playerInternalBaseUrl) || '');
pendingPlayerUploadSyncs.set(buildPendingPlayerUploadSyncKey(operation), {
type: operation.type === 'delete' ? 'delete' : 'put', type: operation.type === 'delete' ? 'delete' : 'put',
uploadPath: normalizeUploadReference(operation.uploadPath), uploadPath: normalizeUploadReference(operation.uploadPath),
uploadDir: operation.uploadDir || null uploadDir: operation.uploadDir || null,
metadata: metadata,
playerIdentifier: playerIdentifier || null,
playerInternalBaseUrl: playerInternalBaseUrl || null
}); });
schedulePendingPlayerUploadSyncFlush(); schedulePendingPlayerUploadSyncFlush();
@@ -438,9 +522,12 @@ function createUploadSyncService(options) {
console.warn('Unable to flush pending upload syncs:', error); console.warn('Unable to flush pending upload syncs:', error);
}); });
}, 5000); }, 5000);
if (pendingPlayerUploadSyncFlushTimer && typeof pendingPlayerUploadSyncFlushTimer.unref === 'function') {
pendingPlayerUploadSyncFlushTimer.unref();
}
} }
async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
return false; return false;
} }
@@ -450,6 +537,7 @@ function createUploadSyncService(options) {
return false; return false;
} }
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const relativePath = getUploadRelativePath(uploadPath); const relativePath = getUploadRelativePath(uploadPath);
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath); const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
if (!relativePath || !sourcePath) { if (!relativePath || !sourcePath) {
@@ -471,7 +559,8 @@ function createUploadSyncService(options) {
pathname: `/api/media/${encodeURIComponent(relativePath)}`, pathname: `/api/media/${encodeURIComponent(relativePath)}`,
body: fileBuffer body: fileBuffer
}); });
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { const mediaUploadUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
const response = await fetch(mediaUploadUrl, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
@@ -494,7 +583,7 @@ function createUploadSyncService(options) {
} }
} }
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
return false; return false;
} }
@@ -504,6 +593,7 @@ function createUploadSyncService(options) {
return false; return false;
} }
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
const relativePath = getUploadRelativePath(uploadPath); const relativePath = getUploadRelativePath(uploadPath);
if (!relativePath) { if (!relativePath) {
return false; return false;
@@ -513,7 +603,8 @@ function createUploadSyncService(options) {
method: 'DELETE', method: 'DELETE',
pathname: `/api/media/${encodeURIComponent(relativePath)}` pathname: `/api/media/${encodeURIComponent(relativePath)}`
}); });
const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { const mediaDeleteUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
const response = await fetch(mediaDeleteUrl, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@@ -535,20 +626,26 @@ function createUploadSyncService(options) {
} }
} }
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) { async function syncUploadRefsToPlayer(uploadRefs, localUploadDir, preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
if (!shouldMirrorUploads(localUploadDir)) { if (!shouldMirrorUploads(localUploadDir)) {
return; return;
} }
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl);
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean))); const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
for (let i = 0; i < uniqueRefs.length; i += 1) { for (let i = 0; i < uniqueRefs.length; i += 1) {
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl); const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier);
if (!success) { if (!success) {
queuePlayerUploadSync({ queuePlayerUploadSync({
type: 'put', type: 'put',
uploadPath: uniqueRefs[i], uploadPath: uniqueRefs[i],
uploadDir: localUploadDir uploadDir: localUploadDir,
playerIdentifier: preferredPlayerIdentifier,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl,
metadata: preferredPlayerIdentifier || preferredPlayerInternalBaseUrl ? {
playerIdentifier: preferredPlayerIdentifier || null,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl || null
} : null
}); });
} }
} }
@@ -666,10 +763,27 @@ function createUploadSyncService(options) {
return; return;
} }
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', { const players = await fetchLivePlayerRegistrations();
mode: 'playlist', if (!players.length) {
operation: operation return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
}); mode: 'playlist',
operation: operation
});
}
return Promise.all(players.map(function (player) {
const playerIdentifier = String(player && player.identifier || '').trim();
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, '');
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, '');
return queueMediaSyncTask('media-sync:' + operation.key + (playerIdentifier ? ':' + playerIdentifier : ''), 'Media sync', {
mode: 'playlist',
operation: operation,
playerIdentifier: playerIdentifier || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerPublicBaseUrl: playerPublicBaseUrl || null
});
}));
} }
async function flushPendingPlaylistUploadSyncs() { async function flushPendingPlaylistUploadSyncs() {
@@ -677,6 +791,11 @@ function createUploadSyncService(options) {
return pendingPlaylistUploadSyncFlushInFlight; return pendingPlaylistUploadSyncFlushInFlight;
} }
if (pendingPlaylistUploadSyncFlushTimer) {
clearTimeout(pendingPlaylistUploadSyncFlushTimer);
pendingPlaylistUploadSyncFlushTimer = null;
}
if (!pendingPlaylistUploadSyncs.size) { if (!pendingPlaylistUploadSyncs.size) {
return null; return null;
} }
@@ -715,49 +834,59 @@ function createUploadSyncService(options) {
return pendingPlayerUploadSyncFlushInFlight; return pendingPlayerUploadSyncFlushInFlight;
} }
if (pendingPlayerUploadSyncFlushTimer) {
clearTimeout(pendingPlayerUploadSyncFlushTimer);
pendingPlayerUploadSyncFlushTimer = null;
}
if (!pendingPlayerUploadSyncs.size) { if (!pendingPlayerUploadSyncs.size) {
return null; return null;
} }
pendingPlayerUploadSyncFlushInFlight = (async function () { pendingPlayerUploadSyncFlushInFlight = (async function () {
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values()); const pendingEntries = Array.from(pendingPlayerUploadSyncs.entries());
const playerMetadata = pendingEntries.length && pendingEntries[0] && pendingEntries[0].metadata const firstOperation = pendingEntries.length && pendingEntries[0] ? pendingEntries[0][1] : null;
? pendingEntries[0].metadata const summaryPlayerMetadata = firstOperation && firstOperation.metadata
: await getPlayerTaskMetadata(); ? firstOperation.metadata
if (playerMetadata && playerMetadata.playerActive === false) { : await getPlayerTaskMetadata(firstOperation && firstOperation.playerIdentifier, firstOperation && firstOperation.playerInternalBaseUrl);
pendingPlayerUploadSyncs.clear();
pendingPlayerUploadSyncRetryLogAt = 0;
return;
}
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
? playerMetadata.playerInternalBaseUrl
: await getPlayerInternalBaseUrl();
let successCount = 0; let successCount = 0;
let failureCount = 0; let failureCount = 0;
for (let i = 0; i < pendingEntries.length; i += 1) { for (let i = 0; i < pendingEntries.length; i += 1) {
const operation = pendingEntries[i]; const entry = pendingEntries[i];
const pendingKey = entry[0];
const operation = entry[1];
const playerMetadata = operation && operation.metadata
? operation.metadata
: await getPlayerTaskMetadata(operation && operation.playerIdentifier, operation && operation.playerInternalBaseUrl);
if (playerMetadata && playerMetadata.playerActive === false) {
pendingPlayerUploadSyncs.delete(pendingKey);
continue;
}
const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl
? playerMetadata.playerInternalBaseUrl
: await getPlayerInternalBaseUrl(playerMetadata && playerMetadata.playerIdentifier, playerMetadata && playerMetadata.playerInternalBaseUrl);
let success = false; let success = false;
if (operation.type === 'delete') { if (operation.type === 'delete') {
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl); success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier);
} else { } else {
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl); success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier);
} }
if (success) { if (success) {
successCount += 1; successCount += 1;
pendingPlayerUploadSyncs.delete(operation.uploadPath); pendingPlayerUploadSyncs.delete(pendingKey);
} else { } else {
failureCount += 1; failureCount += 1;
} }
} }
if (successCount) { if (successCount) {
logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, playerMetadata); logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, summaryPlayerMetadata);
} }
if (failureCount) { if (failureCount) {
const now = Date.now(); const now = Date.now();
if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) { if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) {
pendingPlayerUploadSyncRetryLogAt = now; pendingPlayerUploadSyncRetryLogAt = now;
logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, playerMetadata); logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, summaryPlayerMetadata);
} }
} else if (!pendingPlayerUploadSyncs.size) { } else if (!pendingPlayerUploadSyncs.size) {
pendingPlayerUploadSyncRetryLogAt = 0; pendingPlayerUploadSyncRetryLogAt = 0;
@@ -782,6 +911,8 @@ function createUploadSyncService(options) {
return; return;
} }
const playerMetadata = await getPlayerTaskMetadata(taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
const data = await common.fetchAdminData(pool); const data = await common.fetchAdminData(pool);
const uploadRefs = new Set(); const uploadRefs = new Set();
(data.slides || []).forEach(function (slide) { (data.slides || []).forEach(function (slide) {
@@ -799,7 +930,8 @@ function createUploadSyncService(options) {
queuePlayerUploadSync({ queuePlayerUploadSync({
type: 'put', type: 'put',
uploadPath: uploadPath, uploadPath: uploadPath,
uploadDir: uploadDir uploadDir: uploadDir,
metadata: playerMetadata
}); });
}); });
fontLibraryOperations.forEach(function (operation) { fontLibraryOperations.forEach(function (operation) {
@@ -810,7 +942,8 @@ function createUploadSyncService(options) {
queuePlayerUploadSync({ queuePlayerUploadSync({
type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put', type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put',
uploadPath: operation.uploadPath, uploadPath: operation.uploadPath,
uploadDir: uploadDir uploadDir: uploadDir,
metadata: playerMetadata
}); });
}); });
await flushPendingPlayerUploadSyncs(); await flushPendingPlayerUploadSyncs();
@@ -821,14 +954,34 @@ function createUploadSyncService(options) {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload); const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
if (operation.nextUploadRefs.length) { if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir); await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
} }
if (operation.previousUploadRefs.length) { if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs); const nextUploadRefSet = new Set(operation.nextUploadRefs);
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) { const removedUploadRefs = operation.previousUploadRefs.filter(function (reference) {
return !nextUploadRefSet.has(reference); return !nextUploadRefSet.has(reference);
})); });
for (let i = 0; i < removedUploadRefs.length; i += 1) {
const removedUploadRef = removedUploadRefs[i];
const referenceCount = await countUploadReferences(pool, removedUploadRef);
if (referenceCount > 0) {
continue;
}
const deleted = await removeUploadFileFromPlayer(removedUploadRef, operation.localUploadDir, taskPayload.playerInternalBaseUrl, taskPayload.playerIdentifier);
if (!deleted) {
queuePlayerUploadSync({
type: 'delete',
uploadPath: removedUploadRef,
uploadDir: operation.localUploadDir,
playerIdentifier: taskPayload.playerIdentifier,
playerInternalBaseUrl: taskPayload.playerInternalBaseUrl,
metadata: playerMetadata
});
}
}
await removeUnusedUploadFiles(pool, operation.localUploadDir, removedUploadRefs);
} }
if (operation.refreshScreenSlugs.length) { if (operation.refreshScreenSlugs.length) {
@@ -857,7 +1010,7 @@ function createUploadSyncService(options) {
safePayload.operation = Object.assign({}, safePayload.operation); safePayload.operation = Object.assign({}, safePayload.operation);
delete safePayload.operation.pool; delete safePayload.operation.pool;
} }
const playerMetadata = await getPlayerTaskMetadata(); const playerMetadata = await getPlayerTaskMetadata(safePayload.playerIdentifier, safePayload.playerInternalBaseUrl);
const definition = { const definition = {
key: taskKey, key: taskKey,
+119 -66
View File
@@ -1,5 +1,5 @@
const { createRequestAuthHeaders } = require('#src/request-auth'); const { createRequestAuthHeaders } = require('#src/request-auth');
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry'); const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
function isLocalLikeBaseUrl(value) { function isLocalLikeBaseUrl(value) {
let host = ''; let host = '';
@@ -33,24 +33,10 @@ function isRecentPlayerRegistration(player, staleSeconds) {
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
} }
async function fetchRecentPlayerRegistrations(pool) {
if (!pool || typeof fetchPlayerRegistrations !== 'function') {
return [];
}
try {
const players = await fetchPlayerRegistrations(pool);
return (Array.isArray(players) ? players : []).filter(function (player) {
return isRecentPlayerRegistration(player, 60);
});
} catch (_error) {
return [];
}
}
function createPlayerActionService(options) { function createPlayerActionService(options) {
const pool = options && options.pool; const pool = options && options.pool;
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const configuredBridgeInternalBaseUrl = String(options && options.bridgeInternalBaseUrl || '').trim().replace(/\/$/, '');
const common = options && options.common; const common = options && options.common;
if (!common) { if (!common) {
@@ -151,6 +137,79 @@ function createPlayerActionService(options) {
}); });
} }
async function fetchScreenConnectionsFromBaseUrl(baseUrl, slug) {
const targetBaseUrl = normalizeBaseUrl(baseUrl);
if (!targetBaseUrl) {
return null;
}
const authHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
});
const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
method: 'GET',
headers: {
Accept: 'application/json',
...authHeaders
}
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to fetch connections for player ${slug}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return null;
});
}
async function forwardPlayerCommandToDevice(deviceId, commandOrPayload) {
const targetDeviceId = String(deviceId || '').trim();
const targetBridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl);
if (!targetDeviceId) {
throw new Error('Device ID is required.');
}
if (!targetBridgeBaseUrl) {
throw new Error('Unable to resolve the player bridge base URL.');
}
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
: { command: commandOrPayload };
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: `/api/players/${encodeURIComponent(targetDeviceId)}/commands`,
body: payload
});
const response = await fetch(`${targetBridgeBaseUrl}/api/players/${encodeURIComponent(targetDeviceId)}/commands`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...authHeaders
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to send command to player ${targetDeviceId}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return { ok: true };
});
}
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) { async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(); const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId); return forwardPlayerCommandToBaseUrl(resolvedPlayerInternalBaseUrl, slug, commandOrPayload, connectionId);
@@ -190,69 +249,62 @@ function createPlayerActionService(options) {
} }
async function getScreenConnections(slug) { async function getScreenConnections(slug) {
const authHeaders = createRequestAuthHeaders({ const bridgeBaseUrl = normalizeBaseUrl(configuredBridgeInternalBaseUrl);
method: 'GET', const playerBaseUrl = await getPlayerInternalBaseUrl();
pathname: `/api/screens/${encodeURIComponent(slug)}/connections` const targetBaseUrls = Array.from(new Set([bridgeBaseUrl, playerBaseUrl].map(normalizeBaseUrl).filter(Boolean)));
});
const recentPlayers = await fetchRecentPlayerRegistrations(pool);
const targetBaseUrls = Array.from(new Set((recentPlayers.length ? recentPlayers : []).map(function (player) {
return normalizeBaseUrl(player && player.public_base_url);
}).filter(Boolean)));
if (!targetBaseUrls.length) {
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
if (resolvedPlayerInternalBaseUrl) {
targetBaseUrls.push(resolvedPlayerInternalBaseUrl);
}
}
if (!targetBaseUrls.length) { if (!targetBaseUrls.length) {
throw new Error('Unable to resolve the player internal base URL.'); throw new Error('Unable to resolve the player internal base URL.');
} }
const results = await Promise.all(targetBaseUrls.map(async function (baseUrl) { const results = await Promise.allSettled(targetBaseUrls.map(function (baseUrl) {
const response = await fetch(`${baseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, { return fetchScreenConnectionsFromBaseUrl(baseUrl, slug);
method: 'GET',
headers: {
Accept: 'application/json',
...authHeaders
}
});
if (!response.ok) {
return null;
}
return response.json().catch(function () {
return null;
});
})); }));
const successfulResults = results.filter(function (result) {
return result.status === 'fulfilled' && result.value;
}).map(function (result) {
return result.value;
});
if (!successfulResults.length) {
const rejection = results.find(function (result) {
return result.status === 'rejected';
});
throw rejection ? rejection.reason : new Error(`Unable to fetch connections for player ${slug}.`);
}
const mergedConnections = []; const mergedConnections = [];
let screen = null; const seenKeys = new Set();
let degraded = false;
results.forEach(function (result) { successfulResults.forEach(function (result) {
if (!result) { const connections = Array.isArray(result && result.connections) ? result.connections : [];
degraded = true; connections.forEach(function (connection) {
return; const key = [
} String(connection && connection.id || '').trim(),
if (!screen && result.screen) { String(connection && connection.clientId || '').trim(),
screen = result.screen; String(connection && connection.deviceId || '').trim(),
} String(connection && connection.playerPublicBaseUrl || '').trim()
if (Array.isArray(result.connections)) { ].join('|');
mergedConnections.push.apply(mergedConnections, result.connections); if (!key || seenKeys.has(key)) {
} return;
if (result.degraded) { }
degraded = true; seenKeys.add(key);
} mergedConnections.push(connection);
});
}); });
return { return {
screen: screen, screen: successfulResults.find(function (result) {
return Boolean(result && result.screen);
}) ? successfulResults.find(function (result) {
return Boolean(result && result.screen);
}).screen : null,
screenSlug: slug, screenSlug: slug,
count: mergedConnections.length, count: mergedConnections.length,
connections: mergedConnections, connections: mergedConnections,
degraded: degraded degraded: results.some(function (result) {
return result.status === 'fulfilled' && Boolean(result.value && result.value.degraded);
})
}; };
} }
@@ -302,6 +354,7 @@ function createPlayerActionService(options) {
forwardAnnouncementRefresh: forwardAnnouncementRefresh, forwardAnnouncementRefresh: forwardAnnouncementRefresh,
getScreenConnections: getScreenConnections, getScreenConnections: getScreenConnections,
forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl, forwardPlayerCommandToBaseUrl: forwardPlayerCommandToBaseUrl,
forwardPlayerCommandToDevice: forwardPlayerCommandToDevice,
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage, getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage, getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage, getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
+1
View File
@@ -22,6 +22,7 @@ async function initializeWebServer(options) {
pool: pool, pool: pool,
common: common, common: common,
backgroundTaskQueue: backgroundTaskQueue, backgroundTaskQueue: backgroundTaskQueue,
notifyPlayerScreens: options && options.notifyPlayerScreens ? options.notifyPlayerScreens : null,
uploadSyncService: webBootstrap.uploadSyncService, uploadSyncService: webBootstrap.uploadSyncService,
captureSlideThumbnail: captureSlideThumbnail, captureSlideThumbnail: captureSlideThumbnail,
mediaDir: mediaDir, mediaDir: mediaDir,
+1 -1
View File
@@ -29,7 +29,7 @@ module.exports = function registerMiddleware(app, deps) {
}); });
app.use(function (req, res, next) { app.use(function (req, res, next) {
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media') { if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font') {
return next(); return next();
} }
+32
View File
@@ -756,6 +756,38 @@
background: var(--bs-tertiary-bg); background: var(--bs-tertiary-bg);
} }
.timetable-entries-table-shell {
overflow: hidden;
border-bottom-left-radius: calc(var(--bs-border-radius) - 1px);
border-bottom-right-radius: calc(var(--bs-border-radius) - 1px);
}
.timetable-entries-table-shell > .table-responsive {
margin-bottom: 0;
}
.timetable-entries-table {
border-collapse: collapse;
}
.timetable-entries-table > thead > tr:first-child > * {
border-top-width: 0;
}
.timetable-entries-table > :not(caption) > * > :first-child {
border-left-width: 0;
padding-left: 0;
}
.timetable-entries-table > :not(caption) > * > :last-child {
border-right-width: 0;
padding-right: 0;
}
.timetable-entries-table > tbody > tr:last-child > * {
border-bottom-width: 0;
}
.api-source-section-heading { .api-source-section-heading {
display: flex; display: flex;
align-items: center; align-items: center;
+13 -13
View File
@@ -423,19 +423,6 @@
return true; return true;
} }
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href)
: String(response.url || form.dataset.asyncSaveNewUrl || window.location.href);
window.location.replace(redirectUrl);
return true;
}
if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') {
window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href));
return true;
}
var responseText = await response.text(); var responseText = await response.text();
var responseDocument = null; var responseDocument = null;
try { try {
@@ -456,6 +443,19 @@
clearFormDirty(form); clearFormDirty(form);
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href)
: String(response.url || form.dataset.asyncSaveNewUrl || window.location.href);
window.location.replace(redirectUrl);
return true;
}
if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') {
window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href));
return true;
}
var successMessage = typeof settings.getSuccessMessage === 'function' var successMessage = typeof settings.getSuccessMessage === 'function'
? settings.getSuccessMessage({ ? settings.getSuccessMessage({
form: form, form: form,
+343 -264
View File
@@ -8,8 +8,6 @@
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp; var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
var LIST_PAGE_SIZE = 25; var LIST_PAGE_SIZE = 25;
var latestDashboardState = null; var latestDashboardState = null;
var ALL_SCREENS_SLUG = '__all__';
var ALL_SCREENS_LABEL = 'All screens';
function getClientSearchInput() { function getClientSearchInput() {
var table = document.getElementById('dashboard-clients-table'); var table = document.getElementById('dashboard-clients-table');
@@ -20,6 +18,152 @@
return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]'); return container && container.querySelector ? container.querySelector('[data-table-search]') : document.querySelector('[data-table-search]');
} }
function getScreenCommandSelect() {
return document.querySelector('[data-screen-command-select]');
}
function getScreenCommandForms() {
if (!document.querySelectorAll) {
return [];
}
return Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]'));
}
function getSelectedScreenClients(state) {
var select = getScreenCommandSelect();
var selectedSlug = select ? String(select.value || '').trim() : '';
var clients = Array.isArray(state && state.clients) ? state.clients : [];
if (!selectedSlug || selectedSlug === '__all__') {
return clients;
}
return clients.filter(function (client) {
return String(client && client.screen_slug || '').trim() === selectedSlug;
});
}
function getSelectedScreenLabel() {
var select = getScreenCommandSelect();
if (!select) {
return '';
}
var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
return selectedOption ? String(selectedOption.getAttribute('data-screen-name') || selectedOption.textContent || '').trim() : '';
}
function updateToggleButton(button, form, state) {
if (!button) {
return;
}
var action = String(form && form.getAttribute('data-screen-command-action') || '').trim();
var selectedLabel = getSelectedScreenLabel() || 'selected screen group';
var isAllScreens = String(getScreenCommandSelect() && getScreenCommandSelect().value || '').trim() === '__all__';
var clients = getSelectedScreenClients(state);
var hasClients = clients.length > 0;
var allPaused = hasClients && clients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = hasClients && clients.every(function (client) {
return Boolean(client && client.blackout);
});
if (action === 'pause') {
var pauseLabel = allPaused ? 'Resume ' + (isAllScreens ? 'all clients' : 'screen') : 'Pause ' + (isAllScreens ? 'all clients' : 'screen');
var pauseConfirm = allPaused ? 'Resume ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?' : 'Pause ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?';
var pauseIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill';
button.innerHTML = '<i class="bi ' + pauseIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(pauseLabel);
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
button.setAttribute('aria-label', pauseLabel);
button.setAttribute('title', pauseLabel);
if (form) {
var pauseInput = form.querySelector('input[name="paused"]');
if (pauseInput) {
pauseInput.value = allPaused ? 'false' : 'true';
}
form.setAttribute('data-confirm-message', pauseConfirm);
}
return;
}
if (action === 'blackout') {
var blackoutLabel = allBlackout ? 'Restore ' + (isAllScreens ? 'all clients' : 'screen') : 'Blackout ' + (isAllScreens ? 'all clients' : 'screen');
var blackoutConfirm = allBlackout ? 'Restore ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?' : 'Blackout ' + (isAllScreens ? 'all connected clients' : selectedLabel) + '?';
var blackoutIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash';
button.innerHTML = '<i class="bi ' + blackoutIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(blackoutLabel);
setButtonVariant(button, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
button.setAttribute('aria-label', blackoutLabel);
button.setAttribute('title', blackoutLabel);
if (form) {
var blackoutInput = form.querySelector('input[name="blackout"]');
if (blackoutInput) {
blackoutInput.value = allBlackout ? 'false' : 'true';
}
form.setAttribute('data-confirm-message', blackoutConfirm);
}
}
}
function updateScreenCommandControls() {
var select = getScreenCommandSelect();
if (!select) {
return;
}
var selectedSlug = String(select.value || '').trim();
var hasSelectedGroup = Boolean(selectedSlug);
var actionTarget = '/clients/' + encodeURIComponent(selectedSlug || '__all__') + '/commands';
var selectedName = getSelectedScreenLabel();
var selectedClients = getSelectedScreenClients(latestDashboardState);
var allPaused = selectedClients.length > 0 && selectedClients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = selectedClients.length > 0 && selectedClients.every(function (client) {
return Boolean(client && client.blackout);
});
getScreenCommandForms().forEach(function (form) {
if (!form) {
return;
}
form.setAttribute('action', actionTarget);
var action = String(form.getAttribute('data-screen-command-action') || '').trim();
var button = form.querySelector('button[type="submit"]');
if (button) {
button.disabled = !hasSelectedGroup;
}
if (action === 'pause' || action === 'blackout') {
updateToggleButton(button, form, latestDashboardState);
return;
}
if (button) {
button.setAttribute('aria-label', selectedName ? selectedName : 'Selected screen group');
}
});
}
function initScreenCommandControls() {
var select = getScreenCommandSelect();
if (!select || (select.dataset && select.dataset.bound === 'true')) {
return;
}
if (select.dataset) {
select.dataset.bound = 'true';
}
select.addEventListener('change', updateScreenCommandControls);
updateScreenCommandControls();
}
function getClientListQueryState() { function getClientListQueryState() {
var searchParams = new URLSearchParams(String(window.location && window.location.search || '')); var searchParams = new URLSearchParams(String(window.location && window.location.search || ''));
var searchInput = getClientSearchInput(); var searchInput = getClientSearchInput();
@@ -101,8 +245,6 @@
client && client.slug, client && client.slug,
client && client.screen_slug, client && client.screen_slug,
client && client.screen_name, client && client.screen_name,
client && client.ipAddress,
client && client.clientIp,
client && client.status, client && client.status,
client && client.currentSlideTitle client && client.currentSlideTitle
]; ];
@@ -120,7 +262,6 @@
client: function (client) { return String(client && (getClientDisplayName(client) || client.client_name || client.name || client.clientId) || '').trim(); }, client: function (client) { return String(client && (getClientDisplayName(client) || client.client_name || client.name || client.clientId) || '').trim(); },
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); }, screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); }, slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
ip: function (client) { return String(client && client.clientIp || '').trim(); },
viewport: function (client) { viewport: function (client) {
var viewport = client && client.viewport; var viewport = client && client.viewport;
if (!viewport || !viewport.width || !viewport.height) { if (!viewport || !viewport.width || !viewport.height) {
@@ -139,12 +280,6 @@
? [normalizedSortKey] ? [normalizedSortKey]
: ['client']; : ['client'];
if (sortKeys[0] === 'client') {
sortKeys.push('ip');
} else if (sortKeys[0] === 'ip') {
sortKeys.push('client');
}
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) { return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
for (var index = 0; index < sortKeys.length; index += 1) { for (var index = 0; index < sortKeys.length; index += 1) {
var sortKeyName = sortKeys[index]; var sortKeyName = sortKeys[index];
@@ -170,77 +305,6 @@
return clients.slice((query.page - 1) * LIST_PAGE_SIZE, ((query.page - 1) * LIST_PAGE_SIZE) + LIST_PAGE_SIZE); return clients.slice((query.page - 1) * LIST_PAGE_SIZE, ((query.page - 1) * LIST_PAGE_SIZE) + LIST_PAGE_SIZE);
} }
function getClientMoveModalElements() {
return {
modal: document.getElementById('client-move-screen-modal'),
form: document.getElementById('client-move-screen-form'),
targetSelect: document.getElementById('client-move-screen-target'),
connectionInput: document.querySelector('[data-client-move-connection-id]'),
deviceInput: document.querySelector('[data-client-move-device-id]'),
clientNameInput: document.querySelector('[data-client-move-client-name]'),
playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]')
};
}
function getClientMoveScreens() {
if (latestDashboardState && Array.isArray(latestDashboardState.screens) && latestDashboardState.screens.length) {
return latestDashboardState.screens.slice().sort(compareScreensByConnectedClients);
}
var select = document.getElementById('client-move-screen-target');
if (!select || !select.options) {
return [];
}
return Array.prototype.slice.call(select.options).map(function (option) {
return {
slug: String(option.value || '').trim(),
name: String(option.textContent || option.value || '').trim()
};
}).filter(function (screen) {
return Boolean(screen && screen.slug);
});
}
function updateClientMoveModalFromRow(row) {
var elements = getClientMoveModalElements();
if (!elements.form || !elements.targetSelect || !row) {
return;
}
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
var options = Array.prototype.slice.call(elements.targetSelect.options || []);
options.forEach(function (option) {
option.disabled = false;
if (String(option.value || '').trim() === currentScreenSlug) {
option.disabled = true;
}
});
elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#';
if (elements.connectionInput) {
elements.connectionInput.value = connectionId;
}
if (elements.deviceInput) {
elements.deviceInput.value = deviceId;
}
if (elements.clientNameInput) {
elements.clientNameInput.value = clientName;
}
if (elements.playerBaseUrlInput) {
elements.playerBaseUrlInput.value = playerBaseUrl;
}
elements.targetSelect.value = '';
if (elements.form.querySelector('button[type="submit"]')) {
elements.form.querySelector('button[type="submit"]').disabled = false;
}
}
function renderClientActionCell(client) { function renderClientActionCell(client) {
var paused = Boolean(client.paused); var paused = Boolean(client.paused);
var pauseButtonClass = 'btn btn-sm btn-info'; var pauseButtonClass = 'btn btn-sm btn-info';
@@ -257,7 +321,7 @@
return [ return [
'<div class="actions justify-content-end">', '<div class="actions justify-content-end">',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>', '<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>',
'<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>', '<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>', '<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>', '<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>', '<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><input type="hidden" name="playerBaseUrl" value="' + escapeHtml(client.player_url || '') + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>',
@@ -266,6 +330,60 @@
].join(''); ].join('');
} }
function getClientMoveModalElements() {
return {
modal: document.getElementById('client-move-screen-modal'),
form: document.getElementById('client-move-screen-form'),
targetSelect: document.getElementById('client-move-screen-target'),
connectionInput: document.querySelector('[data-client-move-connection-id]'),
deviceInput: document.querySelector('[data-client-move-device-id]'),
clientNameInput: document.querySelector('[data-client-move-client-name]'),
playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]')
};
}
function updateClientMoveModalFromRow(row) {
var elements = getClientMoveModalElements();
if (!elements.form || !elements.targetSelect || !row) {
return;
}
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var connectionId = String(row.getAttribute('data-client-id') || '').trim();
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
var options = Array.prototype.slice.call(elements.targetSelect.options || []);
options.forEach(function (option) {
if (!option) {
return;
}
option.disabled = String(option.value || '').trim() === currentScreenSlug;
});
elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#';
if (elements.connectionInput) {
elements.connectionInput.value = connectionId;
}
if (elements.deviceInput) {
elements.deviceInput.value = deviceId;
}
if (elements.clientNameInput) {
elements.clientNameInput.value = clientName;
}
if (elements.playerBaseUrlInput) {
elements.playerBaseUrlInput.value = playerBaseUrl;
}
if (elements.targetSelect) {
elements.targetSelect.value = '';
}
if (elements.form.querySelector('button[type="submit"]')) {
elements.form.querySelector('button[type="submit"]').disabled = false;
}
}
function updateClientActionCell(cell, client) { function updateClientActionCell(cell, client) {
if (!cell) { if (!cell) {
return; return;
@@ -412,13 +530,13 @@
} }
if (!hasActionsColumn) { if (!hasActionsColumn) {
if (row.cells.length > 6) { if (row.cells.length > 5) {
row.deleteCell(row.cells.length - 1); row.deleteCell(row.cells.length - 1);
} }
return; return;
} }
var actionCell = row.cells.length > 6 ? row.cells[6] : null; var actionCell = row.cells.length > 5 ? row.cells[5] : null;
if (!actionCell) { if (!actionCell) {
actionCell = row.insertCell(-1); actionCell = row.insertCell(-1);
actionCell.setAttribute('data-label', 'Actions'); actionCell.setAttribute('data-label', 'Actions');
@@ -430,8 +548,6 @@
function renderClientRow(client, hasActionsColumn) { function renderClientRow(client, hasActionsColumn) {
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>'; var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
var clientIpValue = normalizeDisplayIp(client.clientIp);
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>'; var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>';
var clientNameValue = getClientDisplayName(client); var clientNameValue = getClientDisplayName(client);
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>'; var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
@@ -440,11 +556,10 @@
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : ''; var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
return [ return [
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '" data-client-player-base-url="' + escapeHtml(client.player_url || '') + '">', '<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.id || '') + '" data-client-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '" data-client-player-base-url="' + escapeHtml(client.player_url || '') + '">',
'<td data-label="Client"><div>' + clientName + '</div></td>', '<td data-label="Client"><div>' + clientName + '</div></td>',
'<td data-label="Current Screen"><div>' + screenName + '</div></td>', '<td data-label="Current Screen"><div>' + screenName + '</div></td>',
'<td data-label="Current Slide">' + currentSlide + '</td>', '<td data-label="Current Slide">' + currentSlide + '</td>',
'<td data-label="IP">' + clientIp + '</td>',
'<td data-label="Viewport">' + viewport + '</td>', '<td data-label="Viewport">' + viewport + '</td>',
'<td data-label="Connected/Updated">' + connectedAt + '</td>', '<td data-label="Connected/Updated">' + connectedAt + '</td>',
actionCell, actionCell,
@@ -453,13 +568,11 @@
} }
function updateClientRowCells(row, client, hasActionsColumn) { function updateClientRowCells(row, client, hasActionsColumn) {
if (!row || !row.cells || row.cells.length < 6) { if (!row || !row.cells || row.cells.length < 5) {
return; return;
} }
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>'; var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
var clientIpValue = normalizeDisplayIp(client.clientIp);
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>'; var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>';
var clientNameValue = getClientDisplayName(client); var clientNameValue = getClientDisplayName(client);
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>'; var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
@@ -467,7 +580,8 @@
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>'; var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
row.setAttribute('data-client-key', escapeHtml(getClientRowKey(client))); row.setAttribute('data-client-key', escapeHtml(getClientRowKey(client)));
row.setAttribute('data-client-id', escapeHtml(client.clientId || '')); row.setAttribute('data-client-id', escapeHtml(client.id || ''));
row.setAttribute('data-client-client-id', escapeHtml(client.clientId || ''));
row.setAttribute('data-client-device-id', escapeHtml(client.deviceId || '')); row.setAttribute('data-client-device-id', escapeHtml(client.deviceId || ''));
row.setAttribute('data-client-screen-slug', escapeHtml(client.screen_slug || '')); row.setAttribute('data-client-screen-slug', escapeHtml(client.screen_slug || ''));
row.setAttribute('data-client-player-base-url', escapeHtml(client.player_url || '')); row.setAttribute('data-client-player-base-url', escapeHtml(client.player_url || ''));
@@ -475,9 +589,8 @@
setCellHtml(row.cells[0], '<div>' + clientName + '</div>'); setCellHtml(row.cells[0], '<div>' + clientName + '</div>');
setCellHtml(row.cells[1], '<div>' + screenName + '</div>'); setCellHtml(row.cells[1], '<div>' + screenName + '</div>');
setCellHtml(row.cells[2], currentSlide); setCellHtml(row.cells[2], currentSlide);
setCellHtml(row.cells[3], clientIp); setCellHtml(row.cells[3], viewport);
setCellHtml(row.cells[4], viewport); setCellHtml(row.cells[4], connectedAt);
setCellHtml(row.cells[5], connectedAt);
syncClientActionCell(row, client, hasActionsColumn); syncClientActionCell(row, client, hasActionsColumn);
} }
@@ -627,6 +740,112 @@
updateClientTable(latestDashboardState, true); updateClientTable(latestDashboardState, true);
} }
function initClientMoveHandler() {
var elements = getClientMoveModalElements();
if (!elements.modal || !elements.form || !elements.targetSelect) {
return;
}
if (typeof elements.modal.addEventListener === 'function') {
elements.modal.addEventListener('show.bs.modal', function (event) {
var trigger = event && event.relatedTarget ? event.relatedTarget : null;
var row = trigger && trigger.closest ? trigger.closest('tr[data-client-key]') : null;
if (row) {
updateClientMoveModalFromRow(row);
}
});
}
document.addEventListener('click', function (event) {
var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
if (!moveButton) {
return;
}
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
if (!row) {
return;
}
updateClientMoveModalFromRow(row);
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(elements.modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(elements.modal).show();
}
});
elements.form.addEventListener('submit', function (event) {
if (elements.form.dataset && elements.form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var targetScreenSlug = String(elements.targetSelect.value || '').trim();
if (!targetScreenSlug) {
event.preventDefault();
window.alert('Choose a target screen.');
return;
}
event.preventDefault();
elements.form.dataset.busy = 'true';
var formData = new FormData(elements.form);
var body = new URLSearchParams();
formData.forEach(function (value, key) {
body.append(key, value);
});
fetch(elements.form.action, {
method: (elements.form.method || 'POST').toUpperCase(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
body: body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to move client.');
try {
var payload = JSON.parse(text);
if (payload && (payload.error || payload.message)) {
error = new Error(String(payload.error || payload.message));
}
} catch (_error) {
// fall back to the raw text body
}
throw error;
});
}
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(elements.modal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(elements.modal).hide();
}
return response.json().catch(function () {
return { ok: true };
});
}).catch(function (error) {
window.alert(error && error.message ? error.message : 'Unable to move client.');
}).finally(function () {
delete elements.form.dataset.busy;
});
});
}
function updateKioskLauncherModal(state) { function updateKioskLauncherModal(state) {
var modal = document.getElementById('dashboard-kiosk-launcher-modal'); var modal = document.getElementById('dashboard-kiosk-launcher-modal');
if (!modal) { if (!modal) {
@@ -696,163 +915,6 @@
} }
grid.innerHTML = screens.map(renderScreenTile).join(''); grid.innerHTML = screens.map(renderScreenTile).join('');
} }
function updateScreenCommandControls(state) {
var select = document.getElementById('screen-command-select');
if (!select) {
return;
}
var forms = Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]'));
var pill = document.querySelector('[data-screen-command-pill]');
var nameNode = document.querySelector('[data-screen-command-name]');
var metaNode = document.querySelector('[data-screen-command-meta]');
var screens = Array.isArray(state && state.screens) ? state.screens : [];
var screenBySlug = {};
screens.forEach(function (screen) {
if (screen && screen.slug) {
screenBySlug[String(screen.slug)] = screen;
}
});
if (!screens.length) {
select.value = '';
select.disabled = true;
forms.forEach(function (form) {
form.querySelectorAll('button, input').forEach(function (control) {
control.disabled = true;
});
});
if (pill) {
pill.classList.remove('is-live');
pill.classList.add('is-idle');
pill.textContent = 'No screens';
}
if (nameNode) {
nameNode.textContent = 'No target available';
}
if (metaNode) {
metaNode.textContent = 'Create a screen before using screen-level commands.';
}
return;
}
select.disabled = false;
var selectedOption = select.options && select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
var isAllSelected = Boolean(selectedOption && String(selectedOption.getAttribute('data-screen-target-all') || '').toLowerCase() === 'true') || select.value === ALL_SCREENS_SLUG;
var selectedScreen = isAllSelected
? {
slug: ALL_SCREENS_SLUG,
name: String(selectedOption && selectedOption.textContent || ALL_SCREENS_LABEL).trim() || ALL_SCREENS_LABEL
}
: screenBySlug[select.value] || null;
var selectedSlug = isAllSelected
? ALL_SCREENS_SLUG
: String(selectedScreen && selectedScreen.slug || '').trim();
var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) {
if (isAllSelected) {
return true;
}
return String(client && client.screen_slug || '').trim() === selectedSlug;
}) : [];
var connectionCount = selectedClients.length;
var hasClients = connectionCount > 0;
var allPaused = hasClients && selectedClients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = hasClients && selectedClients.every(function (client) {
return Boolean(client && client.blackout);
});
var connectionLabel = hasClients ? connectionCount + ' connected client' + (connectionCount === 1 ? '' : 's') : 'No clients connected';
if (pill) {
pill.classList.toggle('is-live', hasClients);
pill.classList.toggle('is-idle', !hasClients);
pill.textContent = connectionLabel;
}
if (nameNode) {
nameNode.textContent = selectedScreen
? String(selectedScreen.name || 'Selected screen')
: 'Select a target screen group';
}
if (metaNode) {
metaNode.textContent = !selectedSlug
? 'Choose a screen group before sending commands.'
: isAllSelected
? 'Commands sent here target every client across every screen group.'
: 'Commands sent here target every client currently using this screen.';
}
var commandTargetSlug = selectedSlug || '';
forms.forEach(function (form) {
var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase();
var commandInput = form.querySelector('input[name="command"]');
var button = form.querySelector('button[type="submit"]');
if (commandInput) {
if (command === 'pause') {
commandInput.value = allPaused ? 'pause' : 'pause';
var pauseStateInput = form.querySelector('input[name="paused"]');
if (pauseStateInput) {
pauseStateInput.value = allPaused ? 'false' : 'true';
}
if (button) {
button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? (isAllSelected ? 'Resume all screens' : 'Resume screen') : (isAllSelected ? 'Pause all screens' : 'Pause screen'));
}
setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
form.setAttribute('data-confirm-message', allPaused
? (isAllSelected ? 'Resume all connected clients on all screens?' : 'Resume all connected clients on this screen?')
: (isAllSelected ? 'Pause all connected clients on all screens?' : 'Pause all connected clients on this screen?'));
} else if (command === 'blackout') {
commandInput.value = 'blackout';
var blackoutStateInput = form.querySelector('input[name="blackout"]');
if (blackoutStateInput) {
blackoutStateInput.value = allBlackout ? 'false' : 'true';
}
if (button) {
button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? (isAllSelected ? 'Restore all screens' : 'Restore screen') : (isAllSelected ? 'Blackout all screens' : 'Blackout screen'));
}
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
form.setAttribute('data-confirm-message', allBlackout
? (isAllSelected ? 'Restore all connected clients on all screens?' : 'Restore all connected clients on this screen?')
: (isAllSelected ? 'Blackout all connected clients on all screens?' : 'Blackout all connected clients on this screen?'));
} else {
commandInput.value = command || commandInput.value || '';
}
}
form.action = commandTargetSlug ? '/clients/' + encodeURIComponent(commandTargetSlug) + '/commands' : '#';
if (command === 'reload') {
form.setAttribute('data-confirm-message', isAllSelected ? 'Reload all screens?' : 'Reload selected screen?');
if (button) {
button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>' + (isAllSelected ? 'Reload all screens' : 'Reload screen');
}
}
Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) {
control.disabled = !commandTargetSlug;
});
});
}
function readScreenCommandStateFromDom() {
var select = document.getElementById('screen-command-select');
if (!select) {
return { screens: [] };
}
return {
screens: Array.prototype.slice.call(select.options || []).map(function (option) {
return {
slug: String(option.value || '').trim(),
name: String(option.getAttribute('data-screen-name') || option.textContent || option.value || '').trim(),
player_connection_count: Number(option.getAttribute('data-player-connection-count') || 0),
playlist_name: String(option.getAttribute('data-playlist-name') || '').trim()
};
})
};
}
function updateDashboardQuickActions(state) { function updateDashboardQuickActions(state) {
var pauseButton = document.getElementById('dashboard-pause-all-button'); var pauseButton = document.getElementById('dashboard-pause-all-button');
var blackoutButton = document.getElementById('dashboard-blackout-all-button'); var blackoutButton = document.getElementById('dashboard-blackout-all-button');
@@ -889,7 +951,7 @@
var blackoutButtonIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash'; var blackoutButtonIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash';
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(label); blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(label);
setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], 'btn-secondary'); setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
if (blackoutInput) { if (blackoutInput) {
blackoutInput.value = allBlackout ? 'false' : 'true'; blackoutInput.value = allBlackout ? 'false' : 'true';
} }
@@ -907,10 +969,10 @@
window.webLatestDashboardState = latestDashboardState; window.webLatestDashboardState = latestDashboardState;
updateStats(state); updateStats(state);
updateScreenGrid(state); updateScreenGrid(state);
updateScreenCommandControls(state);
updateClientTable(state); updateClientTable(state);
updateKioskLauncherModal(state); updateKioskLauncherModal(state);
updateDashboardQuickActions(state); updateDashboardQuickActions(state);
updateScreenCommandControls();
} }
function sendClientRename(screenSlug, connectionId, clientId, deviceId, clientName) { function sendClientRename(screenSlug, connectionId, clientId, deviceId, clientName) {
@@ -967,8 +1029,8 @@
return; return;
} }
var connectionId = String(row.getAttribute('data-client-key') || '').trim(); var connectionId = String(row.getAttribute('data-client-id') || '').trim();
var clientId = String(row.getAttribute('data-client-id') || '').trim(); var clientId = String(row.getAttribute('data-client-client-id') || '').trim();
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim(); var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
var screenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim(); var screenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var currentName = String(cell.textContent || '').trim(); var currentName = String(cell.textContent || '').trim();
@@ -1003,18 +1065,41 @@
return; return;
} }
if (typeof elements.modal.addEventListener === 'function') {
elements.modal.addEventListener('show.bs.modal', function (event) {
var trigger = event && event.relatedTarget ? event.relatedTarget : null;
var row = trigger && trigger.closest ? trigger.closest('tr[data-client-key]') : null;
if (row) {
updateClientMoveModalFromRow(row);
}
});
}
document.addEventListener('click', function (event) { document.addEventListener('click', function (event) {
var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null; var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
if (!moveButton) { if (!moveButton) {
return; return;
} }
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null; var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
if (!row) { if (!row) {
return; return;
} }
updateClientMoveModalFromRow(row); updateClientMoveModalFromRow(row);
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(elements.modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(elements.modal).show();
}
}); });
elements.form.addEventListener('submit', function (event) { elements.form.addEventListener('submit', function (event) {
@@ -1131,11 +1216,5 @@
initClientRenameHandler(); initClientRenameHandler();
initClientMoveHandler(); initClientMoveHandler();
initKioskLauncherModal(); initKioskLauncherModal();
var screenCommandSelect = document.getElementById('screen-command-select'); initScreenCommandControls();
if (screenCommandSelect) {
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
screenCommandSelect.addEventListener('change', function () {
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
});
}
}()); }());
@@ -2,14 +2,17 @@
(function () { (function () {
var form = document.getElementById('timetable-group-form'); var form = document.getElementById('timetable-group-form');
var timezoneInput = document.getElementById('timetable-group-timezone');
var body = document.querySelector('[data-timetable-entries-body]'); var body = document.querySelector('[data-timetable-entries-body]');
var addButton = document.querySelector('[data-add-timetable-entry]'); var addButton = document.querySelector('[data-add-timetable-entry]');
var template = document.getElementById('timetable-entry-row-template'); var template = document.getElementById('timetable-entry-row-template');
if (!form || !body || !addButton || !template) { if (!form || !timezoneInput || !body || !addButton || !template) {
return; return;
} }
var DEFAULT_TIME_ZONE = 'Europe/London';
function markDirty() { function markDirty() {
form.dataset.dirty = 'true'; form.dataset.dirty = 'true';
} }
@@ -18,61 +21,275 @@
return String(value).padStart(2, '0'); return String(value).padStart(2, '0');
} }
function formatDateTimeLocalValue(date) { function getDefaultTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIME_ZONE;
} catch (_error) {
return DEFAULT_TIME_ZONE;
}
}
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 parseDateTimeLocalParts(value) {
var raw = String(value || '').trim();
var match = raw.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/);
if (!match) {
return null;
}
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4]),
minute: Number(match[5]),
second: Number(match[6] || '0')
};
}
function getDateTimeParts(date, timeZone) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) { if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return null;
}
var resolvedTimeZone = resolveTimeZone(timeZone);
var parts = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).formatToParts(date);
var mapped = Object.create(null);
parts.forEach(function (part) {
if (part && part.type && part.type !== 'literal') {
mapped[part.type] = part.value;
}
});
return mapped;
}
function formatDateTimeLocalValue(date, timeZone) {
var parts = getDateTimeParts(date, timeZone);
if (!parts) {
return ''; return '';
} }
return [ return [
String(date.getFullYear()).padStart(4, '0'), String(parts.year || '').padStart(4, '0'),
'-', '-',
pad(date.getMonth() + 1), pad(parts.month),
'-', '-',
pad(date.getDate()), pad(parts.day),
'T', 'T',
pad(date.getHours()), pad(parts.hour),
':', ':',
pad(date.getMinutes()) pad(parts.minute)
].join(''); ].join('');
} }
function parseUtcDateTimeLocalValue(value) { function getTimeZoneOffsetMillis(date, timeZone) {
var raw = String(value || '').trim(); var parts = getDateTimeParts(date, timeZone);
if (!raw) { if (!parts) {
return null; return 0;
} }
var normalized = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? raw : raw + 'Z'; var localAsUtc = Date.UTC(
var date = new Date(normalized); Number(parts.year) || 0,
return Number.isNaN(date.getTime()) ? null : date; (Number(parts.month) || 1) - 1,
Number(parts.day) || 1,
Number(parts.hour) || 0,
Number(parts.minute) || 0,
Number(parts.second) || 0,
0
);
return localAsUtc - date.getTime();
} }
function toUtcDateTimeLocalValue(value) { function parseDateTimeLocalAsUtc(value, timeZone) {
var localDate = new Date(String(value || '').trim()); var raw = String(value || '').trim();
return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString(); var isoDate;
var parts = parseDateTimeLocalParts(value);
if (!parts) {
isoDate = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? new Date(raw) : null;
return isoDate && !Number.isNaN(isoDate.getTime()) ? isoDate : null;
}
var resolvedTimeZone = resolveTimeZone(timeZone);
var utcMillis = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0);
var date = new Date(utcMillis);
var offset = getTimeZoneOffsetMillis(date, resolvedTimeZone);
var adjusted = new Date(utcMillis - offset);
var adjustedOffset = getTimeZoneOffsetMillis(adjusted, resolvedTimeZone);
if (adjustedOffset !== offset) {
adjusted = new Date(utcMillis - adjustedOffset);
}
return Number.isNaN(adjusted.getTime()) ? null : adjusted;
} }
function syncRowValuesToLocal(row) { function convertDateTimeLocalValue(value, sourceTimeZone, targetTimeZone) {
var raw = String(value || '').trim();
if (!raw) {
return '';
}
var utcDate = parseDateTimeLocalAsUtc(raw, sourceTimeZone);
if (!utcDate) {
return '';
}
var resolvedTargetTimeZone = resolveTimeZone(targetTimeZone);
if (resolvedTargetTimeZone === 'UTC') {
return utcDate.toISOString();
}
return formatDateTimeLocalValue(utcDate, resolvedTargetTimeZone);
}
function clearFieldValidity(input) {
if (!input) {
return;
}
input.setCustomValidity('');
input.classList.remove('is-invalid');
if (input.removeAttribute) {
input.removeAttribute('aria-invalid');
}
}
function setFieldValidity(input, message) {
if (!input) {
return;
}
input.setCustomValidity(message);
input.classList.add('is-invalid');
if (input.setAttribute) {
input.setAttribute('aria-invalid', 'true');
}
}
function validateEntryRow(row) {
var timezone = resolveTimeZone(timezoneInput.value);
var startInput;
var endInput;
var startValue;
var endValue;
var startDate;
var endDate;
if (!row) {
return;
}
startInput = row.querySelector('[name="entry_start_datetime[]"]');
endInput = row.querySelector('[name="entry_end_datetime[]"]');
clearFieldValidity(endInput);
if (!startInput || !endInput) {
return;
}
startValue = String(startInput.value || '').trim();
endValue = String(endInput.value || '').trim();
if (!startValue || !endValue) {
return;
}
startDate = parseDateTimeLocalAsUtc(startValue, timezone);
endDate = parseDateTimeLocalAsUtc(endValue, timezone);
if (!startDate || !endDate) {
return;
}
if (endDate.getTime() < startDate.getTime() + 60000) {
setFieldValidity(endInput, 'End time must be at least 1 minute after the start time.');
}
}
function bindRowValidation(row) {
var startInput;
var endInput;
if (!row) {
return;
}
startInput = row.querySelector('[name="entry_start_datetime[]"]');
endInput = row.querySelector('[name="entry_end_datetime[]"]');
if (!startInput || !endInput) {
return;
}
function handleValidation() {
validateEntryRow(row);
}
startInput.addEventListener('input', handleValidation);
startInput.addEventListener('change', handleValidation);
endInput.addEventListener('input', handleValidation);
endInput.addEventListener('change', handleValidation);
validateEntryRow(row);
}
function syncRowValuesToTimeZone(row, sourceTimeZone, targetTimeZone) {
if (!row) { if (!row) {
return; return;
} }
['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) { ['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
var input = row.querySelector('[name="' + name + '"]'); var input = row.querySelector('[name="' + name + '"]');
if (!input || input.dataset.timetableTimezoneSynced === 'true') { var convertedValue;
if (!input) {
return; return;
} }
var localValue = formatDateTimeLocalValue(parseUtcDateTimeLocalValue(input.value)); convertedValue = convertDateTimeLocalValue(input.value, sourceTimeZone, targetTimeZone);
if (localValue) { if (convertedValue) {
input.value = localValue; input.value = convertedValue;
} }
input.dataset.timetableTimezoneSynced = 'true'; });
}
function syncAllRowsToTimeZone(sourceTimeZone, targetTimeZone) {
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
syncRowValuesToTimeZone(row, sourceTimeZone, targetTimeZone);
validateEntryRow(row);
}); });
} }
function syncFormDataToUtc(formData) { function syncFormDataToUtc(formData) {
var rows = body.querySelectorAll('[data-timetable-entry-row]'); var rows = body.querySelectorAll('[data-timetable-entry-row]');
var currentTimeZone = resolveTimeZone(timezoneInput.value);
['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) { ['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
formData.delete(name); formData.delete(name);
}); });
@@ -87,8 +304,8 @@
formData.append('entry_id[]', idInput ? idInput.value : ''); formData.append('entry_id[]', idInput ? idInput.value : '');
formData.append('entry_title[]', titleInput ? titleInput.value : ''); formData.append('entry_title[]', titleInput ? titleInput.value : '');
formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : ''); formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : '');
formData.append('entry_start_datetime[]', startInput ? toUtcDateTimeLocalValue(startInput.value) : ''); formData.append('entry_start_datetime[]', startInput ? convertDateTimeLocalValue(startInput.value, currentTimeZone, 'UTC') : '');
formData.append('entry_end_datetime[]', endInput ? toUtcDateTimeLocalValue(endInput.value) : ''); formData.append('entry_end_datetime[]', endInput ? convertDateTimeLocalValue(endInput.value, currentTimeZone, 'UTC') : '');
}); });
} }
@@ -110,18 +327,30 @@
return; return;
} }
bindRemove(row); bindRemove(row);
bindRowValidation(row);
body.appendChild(fragment); body.appendChild(fragment);
markDirty(); markDirty();
} }
function handleTimezoneChange() {
var nextTimeZone = resolveTimeZone(timezoneInput.value || DEFAULT_TIME_ZONE);
form.dataset.timetableTimezone = nextTimeZone;
markDirty();
}
form.dataset.timetableTimezone = resolveTimeZone(timezoneInput.value || DEFAULT_TIME_ZONE);
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) { body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
bindRemove(row); bindRemove(row);
syncRowValuesToLocal(row); bindRowValidation(row);
}); });
form.addEventListener('formdata', function (event) { form.addEventListener('formdata', function (event) {
syncFormDataToUtc(event.formData); syncFormDataToUtc(event.formData);
}); });
timezoneInput.addEventListener('change', handleTimezoneChange);
timezoneInput.addEventListener('input', handleTimezoneChange);
addButton.addEventListener('click', addRow); addButton.addEventListener('click', addRow);
}()); }());
+105 -2
View File
@@ -28,11 +28,19 @@
var name = String(match[2] || '').toLowerCase(); var name = String(match[2] || '').toLowerCase();
var attrText = String(match[3] || ''); var attrText = String(match[3] || '');
var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr'; var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr';
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']; var allowed = ['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'];
if (allowed.indexOf(name) === -1) { if (allowed.indexOf(name) === -1) {
return ''; return '';
} }
if (name === 'img') {
var srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
var 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 '';
}
}
if (closing) { if (closing) {
return '</' + name + '>'; return '</' + name + '>';
} }
@@ -42,7 +50,100 @@
} }
function sanitizeRichText(html) { function sanitizeRichText(html) {
return 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 attrText = String(match[3] || '');
var allowed = ['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'];
if (allowed.indexOf(name) === -1) {
return '';
}
if (closing) {
return '</' + name + '>';
}
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
});
}
function sanitizeRichTextAttributes(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'],
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'],
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 '';
}
if (tagName === 'img') {
var srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
var 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 '';
}
}
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 sanitizeTagAttributes(tagName, attrText) { function sanitizeTagAttributes(tagName, attrText) {
@@ -64,6 +165,7 @@
pre: ['class', 'style'], pre: ['class', 'style'],
span: ['class', 'style'], span: ['class', 'style'],
table: ['class', 'style'], table: ['class', 'style'],
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
td: ['class', 'style', 'colspan', 'rowspan'], td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'], th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
tr: ['class', 'style'], tr: ['class', 'style'],
@@ -844,6 +946,7 @@
escapeHtml: escapeHtml, escapeHtml: escapeHtml,
sanitizePreviewHtml: sanitizePreviewHtml, sanitizePreviewHtml: sanitizePreviewHtml,
sanitizeRichText: sanitizeRichText, sanitizeRichText: sanitizeRichText,
sanitizeRichTextAttributes: sanitizeRichTextAttributes,
sanitizeFontFamily: sanitizeFontFamily, sanitizeFontFamily: sanitizeFontFamily,
sanitizeTextColor: sanitizeTextColor, sanitizeTextColor: sanitizeTextColor,
normalizeAcceptList: normalizeAcceptList, normalizeAcceptList: normalizeAcceptList,
+15 -5
View File
@@ -9,8 +9,8 @@
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value); return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
} }
function sanitizeRichText(html) { function sanitizePreviewHtml(html) {
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html); return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
} }
function sanitizeFontFamily(value) { function sanitizeFontFamily(value) {
@@ -128,7 +128,7 @@
summary.push('<h3>' + escapeHtml(title) + '</h3>'); summary.push('<h3>' + escapeHtml(title) + '</h3>');
} }
if (description) { if (description) {
summary.push('<div>' + sanitizeRichText(description) + '</div>'); summary.push('<div>' + sanitizePreviewHtml(description) + '</div>');
} }
if (!summary.length) { if (!summary.length) {
return ''; return '';
@@ -183,7 +183,7 @@
if (!body) { if (!body) {
return ''; return '';
} }
var renderedBody = sanitizeRichText(body); var renderedBody = sanitizePreviewHtml(body);
return renderedBody ? '<div class="template-region api" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : ''; return renderedBody ? '<div class="template-region api" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
} }
@@ -272,8 +272,18 @@
function buildPreviewRenderContext(region, card, existingContent, sources) { function buildPreviewRenderContext(region, card, existingContent, sources) {
var current = getCurrentConfig(region, existingContent || {}, sources || []); var current = getCurrentConfig(region, existingContent || {}, sources || []);
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null; var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
var fallbackValue = card && card.querySelector ? ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '') : current.value;
var content = card && card.querySelector ? { var content = card && card.querySelector ? {
value: String(editor ? editor.getContent({ format: 'html' }) : ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '')), value: String((function () {
if (!editor || typeof editor.getContent !== 'function') {
return fallbackValue;
}
try {
return editor.getContent({ format: 'html' });
} catch (_error) {
return fallbackValue;
}
}())),
source_id: (card.querySelector('select[name="region_api_source_id_' + region.id + '"]') || {}).value || current.source_id, source_id: (card.querySelector('select[name="region_api_source_id_' + region.id + '"]') || {}).value || current.source_id,
item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number, item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number,
items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path
+40 -4
View File
@@ -8,13 +8,49 @@
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value); return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
} }
function renderPreview(value) { function normalizePreviewValue(value) {
var content = value && typeof value === 'object' && value.value !== undefined ? value : null; if (value && typeof value === 'object') {
var html = String(content ? content.value : value || '').trim(); if (value.value !== undefined) {
return normalizePreviewValue(value.value);
}
if (value.text !== undefined) {
return normalizePreviewValue(value.text);
}
if (value.html !== undefined) {
return normalizePreviewValue(value.html);
}
if (value.content !== undefined) {
return normalizePreviewValue(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;}</style></head><body>' + raw + '</body></html>';
}
function renderPreview(region, value) {
var html = normalizePreviewValue(value !== undefined ? value : region).trim();
if (!html) { if (!html) {
return '<div class="slide-preview-placeholder">HTML</div>'; return '<div class="slide-preview-placeholder">HTML</div>';
} }
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>'; if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
return '<iframe class="slide-preview-html-frame" sandbox="" allowtransparency="true" scrolling="no" srcdoc="' + escapeHtml(buildHtmlDocument(html)) + '" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
}
return '<div class="slide-preview-html-frame" style="width:100%;height:100%;overflow:hidden;background:transparent;">' + html + '</div>';
} }
function renderEditorCard(context) { function renderEditorCard(context) {
+13 -7
View File
@@ -17,10 +17,6 @@
return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html); return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
} }
function sanitizeRichText(html) {
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : sanitizePreviewHtml(html);
}
function sanitizeFontFamily(value) { function sanitizeFontFamily(value) {
return utils.sanitizeFontFamily ? utils.sanitizeFontFamily(value) : String(value || '').trim(); return utils.sanitizeFontFamily ? utils.sanitizeFontFamily(value) : String(value || '').trim();
} }
@@ -198,7 +194,7 @@
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>'); summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
} }
if (item.description) { if (item.description) {
summaryParts.push('<div>' + sanitizeRichText(item.description) + '</div>'); summaryParts.push('<div>' + sanitizePreviewHtml(item.description) + '</div>');
} }
body = summaryParts.join(''); body = summaryParts.join('');
} }
@@ -206,7 +202,7 @@
if (!body) { if (!body) {
return ''; return '';
} }
var renderedBody = sanitizeRichText(body); var renderedBody = sanitizePreviewHtml(body);
return renderedBody ? '<div class="template-region rss" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : ''; return renderedBody ? '<div class="template-region rss" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
} }
@@ -277,8 +273,18 @@
function buildPreviewRenderContext(region, card, existingContent, feeds) { function buildPreviewRenderContext(region, card, existingContent, feeds) {
var current = getCurrentConfig(region, existingContent || {}); var current = getCurrentConfig(region, existingContent || {});
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null; var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
var fallbackValue = card && card.querySelector ? ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '') : current.value;
var content = card && card.querySelector ? { var content = card && card.querySelector ? {
value: String(editor ? editor.getContent({ format: 'html' }) : ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '')), value: String((function () {
if (!editor || typeof editor.getContent !== 'function') {
return fallbackValue;
}
try {
return editor.getContent({ format: 'html' });
} catch (_error) {
return fallbackValue;
}
}())),
feed_id: (card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') || {}).value || current.feed_id, feed_id: (card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') || {}).value || current.feed_id,
item_number: (card.querySelector('input[name="region_rss_item_number_' + region.id + '"]') || {}).value || current.item_number item_number: (card.querySelector('input[name="region_rss_item_number_' + region.id + '"]') || {}).value || current.item_number
} : current; } : current;
+11 -1
View File
@@ -84,7 +84,17 @@
} : {}; } : {};
return { return {
value: String(editor ? editor.getContent({ format: 'html' }) : (hidden && hidden.value !== undefined ? hidden.value : (textarea && textarea.value !== undefined ? textarea.value : (current && current.value !== undefined ? current.value : '')))), value: String((function () {
var fallback = hidden && hidden.value !== undefined ? hidden.value : (textarea && textarea.value !== undefined ? textarea.value : (current && current.value !== undefined ? current.value : ''));
if (!editor || typeof editor.getContent !== 'function') {
return fallback;
}
try {
return editor.getContent({ format: 'html' });
} catch (_error) {
return fallback;
}
}())),
style: style, style: style,
existingContent: existingContent || {} existingContent: existingContent || {}
}; };
+6 -6
View File
@@ -156,19 +156,19 @@
MMM: toTitleCase(getPart(monthShort, 'month')), MMM: toTitleCase(getPart(monthShort, 'month')),
MMMM: toTitleCase(getPart(monthLong, 'month')), MMMM: toTitleCase(getPart(monthLong, 'month')),
a: toTitleCase(getPart(ampm, 'dayPeriod')), a: toTitleCase(getPart(ampm, 'dayPeriod')),
tz: resolvedTimeZone, tz: getPart(getFormatter('tz-short:' + resolvedTimeZone, {
tz_short: getPart(getFormatter('tz-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone, timeZone: resolvedTimeZone,
timeZoneName: 'short' timeZoneName: 'short'
}).formatToParts(targetDate), 'timeZoneName'), }).formatToParts(targetDate), 'timeZoneName'),
tz_long: resolvedTimeZone,
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'), date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second') time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
}; };
} }
function resolveTimeDatePlaceholder(values, expression) { function resolveTimeDatePlaceholder(values, expression, options) {
if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') { if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression)); return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression, options || {}));
} }
var parsed = String(expression || '').trim(); var parsed = String(expression || '').trim();
@@ -200,7 +200,7 @@
'MMMM', 'MMMM',
'a', 'a',
'tz', 'tz',
'tz_short' 'tz_long'
]; ];
if (typeof placeholderChips.renderChips === 'function') { if (typeof placeholderChips.renderChips === 'function') {
@@ -220,7 +220,7 @@
var template = String(format || '').trim(); var template = String(format || '').trim();
var values = getFormattedParts(timeZone, date); var values = getFormattedParts(timeZone, date);
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) { return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
return String(resolveTimeDatePlaceholder(values, key) || ''); return String(resolveTimeDatePlaceholder(values, key, { timeZone: timeZone }) || '');
}); });
} }
@@ -15,10 +15,6 @@
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value); return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
} }
function sanitizeRichText(html) {
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html);
}
function getDefaultStyle() { function getDefaultStyle() {
return { return {
font_family: DEFAULT_STYLE.font_family, font_family: DEFAULT_STYLE.font_family,
@@ -43,6 +39,52 @@
return group && Array.isArray(group.entries) ? group.entries : []; return group && Array.isArray(group.entries) ? group.entries : [];
} }
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 getTimezoneValues(group, dateValue) {
var timeZone = resolveTimeZone(group && (group.timezone || group.time_zone || ''));
var shortName = timeZone;
var targetDate = dateValue instanceof Date ? dateValue : new Date(dateValue || Date.now());
try {
var parts = new Intl.DateTimeFormat('en-GB', {
timeZone: timeZone,
timeZoneName: 'short'
}).formatToParts(Number.isNaN(targetDate.getTime()) ? new Date() : targetDate);
var timeZonePart = parts.find(function (part) {
return part && part.type === 'timeZoneName';
});
shortName = timeZonePart && timeZonePart.value ? String(timeZonePart.value) : timeZone;
} catch (_error) {
shortName = timeZone;
}
return {
tz: shortName,
tz_long: timeZone
};
}
function toDate(value) { function toDate(value) {
if (!value) { if (!value) {
return null; return null;
@@ -119,9 +161,9 @@
var current = existingContent[region.region_key] || {}; var current = existingContent[region.region_key] || {};
return { return {
timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id), timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id),
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming', display_mode: String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
value: String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''), value: String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : ''),
max_items: Math.max(1, Number(current.max_items || 5)), max_items: Math.max(1, Number(current.max_items || current.timetable_max_items || 5)),
font_family: current.font_family || region.font_family || getDefaultStyle().font_family, font_family: current.font_family || region.font_family || getDefaultStyle().font_family,
font_size: current.font_size || region.font_size || getDefaultStyle().font_size, font_size: current.font_size || region.font_size || getDefaultStyle().font_size,
font_color: current.font_color || region.font_color || getDefaultStyle().font_color font_color: current.font_color || region.font_color || getDefaultStyle().font_color
@@ -143,14 +185,47 @@
}).join(''); }).join('');
} }
function renderScheduleFormatTokenTable() {
if (window.timeDatePlaceholders && typeof window.timeDatePlaceholders.renderTable === 'function') {
return window.timeDatePlaceholders.renderTable();
}
return '' +
'<div class="table-responsive">' +
'<table class="table table-sm align-middle mb-0">' +
'<thead><tr><th scope="col">Format</th><th scope="col">Output</th><th scope="col">Description</th></tr></thead>' +
'<tbody>' +
'<tr><td><code>h</code></td><td>1-12</td><td>The hour, 12-hour clock</td></tr>' +
'<tr><td><code>hh</code></td><td>01-12</td><td>The hour, 12-hour clock, 2-digits</td></tr>' +
'<tr><td><code>m</code></td><td>0-59</td><td>The minute</td></tr>' +
'<tr><td><code>mm</code></td><td>00-59</td><td>The minute, 2-digits</td></tr>' +
'<tr><td><code>s</code></td><td>0-59</td><td>The second</td></tr>' +
'<tr><td><code>ss</code></td><td>00-59</td><td>The second, 2-digits</td></tr>' +
'<tr><td><code>A</code></td><td>AM/PM</td><td>Uppercase day period</td></tr>' +
'<tr><td><code>a</code></td><td>am/pm</td><td>Lowercase day period</td></tr>' +
'<tr><td><code>D</code></td><td>1-31</td><td>The day of the month</td></tr>' +
'<tr><td><code>DD</code></td><td>01-31</td><td>The day of the month, 2-digits</td></tr>' +
'<tr><td><code>ddd</code></td><td>Mon</td><td>The abbreviated weekday name</td></tr>' +
'<tr><td><code>dddd</code></td><td>Monday</td><td>The full weekday name</td></tr>' +
'<tr><td><code>M</code></td><td>1-12</td><td>The month, beginning at 1</td></tr>' +
'<tr><td><code>MM</code></td><td>01-12</td><td>The month, 2-digits</td></tr>' +
'<tr><td><code>MMM</code></td><td>Jan-Dec</td><td>The abbreviated month name</td></tr>' +
'<tr><td><code>MMMM</code></td><td>January-December</td><td>The full month name</td></tr>' +
'<tr><td><code>YY</code></td><td>18</td><td>The two-digit year</td></tr>' +
'<tr><td><code>YYYY</code></td><td>2018</td><td>The four-digit year</td></tr>' +
'</tbody>' +
'</table>' +
'</div>';
}
function getCurrentSelection(regionId, card, existingContent, timetableGroups) { function getCurrentSelection(regionId, card, existingContent, timetableGroups) {
var current = existingContent[regionId] || {}; var current = existingContent[regionId] || {};
var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + regionId + '"]') : null; var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + regionId + '"]') : null;
var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + regionId + '"]') : null; var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + regionId + '"]') : null;
var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + regionId + '"]') : null; var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + regionId + '"]') : null;
var groupId = timetableGroupInput ? timetableGroupInput.value : (current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id)); var groupId = timetableGroupInput ? timetableGroupInput.value : (current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id));
var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming'; var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || 5)); var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || current.timetable_max_items || 5));
var group = getGroupById(groupId, timetableGroups); var group = getGroupById(groupId, timetableGroups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups); var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups);
if (!entries.length) { if (!entries.length) {
@@ -169,10 +244,10 @@
var region = context.region; var region = context.region;
var current = context.current || {}; var current = context.current || {};
var timetableGroups = Array.isArray(context.timetableGroups) ? context.timetableGroups : []; var timetableGroups = Array.isArray(context.timetableGroups) ? context.timetableGroups : [];
var currentValue = String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''); var currentValue = String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : '');
var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id); var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id);
var currentDisplayMode = String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming'; var currentDisplayMode = String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var currentMaxItems = Math.max(1, Number(current.max_items || 5)); var currentMaxItems = Math.max(1, Number(current.max_items || current.timetable_max_items || 5));
var currentGroup = getGroupById(currentGroupId, timetableGroups); var currentGroup = getGroupById(currentGroupId, timetableGroups);
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups); var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups);
if (!currentEntries.length) { if (!currentEntries.length) {
@@ -219,7 +294,12 @@
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' + '<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
'<div class="api-region-placeholder-title">Available placeholders</div>' + '<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' + '<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code> or <code>{{start.format("MMM D, YYYY h:mm A")}}</code>.</div>' + '<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{start.format("MMM D, YYYY h:mm A")}}</code>, <code>{{start.tz()}}</code> for the short zone name, or <code>{{start.tz_long()}}</code> for the full IANA zone name.</div>' +
'<details class="mt-2">' +
'<summary class="small text-body-secondary">Supported date format tokens</summary>' +
'<div class="mt-2">' + renderScheduleFormatTokenTable() + '</div>' +
'<div class="text-body-secondary small mt-1">Use these tokens inside <code>.format(...)</code>; for example <code>{{start.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
'</details>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
@@ -287,13 +367,29 @@
var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null; var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null;
var hiddenInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]') : null; var hiddenInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]') : null;
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null; var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
var value = String(editor ? editor.getContent({ format: 'html' }) : (hiddenInput && hiddenInput.value !== undefined ? hiddenInput.value : (textAreaInput && textAreaInput.value !== undefined ? textAreaInput.value : current.value))); var fallbackValue = hiddenInput && hiddenInput.value !== undefined ? hiddenInput.value : (textAreaInput && textAreaInput.value !== undefined ? textAreaInput.value : current.value);
var currentGroup = getGroupById(timetableGroupInput ? timetableGroupInput.value : current.timetable_group_id, timetableGroups);
var timezoneValues = getTimezoneValues(currentGroup);
var value = String((function () {
if (!editor || typeof editor.getContent !== 'function') {
return fallbackValue;
}
try {
return editor.getContent({ format: 'html' });
} catch (_error) {
return fallbackValue;
}
}()));
return { return {
value: value, value: value,
style: getTextStyle(region, current), style: getTextStyle(region, current),
timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id, timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id,
display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode, display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode,
tz: timezoneValues.tz,
tz_long: timezoneValues.tz_long,
timeZone: timezoneValues.tz_long,
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items, max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
existingContent: existingContent || {}, existingContent: existingContent || {},
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : [] timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
@@ -303,7 +399,7 @@
function renderPreview(region, regionContent, context) { function renderPreview(region, regionContent, context) {
var groups = context && context.timetableGroups ? context.timetableGroups : []; var groups = context && context.timetableGroups ? context.timetableGroups : [];
var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {}); var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {});
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim(); var value = String(regionContent && (regionContent.text !== undefined ? regionContent.text : regionContent.value !== undefined ? regionContent.value : '') || '').trim();
var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : ''; var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming'; var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
@@ -315,13 +411,27 @@
} }
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (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) + ';' : '') + '">' + entries.map(function (entry, index) { return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (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) + ';' : '') + '">' + entries.map(function (entry, index) {
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, { var entryDate = entry && (entry.start_datetime || entry.end_datetime || entry.date || entry.time || '');
var timezoneValues = getTimezoneValues(group, entryDate);
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + (utils.sanitizeRichText ? utils.sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '', start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '', end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
tz: timezoneValues.tz,
tz_long: timezoneValues.tz_long,
timeZone: timezoneValues.tz_long,
group: group || {}, group: group || {},
entries: entries, entries: entries,
index: index + 1 index: index + 1
}))) + '</div>'; }))) : escapeHtml(renderTemplate(value, Object.assign({}, entry || {}, {
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
tz: timezoneValues.tz,
tz_long: timezoneValues.tz_long,
timeZone: timezoneValues.tz_long,
group: group || {},
entries: entries,
index: index + 1
})))) + '</div>';
}).join('') + '</div>'; }).join('') + '</div>';
} }
+32 -4
View File
@@ -8,9 +8,34 @@
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value); return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
} }
function renderPreview(value) { function normalizePreviewValue(value) {
var content = value && typeof value === 'object' && value.value !== undefined ? value : null; if (value && typeof value === 'object') {
var src = String(content ? content.value : value || '').trim(); if (value.value !== undefined) {
return normalizePreviewValue(value.value);
}
if (value.text !== undefined) {
return normalizePreviewValue(value.text);
}
if (value.url !== undefined) {
return normalizePreviewValue(value.url);
}
if (value.href !== undefined) {
return normalizePreviewValue(value.href);
}
if (value.src !== undefined) {
return normalizePreviewValue(value.src);
}
if (value.content !== undefined) {
return normalizePreviewValue(value.content);
}
return '';
}
return String(value === undefined || value === null ? '' : value);
}
function renderPreview(region, value) {
var src = normalizePreviewValue(value !== undefined ? value : region).trim();
if (!src) { if (!src) {
return '<div class="slide-preview-placeholder">Webpage</div>'; return '<div class="slide-preview-placeholder">Webpage</div>';
} }
@@ -24,7 +49,10 @@
region: region, region: region,
headerActions: '<span class="chip">Webpage</span>', headerActions: '<span class="chip">Webpage</span>',
bodyHtml: '' + bodyHtml: '' +
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' '<div class="input-group flex-nowrap">' +
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
'<button type="button" class="btn btn-outline-secondary text-nowrap" data-webpage-preview-update data-region-id="' + region.id + '">Update</button>' +
'</div>'
}); });
} }
+177 -28
View File
@@ -75,36 +75,148 @@
return text; return text;
} }
function formatDateValue(value, pattern) { function getDefaultTimeZone() {
var date = value instanceof Date ? value : new Date(value); try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch (_error) {
return 'UTC';
}
}
function getDateValue(value) {
return value instanceof Date ? value : new Date(value);
}
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 getDatePartsFromLocalTime(date) {
var dayPeriod = date.getHours() >= 12 ? 'PM' : 'AM';
return {
year: String(date.getFullYear()),
month: padNumber(date.getMonth() + 1, 2),
day: padNumber(date.getDate(), 2),
hour24: padNumber(date.getHours(), 2),
hour12: padNumber(date.getHours() % 12 || 12, 2),
minute: padNumber(date.getMinutes(), 2),
second: padNumber(date.getSeconds(), 2),
weekdayLong: dayNamesLong[date.getDay()],
weekdayShort: dayNamesShort[date.getDay()],
monthLong: monthNamesLong[date.getMonth()],
monthShort: monthNamesShort[date.getMonth()],
dayPeriod: dayPeriod
};
}
function getDatePartsFromTimeZone(date, timeZone) {
var resolvedTimeZone = resolveTimeZone(timeZone);
var baseFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
var weekdayLongFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
weekday: 'long'
});
var weekdayShortFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
weekday: 'short'
});
var monthLongFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
month: 'long'
});
var monthShortFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
month: 'short'
});
var ampmFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
hour12: true,
hour: '2-digit',
minute: '2-digit'
});
var numericParts = baseFormatter.formatToParts(date);
var weekdayLong = weekdayLongFormatter.formatToParts(date);
var weekdayShort = weekdayShortFormatter.formatToParts(date);
var monthLong = monthLongFormatter.formatToParts(date);
var monthShort = monthShortFormatter.formatToParts(date);
var ampm = ampmFormatter.formatToParts(date);
function getPart(parts, type) {
var match = parts.find(function (part) {
return part && part.type === type;
});
return match ? String(match.value || '') : '';
}
var dayPeriod = getPart(ampm, 'dayPeriod');
return {
year: getPart(numericParts, 'year'),
month: getPart(numericParts, 'month'),
day: getPart(numericParts, 'day'),
hour24: getPart(numericParts, 'hour'),
hour12: getPart(ampm, 'hour'),
minute: getPart(numericParts, 'minute'),
second: getPart(numericParts, 'second'),
weekdayLong: getPart(weekdayLong, 'weekday'),
weekdayShort: getPart(weekdayShort, 'weekday'),
monthLong: getPart(monthLong, 'month'),
monthShort: getPart(monthShort, 'month'),
dayPeriod: String(dayPeriod || '').toUpperCase(),
timeZone: resolvedTimeZone
};
}
function formatDateValue(value, pattern, timeZone) {
var date = getDateValue(value);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return ''; return '';
} }
var format = String(pattern || 'YYYY-MM-DD HH:mm').trim() || 'YYYY-MM-DD HH:mm'; var format = String(pattern || 'YYYY-MM-DD HH:mm').trim() || 'YYYY-MM-DD HH:mm';
var hours24 = date.getHours(); var parts = timeZone ? getDatePartsFromTimeZone(date, timeZone) : getDatePartsFromLocalTime(date);
var hours12 = hours24 % 12 || 12; var hours24 = parts.hour24;
var hours12 = parts.hour12;
var tokenMap = { var tokenMap = {
YYYY: String(date.getFullYear()), YYYY: parts.year,
YY: String(date.getFullYear()).slice(-2), YY: parts.year.slice(-2),
MMMM: monthNamesLong[date.getMonth()], MMMM: parts.monthLong,
MMM: monthNamesShort[date.getMonth()], MMM: parts.monthShort,
MM: padNumber(date.getMonth() + 1, 2), MM: parts.month,
M: String(date.getMonth() + 1), M: String(Number(parts.month) || 0),
DD: padNumber(date.getDate(), 2), DD: parts.day,
D: String(date.getDate()), D: String(Number(parts.day) || 0),
dddd: dayNamesLong[date.getDay()], dddd: parts.weekdayLong,
ddd: dayNamesShort[date.getDay()], ddd: parts.weekdayShort,
HH: padNumber(hours24, 2), HH: hours24,
H: String(hours24), H: String(Number(hours24) || 0),
hh: padNumber(hours12, 2), hh: hours12,
h: String(hours12), h: String(Number(hours12) || 0),
mm: padNumber(date.getMinutes(), 2), mm: parts.minute,
m: String(date.getMinutes()), m: String(Number(parts.minute) || 0),
ss: padNumber(date.getSeconds(), 2), ss: parts.second,
s: String(date.getSeconds()), s: String(Number(parts.second) || 0),
A: hours24 >= 12 ? 'PM' : 'AM', A: parts.dayPeriod,
a: hours24 >= 12 ? 'pm' : 'am' a: String(parts.dayPeriod || '').toLowerCase()
}; };
return format.replace(/\[([^\]]+)\]|YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|A|a/g, function (match, literal) { return format.replace(/\[([^\]]+)\]|YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|A|a/g, function (match, literal) {
@@ -112,7 +224,36 @@
}); });
} }
function applyTransform(value, transform) { function getTimeZoneShortName(value, timeZone) {
var date = getDateValue(value);
if (Number.isNaN(date.getTime())) {
return '';
}
var resolvedTimeZone = resolveTimeZone(timeZone);
try {
var parts = new Intl.DateTimeFormat('en-GB', {
timeZone: resolvedTimeZone,
timeZoneName: 'short'
}).formatToParts(date);
var match = parts.find(function (part) {
return part && part.type === 'timeZoneName';
});
return match ? String(match.value || '') : resolvedTimeZone;
} catch (_error) {
return resolvedTimeZone;
}
}
function getTimeZoneLongName(timeZone) {
return getTransformTimeZone([], { timeZone: timeZone });
}
function getTransformTimeZone(args, options) {
return resolveTimeZone((args && args[0]) || (options && options.timeZone) || '');
}
function applyTransform(value, transform, options) {
var text = String(value === undefined || value === null ? '' : value); var text = String(value === undefined || value === null ? '' : value);
var name = String(transform && transform.name || '').trim().toLowerCase(); var name = String(transform && transform.name || '').trim().toLowerCase();
var args = Array.isArray(transform && transform.args) ? transform.args : []; var args = Array.isArray(transform && transform.args) ? transform.args : [];
@@ -132,18 +273,26 @@
} }
if (name === 'format' || name === 'date' || name === 'datetime' || name === 'time') { if (name === 'format' || name === 'date' || name === 'datetime' || name === 'time') {
return formatDateValue(value, args[0] || (name === 'time' ? 'h:mm A' : name === 'date' ? 'MMM D, YYYY' : 'MMM D, YYYY h:mm A')); return formatDateValue(value, args[0] || (name === 'time' ? 'h:mm A' : name === 'date' ? 'MMM D, YYYY' : 'MMM D, YYYY h:mm A'), getTransformTimeZone(args.slice(1), options));
}
if (name === 'tz') {
return getTimeZoneShortName(value, getTransformTimeZone(args, options));
}
if (name === 'tz_long') {
return getTimeZoneLongName(getTransformTimeZone(args, options));
} }
return text; return text;
} }
function resolvePlaceholderExpression(value, expression) { function resolvePlaceholderExpression(value, expression, options) {
var parsed = parsePlaceholderExpression(expression); var parsed = parsePlaceholderExpression(expression);
var resolved = resolvePath(value, parsed.path); var resolved = resolvePath(value, parsed.path);
parsed.transforms.forEach(function (transform) { parsed.transforms.forEach(function (transform) {
resolved = applyTransform(resolved, transform); resolved = applyTransform(resolved, transform, options || {});
}); });
return resolved; return resolved;
@@ -2,47 +2,79 @@
(function () { (function () {
var root = window; var root = window;
var PLACEHOLDERS = [ var FORMAT_PLACEHOLDERS = [
{ token: 'h', label: 'h' }, { token: 'h', output: '1-12', description: 'The hour, 12-hour clock' },
{ token: 'hh', label: 'hh' }, { token: 'hh', output: '01-12', description: 'The hour, 12-hour clock, 2-digits' },
{ token: 'm', label: 'm' }, { token: 'm', output: '0-59', description: 'The minute' },
{ token: 'mm', label: 'mm' }, { token: 'mm', output: '00-59', description: 'The minute, 2-digits' },
{ token: 's', label: 's' }, { token: 's', output: '0-59', description: 'The second' },
{ token: 'ss', label: 'ss' }, { token: 'ss', output: '00-59', description: 'The second, 2-digits' },
{ token: 'd', label: 'd' }, { token: 'A', output: 'AM/PM', description: 'Uppercase day period' },
{ token: 'dd', label: 'dd' }, { token: 'a', output: 'am/pm', description: 'Lowercase day period' },
{ token: 'M', label: 'M' }, { token: 'D', output: '1-31', description: 'The day of the month' },
{ token: 'MM', label: 'MM' }, { token: 'DD', output: '01-31', description: 'The day of the month, 2-digits' },
{ token: 'y', label: 'y' }, { token: 'ddd', output: 'Mon', description: 'The abbreviated weekday name' },
{ token: 'yyyy', label: 'yyyy' }, { token: 'dddd', output: 'Monday', description: 'The full weekday name' },
{ token: 'yy', label: 'yy' }, { token: 'M', output: '1-12', description: 'The month, beginning at 1' },
{ token: 'ddd', label: 'ddd' }, { token: 'MM', output: '01-12', description: 'The month, 2-digits' },
{ token: 'dddd', label: 'dddd' }, { token: 'MMM', output: 'Jan-Dec', description: 'The abbreviated month name' },
{ token: 'MMM', label: 'MMM' }, { token: 'MMMM', output: 'January-December', description: 'The full month name' },
{ token: 'MMMM', label: 'MMMM' }, { token: 'YY', output: '18', description: 'The two-digit year' },
{ token: 'a', label: 'a' }, { token: 'YYYY', output: '2018', description: 'The four-digit year' }
{ token: 'tz', label: 'tz' },
{ token: 'tz_short', label: 'tz_short' }
]; ];
var HELPER_PLACEHOLDERS = [
{ token: 'tz', output: 'BST/GMT', description: 'The time zone abbreviation for the selected date' },
{ token: 'tz_long', output: 'Europe/London', description: 'The resolved time zone name' }
];
var PLACEHOLDERS = FORMAT_PLACEHOLDERS.concat(HELPER_PLACEHOLDERS);
function getTokens() { function getTokens() {
return PLACEHOLDERS.slice(); return PLACEHOLDERS.slice();
} }
function renderChips() { function getFormatTokens() {
if (root.placeholderChips && typeof root.placeholderChips.renderChips === 'function') { return FORMAT_PLACEHOLDERS.slice();
return root.placeholderChips.renderChips(PLACEHOLDERS.map(function (item) { }
return item.token;
}));
}
return PLACEHOLDERS.map(function (item) { function renderTable() {
return '<span class="chip">{{' + item.token + '}}</span>'; return '' +
}).join(''); '<div class="table-responsive">' +
'<table class="table table-sm align-middle mb-0">' +
'<thead>' +
'<tr>' +
'<th scope="col">Format</th>' +
'<th scope="col">Output</th>' +
'<th scope="col">Description</th>' +
'</tr>' +
'</thead>' +
'<tbody>' +
FORMAT_PLACEHOLDERS.map(function (item) {
return '' +
'<tr>' +
'<td><code>' + item.token + '</code></td>' +
'<td>' + escapeHtml(item.output || '') + '</td>' +
'<td>' + escapeHtml(item.description || '') + '</td>' +
'</tr>';
}).join('') +
'</tbody>' +
'</table>' +
'</div>';
}
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
root.timeDatePlaceholders = { root.timeDatePlaceholders = {
getTokens: getTokens, getTokens: getTokens,
renderChips: renderChips getFormatTokens: getFormatTokens,
renderTable: renderTable
}; };
}()); }());
+249 -12
View File
@@ -11,6 +11,16 @@ export function createSlideFormEditorController(options) {
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () {
return null; return null;
}; };
var imageUploadUrl = String(settings.imageUploadUrl || '/slides/uploads').trim() || '/slides/uploads';
var imageUploadMaxBytes = Math.max(1, Number(settings.imageUploadMaxBytes || 2 * 1024 * 1024));
var imageUploadLimitLabel = String(settings.imageUploadLimitLabel || '').trim() || Math.max(1, Math.round(imageUploadMaxBytes / (1024 * 1024))) + ' MB';
var imageUploadContext = String(settings.imageUploadContext || 'wysiwyg').trim() || 'wysiwyg';
var imageUploadAllowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff'];
var imageUploadAllowedMimeTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/avif', 'image/tiff'];
var imageUploadFileTypes = imageUploadAllowedExtensions.join(',');
var editorImageUploadPaths = new Set();
var committedEditorImageUploadPaths = new Set();
var pendingEditorImageUploadCleanupPaths = new Set();
var getEditorBackgroundColor = typeof settings.getEditorBackgroundColor === 'function' ? settings.getEditorBackgroundColor : function () { var getEditorBackgroundColor = typeof settings.getEditorBackgroundColor === 'function' ? settings.getEditorBackgroundColor : function () {
return '#111111'; return '#111111';
}; };
@@ -29,6 +39,10 @@ export function createSlideFormEditorController(options) {
return true; return true;
} }
if (/<img\b/i.test(raw)) {
return false;
}
var stripped = raw var stripped = raw
.replace(/<\s*br\s*\/?>/gi, '') .replace(/<\s*br\s*\/?>/gi, '')
.replace(/<p[^>]*>(?:\s|&nbsp;|<br\s*\/?>)*<\/p>/gi, '') .replace(/<p[^>]*>(?:\s|&nbsp;|<br\s*\/?>)*<\/p>/gi, '')
@@ -72,6 +86,18 @@ export function createSlideFormEditorController(options) {
return window.tinymce || null; return window.tinymce || null;
} }
function getEditorContentSafely(editor, fallbackValue) {
if (!editor || typeof editor.getContent !== 'function') {
return String(fallbackValue || '');
}
try {
return String(editor.getContent({ format: 'html' }));
} catch (_error) {
return String(fallbackValue || '');
}
}
function getThemeName() { function getThemeName() {
var theme = String(document.documentElement && document.documentElement.dataset && document.documentElement.dataset.bsTheme || 'light').trim().toLowerCase(); var theme = String(document.documentElement && document.documentElement.dataset && document.documentElement.dataset.bsTheme || 'light').trim().toLowerCase();
if (theme === 'auto') { if (theme === 'auto') {
@@ -95,14 +121,19 @@ export function createSlideFormEditorController(options) {
} }
function syncEditorState(regionId, editor, shouldLock, hydrated) { function syncEditorState(regionId, editor, shouldLock, hydrated) {
var hidden = getEditorHiddenInput(regionId);
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
var fallbackContent = hidden && hidden.value !== undefined ? hidden.value : (sourceElm && sourceElm.value !== undefined ? sourceElm.value : '');
if (editor && typeof editor.save === 'function') { if (editor && typeof editor.save === 'function') {
if (typeof editor.blur === 'function') {
editor.blur();
}
editor.save(); editor.save();
} }
var content = editor.getContent({ format: 'html' }); var content = sourceElm && sourceElm.value !== undefined ? String(sourceElm.value || '') : getEditorContentSafely(editor, fallbackContent);
content = isEmptyRichTextValue(content) ? '' : content; content = isEmptyRichTextValue(content) ? '' : content;
var hidden = getEditorHiddenInput(regionId);
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
if (hidden) { if (hidden) {
hidden.value = content; hidden.value = content;
@@ -192,6 +223,191 @@ export function createSlideFormEditorController(options) {
: themeAssets.contentCss; : themeAssets.contentCss;
} }
function normalizeUploadPath(value) {
return String(value || '').trim();
}
function collectEditorImageUploadPaths(html) {
var matches = String(html || '').match(/\/media\/uploads\/[^^\s"'<>]+/g);
return matches ? Array.from(new Set(matches.map(normalizeUploadPath).filter(Boolean))) : [];
}
function collectCurrentEditorImageUploadPaths() {
var currentPaths = new Set();
editorInstances.forEach(function (editor, regionId) {
var hidden = getEditorHiddenInput(regionId);
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
var fallbackContent = hidden && hidden.value !== undefined ? hidden.value : (sourceElm && sourceElm.value !== undefined ? sourceElm.value : '');
collectEditorImageUploadPaths(getEditorContentSafely(editor, fallbackContent)).forEach(function (path) {
currentPaths.add(path);
});
});
return currentPaths;
}
function getImageUploadCleanupPaths() {
var currentPaths = collectCurrentEditorImageUploadPaths();
return Array.from(editorImageUploadPaths).filter(function (path) {
return !currentPaths.has(path);
});
}
function getCommittedImageUploadCleanupPaths() {
var currentPaths = collectCurrentEditorImageUploadPaths();
return Array.from(committedEditorImageUploadPaths).filter(function (path) {
return !currentPaths.has(path);
});
}
function getPendingImageUploadPaths() {
return Array.from(editorImageUploadPaths).filter(function (path) {
return !committedEditorImageUploadPaths.has(path);
});
}
function queueImageUploadCleanupPaths(paths) {
Array.from(new Set((paths || []).map(normalizeUploadPath).filter(Boolean))).forEach(function (path) {
pendingEditorImageUploadCleanupPaths.add(path);
});
}
function getPendingImageUploadCleanupPaths() {
var currentPaths = collectCurrentEditorImageUploadPaths();
return Array.from(pendingEditorImageUploadCleanupPaths).filter(function (path) {
return !currentPaths.has(path);
});
}
function markImageUploadsCommitted() {
committedEditorImageUploadPaths = collectCurrentEditorImageUploadPaths();
}
function getAllImageUploadPaths() {
return Array.from(editorImageUploadPaths);
}
function clearImageUploadPaths() {
editorImageUploadPaths.clear();
committedEditorImageUploadPaths.clear();
pendingEditorImageUploadCleanupPaths.clear();
}
function getFileExtension(fileName) {
var match = String(fileName || '').toLowerCase().match(/\.([a-z0-9]+)$/);
return match ? String(match[1] || '') : '';
}
function getImageUploadValidationMessage(blobInfo) {
var blob = blobInfo && typeof blobInfo.blob === 'function' ? blobInfo.blob() : null;
var fileName = blobInfo && typeof blobInfo.filename === 'function' ? String(blobInfo.filename() || '') : '';
var mimeType = blob && blob.type ? String(blob.type || '').trim().toLowerCase() : '';
var extension = getFileExtension(fileName);
if (!blob) {
return 'No image file was provided.';
}
if (Number(blob.size || 0) > imageUploadMaxBytes) {
return 'Image must be ' + imageUploadLimitLabel + ' or smaller. Larger images should use the dedicated Image region.';
}
if (mimeType && imageUploadAllowedMimeTypes.indexOf(mimeType) === -1) {
return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
}
if (!mimeType && extension && imageUploadAllowedExtensions.indexOf(extension) === -1) {
return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
}
if (!mimeType && !extension) {
return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
}
return '';
}
function uploadEditorImage(blobInfo, progress) {
var validationError = getImageUploadValidationMessage(blobInfo);
if (validationError) {
return Promise.reject(new Error(validationError));
}
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
var formData = new FormData();
var blob = blobInfo.blob();
var fileName = typeof blobInfo.filename === 'function' ? String(blobInfo.filename() || 'image') : 'image';
formData.append('file', blob, fileName);
xhr.open('POST', imageUploadUrl, true);
xhr.responseType = 'text';
xhr.withCredentials = true;
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
xhr.setRequestHeader('X-Upload-Context', imageUploadContext);
xhr.upload.onprogress = function (event) {
if (!progress) {
return;
}
if (!event || !event.lengthComputable || !event.total) {
progress(0);
return;
}
progress(Math.round((event.loaded / event.total) * 100));
};
xhr.onload = function () {
var responseText = String(xhr.responseText || '');
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(responseText || 'Unable to upload image.'));
return;
}
if (responseText.trim().toLowerCase().indexOf('<!doctype html') === 0 || responseText.toLowerCase().indexOf('<html') !== -1) {
reject(new Error('Upload redirected to an HTML page. Please sign in again and retry.'));
return;
}
var payload = {};
try {
payload = JSON.parse(responseText || '{}') || {};
} catch (_error) {
reject(new Error('Unable to parse the upload response.'));
return;
}
if (!payload.path) {
reject(new Error('Unable to upload image.'));
return;
}
if (progress) {
progress(100);
}
editorImageUploadPaths.add(String(payload.path || '').trim());
resolve(String(payload.path || ''));
};
xhr.onerror = function () {
reject(new Error('Unable to upload image.'));
};
xhr.ontimeout = function () {
reject(new Error('Upload timed out. Please try again.'));
};
xhr.send(formData);
});
}
function attachEditorEvents(regionId, editor) { function attachEditorEvents(regionId, editor) {
var hidden = getEditorHiddenInput(regionId); var hidden = getEditorHiddenInput(regionId);
var source = editor && editor.targetElm ? editor.targetElm : null; var source = editor && editor.targetElm ? editor.targetElm : null;
@@ -211,7 +427,7 @@ export function createSlideFormEditorController(options) {
} }
editor.on('init', function () { editor.on('init', function () {
var content = editor.getContent({ format: 'html' }); var content = getEditorContentSafely(editor, hidden && hidden.value !== undefined ? hidden.value : (source && source.value !== undefined ? source.value : ''));
content = isEmptyRichTextValue(content) ? '' : content; content = isEmptyRichTextValue(content) ? '' : content;
if (hidden) { if (hidden) {
hidden.value = content; hidden.value = content;
@@ -288,16 +504,27 @@ export function createSlideFormEditorController(options) {
menubar: false, menubar: false,
branding: false, branding: false,
promotion: false, promotion: false,
relative_urls: false,
remove_script_host: false,
convert_urls: true,
paste_data_images: false, paste_data_images: false,
plugins: 'lists code advlist fullscreen table', automatic_uploads: true,
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table chip | fullscreen', images_file_types: imageUploadFileTypes,
images_upload_handler: uploadEditorImage,
plugins: 'lists code advlist fullscreen table image',
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table image chip | fullscreen',
toolbar_mode: 'sliding', toolbar_mode: 'sliding',
license_key: 'gpl', license_key: 'gpl',
table_default_attributes: {
border: '1',
cellpadding: '0',
cellspacing: '0'
},
skin: themeAssets.skinName, skin: themeAssets.skinName,
skin_url: themeAssets.skinUrl, skin_url: themeAssets.skinUrl,
content_css: getContentCss(), content_css: getContentCss(),
body_class: themeAssets.bodyClass, body_class: themeAssets.bodyClass,
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid currentColor; padding: 0.35em 0.5em; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''), content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; border-spacing: 0; width: 100%; } td, th { border: 1px solid currentColor; padding: 0; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
font_family_formats: getFontFamilyFormats(), font_family_formats: getFontFamilyFormats(),
font_size_input_default_unit: 'px', font_size_input_default_unit: 'px',
invalid_elements: 'a', invalid_elements: 'a',
@@ -377,6 +604,7 @@ export function createSlideFormEditorController(options) {
if (editor.targetElm) { if (editor.targetElm) {
editor.targetElm.value = editor.getContent({ format: 'html' }); editor.targetElm.value = editor.getContent({ format: 'html' });
} }
markImageUploadsCommitted();
return editor; return editor;
}).catch(function (error) { }).catch(function (error) {
console.error('Failed to initialize TinyMCE.', error); console.error('Failed to initialize TinyMCE.', error);
@@ -405,18 +633,19 @@ export function createSlideFormEditorController(options) {
var saves = Array.prototype.map.call(templateFields.querySelectorAll('.editor-holder'), function (holder) { var saves = Array.prototype.map.call(templateFields.querySelectorAll('.editor-holder'), function (holder) {
var regionId = holder.getAttribute('data-region-id'); var regionId = holder.getAttribute('data-region-id');
var editor = editorInstances.get(regionId); var editor = editorInstances.get(regionId);
var hidden = getEditorHiddenInput(regionId);
if (!editor || !hidden) { if (!editor) {
return Promise.resolve(); return Promise.resolve();
} }
hidden.value = editor.getContent({ format: 'html' }); syncEditorState(regionId, editor, false, true);
syncEditorFontSizeHidden(regionId);
return Promise.resolve(); return Promise.resolve();
}); });
return Promise.all(saves); return Promise.all(saves).then(function (results) {
getCommittedImageUploadCleanupPaths();
return results;
});
} }
function destroyEditors() { function destroyEditors() {
@@ -454,6 +683,14 @@ export function createSlideFormEditorController(options) {
return { return {
renderEditors: renderEditors, renderEditors: renderEditors,
syncEditors: syncEditors, syncEditors: syncEditors,
getImageUploadCleanupPaths: getImageUploadCleanupPaths,
getCommittedImageUploadCleanupPaths: getCommittedImageUploadCleanupPaths,
getPendingImageUploadPaths: getPendingImageUploadPaths,
getPendingImageUploadCleanupPaths: getPendingImageUploadCleanupPaths,
getAllImageUploadPaths: getAllImageUploadPaths,
queueImageUploadCleanupPaths: queueImageUploadCleanupPaths,
markImageUploadsCommitted: markImageUploadsCommitted,
clearImageUploadPaths: clearImageUploadPaths,
destroyEditors: function () { destroyEditors: function () {
if (themeObserver) { if (themeObserver) {
themeObserver.disconnect(); themeObserver.disconnect();
+53
View File
@@ -41,6 +41,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var uploadMaxLabel = '100 MB'; var uploadMaxLabel = '100 MB';
var uploadVideoMaxBytes = 1024 * 1024 * 1024; var uploadVideoMaxBytes = 1024 * 1024 * 1024;
var uploadVideoMaxLabel = '1 GB'; var uploadVideoMaxLabel = '1 GB';
var wysiwygImageUploadMaxBytes = 2 * 1024 * 1024;
var wysiwygImageUploadLimitLabel = '2 MB';
var videoDurationCache = Object.create(null); var videoDurationCache = Object.create(null);
var previewRenderFrame = 0; var previewRenderFrame = 0;
var previewPopupWindow = null; var previewPopupWindow = null;
@@ -106,6 +108,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
defaultFontSize: DEFAULT_FONT_SIZE, defaultFontSize: DEFAULT_FONT_SIZE,
fontFamilyFormats: slideEditorData.fontFamilyFormats || '', fontFamilyFormats: slideEditorData.fontFamilyFormats || '',
fontStylesheetHref: fontStylesheetHref, fontStylesheetHref: fontStylesheetHref,
imageUploadMaxBytes: wysiwygImageUploadMaxBytes,
imageUploadLimitLabel: wysiwygImageUploadLimitLabel,
imageUploadContext: 'wysiwyg',
getRegionTypeModule: getRegionTypeModule, getRegionTypeModule: getRegionTypeModule,
getEditorBackgroundColor: function () { getEditorBackgroundColor: function () {
var template = getTemplateById(templateSelect.value); var template = getTemplateById(templateSelect.value);
@@ -820,6 +825,16 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
}); });
}); });
templateFields.querySelectorAll('input[type="url"][name^="region_webpage_"]').forEach(function (input) {
input.addEventListener('input', function () {
templateSelectorLock.markEdited();
});
input.addEventListener('change', function () {
templateSelectorLock.markEdited();
});
});
if (existingTemplateId && hasExistingSlideContent()) { if (existingTemplateId && hasExistingSlideContent()) {
templateSelectorLock.arm(); templateSelectorLock.arm();
templateSelectorLock.markEdited(); templateSelectorLock.markEdited();
@@ -840,6 +855,14 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
throw new Error('Wait for the image upload to finish before saving.'); throw new Error('Wait for the image upload to finish before saving.');
} }
await slideFormEditorController.syncEditors(); await slideFormEditorController.syncEditors();
await new Promise(function (resolve) {
window.requestAnimationFrame(function () {
resolve();
});
});
await new Promise(function (resolve) {
window.setTimeout(resolve, 0);
});
await syncRegionCards(); await syncRegionCards();
}, },
fallbackSuccessMessage: 'Saved slide.', fallbackSuccessMessage: 'Saved slide.',
@@ -867,18 +890,42 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
templateSelect.addEventListener('change', renderTemplate); templateSelect.addEventListener('change', renderTemplate);
templateFields.addEventListener('change', function () { templateFields.addEventListener('change', function () {
var webpageInput = event.target && typeof event.target.matches === 'function' && event.target.matches('input[type="url"][name^="region_webpage_"]');
if (webpageInput) {
templateSelectorLock.arm();
templateSelectorLock.markEdited();
return;
}
templateSelectorLock.arm(); templateSelectorLock.arm();
templateSelectorLock.markEdited(); templateSelectorLock.markEdited();
requestPreviewRender(); requestPreviewRender();
}); });
templateFields.addEventListener('input', function () { templateFields.addEventListener('input', function () {
var webpageInput = event.target && typeof event.target.matches === 'function' && event.target.matches('input[type="url"][name^="region_webpage_"]');
if (webpageInput) {
templateSelectorLock.arm();
templateSelectorLock.markEdited();
return;
}
templateSelectorLock.arm(); templateSelectorLock.arm();
templateSelectorLock.markEdited(); templateSelectorLock.markEdited();
requestPreviewRender(); requestPreviewRender();
}); });
templateFields.addEventListener('click', function (event) { templateFields.addEventListener('click', function (event) {
var button = event.target && typeof event.target.closest === 'function' ? event.target.closest('[data-api-items-path-reset]') : null; var button = event.target && typeof event.target.closest === 'function' ? event.target.closest('[data-api-items-path-reset]') : null;
var webpageUpdateButton = event.target && typeof event.target.closest === 'function' ? event.target.closest('[data-webpage-preview-update]') : null;
if (!button) { if (!button) {
if (!webpageUpdateButton) {
return;
}
}
if (webpageUpdateButton) {
templateSelectorLock.arm();
templateSelectorLock.markEdited();
requestPreviewRender();
return; return;
} }
@@ -915,6 +962,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
if (regionMediaController) { if (regionMediaController) {
regionMediaController.queueUploadCleanup(regionMediaController.getPendingUploadCleanupPaths()); regionMediaController.queueUploadCleanup(regionMediaController.getPendingUploadCleanupPaths());
} }
if (slideFormEditorController && typeof slideFormEditorController.getCommittedImageUploadCleanupPaths === 'function' && regionMediaController) {
regionMediaController.queueUploadCleanup(slideFormEditorController.getCommittedImageUploadCleanupPaths());
}
if (slideFormEditorController && typeof slideFormEditorController.getImageUploadCleanupPaths === 'function' && regionMediaController) {
regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths());
}
if (previewPopupWindow && !previewPopupWindow.closed) { if (previewPopupWindow && !previewPopupWindow.closed) {
previewPopupWindow.close(); previewPopupWindow.close();
} }
+6 -1
View File
@@ -37,7 +37,12 @@
} }
function getClientRowKey(client) { function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim(); var clientName = String(client && client.client_name ? client.client_name : '').trim();
if (clientName) {
return clientName;
}
return String(client && client.screen_slug ? client.screen_slug : client && client.deviceId ? client.deviceId : client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
} }
function getClientDisplayName(client) { function getClientDisplayName(client) {
+184 -141
View File
@@ -12,40 +12,122 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
const withClientNameReservation = deps.withClientNameReservation; const withClientNameReservation = deps.withClientNameReservation;
const broadcastDashboardState = deps.broadcastDashboardState; const broadcastDashboardState = deps.broadcastDashboardState;
const requirePermission = deps.requirePermission; const requirePermission = deps.requirePermission;
const ALL_SCREENS_SLUG = '__all__';
async function resolveScreenPlayerBaseUrls(screenSlug, connectionId) { function normalizeExplicitPlayerBaseUrl(value) {
if (typeof getScreenConnections !== 'function' || !screenSlug) { return String(value || '').trim().replace(/\/$/, '');
}
function normalizeConnectionBaseUrl(connection) {
return normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl);
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
async function fetchPlayerRegistrations() {
if (typeof common.fetchPlayerRegistrations !== 'function') {
return []; return [];
} }
try { try {
const response = await getScreenConnections(screenSlug); const registrations = await common.fetchPlayerRegistrations(pool);
const liveConnections = Array.isArray(response && response.connections) ? response.connections : []; return Array.isArray(registrations) ? registrations : [];
if (!liveConnections.length) {
return [];
}
const normalizedConnectionId = String(connectionId || '').trim();
const liveConnection = normalizedConnectionId
? liveConnections.find(function (connection) {
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
return candidateConnectionId === normalizedConnectionId || candidateDeviceId === normalizedConnectionId;
})
: null;
const targetConnections = liveConnection ? [liveConnection] : liveConnections;
return Array.from(new Set(targetConnections.map(function (connection) {
return String(connection && connection.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
}).filter(Boolean)));
} catch (_error) { } catch (_error) {
return []; return [];
} }
} }
function normalizeExplicitPlayerBaseUrl(value) { async function fetchScreenConnections(screenSlug) {
return String(value || '').trim().replace(/\/$/, ''); if (typeof getScreenConnections !== 'function') {
return [];
}
try {
const response = await getScreenConnections(screenSlug);
return Array.isArray(response && response.connections) ? response.connections : [];
} catch (_error) {
return [];
}
}
async function fetchAllScreenTargets() {
const [rows] = await pool.query(
`SELECT slug
FROM d_screens
WHERE slug IS NOT NULL
ORDER BY slug ASC`
);
return (rows || [])
.map(function (row) {
return {
slug: String(row && row.slug ? row.slug : '').trim()
};
})
.filter(function (row) {
return Boolean(row.slug);
});
}
async function resolvePlayerBaseUrlsForConnections(connections) {
const connectionList = Array.isArray(connections) ? connections : [];
const seen = new Set();
const registrations = await fetchPlayerRegistrations();
return connectionList.map(function (connection) {
const selectedPublicBaseUrl = normalizeConnectionBaseUrl(connection);
if (!selectedPublicBaseUrl || seen.has(selectedPublicBaseUrl)) {
return '';
}
seen.add(selectedPublicBaseUrl);
const matchedPlayer = registrations.find(function (player) {
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === selectedPublicBaseUrl;
}) || null;
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || selectedPublicBaseUrl;
}).filter(Boolean);
}
async function resolvePlayerBaseUrlForExplicitPublicBaseUrl(playerBaseUrl) {
const normalizedPublicBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
if (!normalizedPublicBaseUrl) {
return '';
}
const registrations = await fetchPlayerRegistrations();
const matchedPlayer = registrations.find(function (player) {
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === normalizedPublicBaseUrl;
}) || null;
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || normalizedPublicBaseUrl;
}
async function forwardPlayerCommandForConnections(screenSlug, connections, commandPayload, connectionId, deviceId) {
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections);
if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) {
return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined);
}));
return {
ok: true,
sent: results.filter(function (result) {
return result.status === 'fulfilled';
}).length,
results: results
};
}
return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined);
}
async function forwardPlayerCommandForPlayerBaseUrl(screenSlug, playerBaseUrl, commandPayload, connectionId, deviceId) {
const targetBaseUrl = await resolvePlayerBaseUrlForExplicitPublicBaseUrl(playerBaseUrl);
if (targetBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') {
return forwardPlayerCommandToBaseUrl(targetBaseUrl, screenSlug, commandPayload, connectionId || deviceId || undefined);
}
return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined);
} }
function isRecentPlayerRegistration(player, staleSeconds) { function isRecentPlayerRegistration(player, staleSeconds) {
@@ -56,32 +138,13 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
} }
async function resolveAllPlayerBaseUrls() {
if (!common || typeof common.fetchPlayerRegistrations !== 'function') {
return [];
}
try {
const players = await common.fetchPlayerRegistrations(pool);
return Array.from(new Set((Array.isArray(players) ? players : [])
.filter(function (player) {
return isRecentPlayerRegistration(player, 60);
})
.map(function (player) {
return String(player && player.public_base_url || '').trim().replace(/\/$/, '');
})
.filter(Boolean)));
} catch (_error) {
return [];
}
}
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) { app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
try { try {
const slug = String(req.params.slug || '').trim(); const slug = String(req.params.slug || '').trim();
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase(); 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(); const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
const explicitPlayerBaseUrl = normalizeExplicitPlayerBaseUrl((req.body && (req.body.playerBaseUrl || req.body.playerPublicBaseUrl)) || req.query.playerBaseUrl || req.query.playerPublicBaseUrl || ''); const playerBaseUrl = String((req.body && req.body.playerBaseUrl) || req.query.playerBaseUrl || '').trim();
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout ? req.body.blackout
: req.query.blackout; : req.query.blackout;
@@ -93,14 +156,25 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(400).json({ error: 'Command is required' }); return res.status(400).json({ error: 'Command is required' });
} }
if (slug === ALL_SCREENS_SLUG) { if (slug === '__all__') {
if (command === 'setclientname' || command === 'moveclient') { if (command !== 'reload' && command !== 'pause' && command !== 'blackout') {
return res.status(400).json({ error: 'This command requires a specific screen.' }); return res.status(400).json({ error: 'This command requires a specific screen.' });
} }
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC'); const targets = await fetchAllScreenTargets();
if (!screenRows.length) { if (!targets.length) {
return res.status(404).json({ error: 'No screens found' }); if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json({
ok: true,
allScreens: true,
command: command,
targetScreenCount: 0,
targetPlayerCount: 0,
sent: 0
});
} }
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
@@ -109,36 +183,41 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') { if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
commandPayload.blackout = blackoutValue; commandPayload.blackout = blackoutValue;
} }
if (command === 'pause' && req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused') && commandPayload && typeof commandPayload === 'object') {
commandPayload.paused = req.body.paused;
}
await Promise.all(screenRows.map(function (screenRow) { const results = await Promise.allSettled(targets.map(async function (target) {
const screenSlug = String(screenRow && screenRow.slug || '').trim(); const liveConnections = await fetchScreenConnections(target.slug);
return resolveScreenPlayerBaseUrls(screenSlug, connectionId).then(function (playerBaseUrls) { if (liveConnections.length) {
if (playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') { return forwardPlayerCommandForConnections(target.slug, liveConnections, commandPayload);
return Promise.all(playerBaseUrls.map(function (playerBaseUrl) { }
return forwardPlayerCommandToBaseUrl(playerBaseUrl, screenSlug, commandPayload, connectionId || undefined);
}));
}
return forwardPlayerCommand(screenSlug, commandPayload); return forwardPlayerCommand(target.slug, commandPayload);
});
})); }));
const sentCount = results.reduce(function (total, result) {
if (result.status !== 'fulfilled') {
return total;
}
const sentValue = Number(result.value && typeof result.value.sent === 'number' ? result.value.sent : 1);
return total + (Number.isFinite(sentValue) && sentValue > 0 ? sentValue : 1);
}, 0);
if (typeof broadcastDashboardState === 'function') { if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState(); await broadcastDashboardState();
} }
return res.json({ return res.json({
screen: {
id: null,
name: 'All screens',
slug: ALL_SCREENS_SLUG
},
screenSlug: slug,
command: command,
connectionId: connectionId || null,
targetScreenCount: screenRows.length,
ok: true, ok: true,
allScreens: true allScreens: true,
command: command,
targetScreenCount: targets.length,
targetPlayerCount: sentCount,
sent: sentCount,
blackout: command === 'blackout' ? Boolean(commandPayload.blackout) : undefined,
paused: command === 'pause' ? Boolean(commandPayload.paused) : undefined
}); });
} }
@@ -147,30 +226,8 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(404).json({ error: 'Screen not found' }); return res.status(404).json({ error: 'Screen not found' });
} }
if (explicitPlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function' && command !== 'moveclient') {
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: { command: command };
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
commandPayload.blackout = blackoutValue;
}
const result = await forwardPlayerCommandToBaseUrl(explicitPlayerBaseUrl, slug, commandPayload, connectionId || undefined);
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json(Object.assign({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null
}, result && typeof result === 'object' ? result : {}));
}
if (command === 'setclientname') { if (command === 'setclientname') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim(); const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim(); const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
if (!deviceId) { if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' }); return res.status(400).json({ error: 'Device ID is required' });
@@ -231,12 +288,21 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
} }
if (!onboardingRow) { if (!onboardingRow) {
await forwardPlayerCommand(slug, { if (playerBaseUrl) {
command: command, await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
clientName: clientName, command: command,
clientId: connectionId || deviceId || null, clientName: clientName,
deviceId: deviceId || null clientId: connectionId || deviceId || null,
}, connectionId || deviceId || undefined); deviceId: deviceId || null
}, connectionId || deviceId || undefined, deviceId || null);
} else {
await forwardPlayerCommandForConnections(slug, liveConnections, {
command: command,
clientName: clientName,
clientId: connectionId || deviceId || null,
deviceId: deviceId || null
}, connectionId || deviceId || undefined, deviceId || null);
}
if (typeof broadcastDashboardState === 'function') { if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState(); await broadcastDashboardState();
@@ -278,7 +344,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
} }
if (command === 'moveclient') { if (command === 'moveclient') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim(); const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim(); const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim(); const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
@@ -323,52 +389,35 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(500).json({ error: 'Client binding is unavailable.' }); return res.status(500).json({ error: 'Client binding is unavailable.' });
} }
let liveConnections = []; const liveConnections = await fetchScreenConnections(slug);
try {
const [screenSlugs] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
const screenSlug = String(row && row.slug ? row.slug : '').trim();
if (!screenSlug || typeof getScreenConnections !== 'function') {
return [];
}
try {
const liveResponse = await getScreenConnections(screenSlug);
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
} catch (_error) {
return [];
}
}));
liveConnections = liveResults.flat();
} catch (_error) {
liveConnections = [];
}
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections); const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
let targetPlayerUrl = '';
const liveConnection = Array.isArray(liveConnections) const liveConnection = Array.isArray(liveConnections)
? liveConnections.find(function (connection) { ? liveConnections.find(function (connection) {
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim(); const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
const candidateDeviceId = String(connection && connection.deviceId || '').trim(); const candidateDeviceId = String(connection && connection.deviceId || '').trim();
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId; return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
}) }) || liveConnections[0] || null
: null; : null;
const sourcePlayerBaseUrl = String( const sourcePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || '');
explicitPlayerBaseUrl || if (sourcePlayerBaseUrl) {
(liveConnection && liveConnection.playerPublicBaseUrl) || targetPlayerUrl = `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}`;
(typeof common.fetchPlayerPublicBaseUrl === 'function' ? await common.fetchPlayerPublicBaseUrl(pool) : '') || }
'' if (!targetPlayerUrl) {
).trim().replace(/\/$/, ''); targetPlayerUrl = `/screen/${encodeURIComponent(targetScreenSlug)}`;
const targetPlayerUrl = sourcePlayerBaseUrl ? `${sourcePlayerBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`; }
if (sourcePlayerBaseUrl && typeof forwardPlayerCommandToBaseUrl === 'function') { if (playerBaseUrl) {
await forwardPlayerCommandToBaseUrl(sourcePlayerBaseUrl, slug, { await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
command: 'redirect', command: 'redirect',
url: targetPlayerUrl url: targetPlayerUrl
}, connectionId || deviceId || undefined); }, connectionId || deviceId || undefined, deviceId || null);
} else { } else {
await forwardPlayerCommand(slug, { await forwardPlayerCommandForConnections(slug, liveConnections, {
command: 'redirect', command: 'redirect',
url: targetPlayerUrl url: targetPlayerUrl
}, connectionId || deviceId || undefined); }, connectionId || deviceId || undefined, deviceId || null);
} }
if (typeof broadcastDashboardState === 'function') { if (typeof broadcastDashboardState === 'function') {
@@ -395,16 +444,10 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
commandPayload.blackout = blackoutValue; commandPayload.blackout = blackoutValue;
} }
const playerBaseUrls = await resolveScreenPlayerBaseUrls(slug, connectionId); const liveConnections = await fetchScreenConnections(slug);
const result = playerBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function' const result = playerBaseUrl
? await Promise.all(playerBaseUrls.map(function (playerBaseUrl) { ? await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, commandPayload, connectionId, null)
return forwardPlayerCommandToBaseUrl(playerBaseUrl, slug, commandPayload, connectionId); : await forwardPlayerCommandForConnections(slug, liveConnections, commandPayload, connectionId, null);
})).then(function (results) {
return Array.isArray(results) && results.length ? results[0] : { ok: true };
})
: (connectionId
? await forwardPlayerCommand(slug, commandPayload, connectionId)
: await forwardPlayerCommand(slug, commandPayload));
if (typeof broadcastDashboardState === 'function') { if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState(); await broadcastDashboardState();
+29 -10
View File
@@ -33,6 +33,7 @@ module.exports = function registerContentRoutes(app, deps) {
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate'); const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
const LIST_PAGE_SIZE = 25; const LIST_PAGE_SIZE = 25;
const WYSIWYG_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024; const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
@@ -104,9 +105,15 @@ module.exports = function registerContentRoutes(app, deps) {
next(error); next(error);
} }
function getUploadedFileMediaType(file) { function getUploadedFileMediaType(file, uploadContext) {
const mimeType = String(file && file.mimetype || '').trim().toLowerCase(); const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
const extension = path.extname(String(file && file.originalname || '')).toLowerCase(); const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
const isWysiwyg = String(uploadContext || '').trim().toLowerCase() === 'wysiwyg';
if (isWysiwyg && mimeType.indexOf('image/') !== 0 && ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.avif', '.tif', '.tiff'].indexOf(extension) === -1) {
return null;
}
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) { if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
return 'video'; return 'video';
} }
@@ -116,12 +123,20 @@ module.exports = function registerContentRoutes(app, deps) {
return null; return null;
} }
function getUploadedFileLimitBytes(file) { function getUploadedFileLimitBytes(file, uploadContext) {
return getUploadedFileMediaType(file) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES; if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
return WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
}
return getUploadedFileMediaType(file, uploadContext) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
} }
function getUploadedFileLimitLabel(file) { function getUploadedFileLimitLabel(file, uploadContext) {
return getUploadedFileMediaType(file) === 'video' ? '1 GB' : '100 MB'; if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
return '10 MB';
}
return getUploadedFileMediaType(file, uploadContext) === 'video' ? '1 GB' : '100 MB';
} }
async function removeUploadedFile(file) { async function removeUploadedFile(file) {
@@ -137,11 +152,11 @@ module.exports = function registerContentRoutes(app, deps) {
} }
} }
async function validateUploadedFiles(files) { async function validateUploadedFiles(files, uploadContext) {
const list = Array.isArray(files) ? files.filter(Boolean) : []; const list = Array.isArray(files) ? files.filter(Boolean) : [];
for (let i = 0; i < list.length; i += 1) { for (let i = 0; i < list.length; i += 1) {
const file = list[i]; const file = list[i];
const mediaType = getUploadedFileMediaType(file); const mediaType = getUploadedFileMediaType(file, uploadContext);
if (!mediaType) { if (!mediaType) {
await removeUploadedFile(file); await removeUploadedFile(file);
const error = new Error('Unsupported upload type.'); const error = new Error('Unsupported upload type.');
@@ -150,9 +165,11 @@ module.exports = function registerContentRoutes(app, deps) {
throw error; throw error;
} }
if (Number(file.size || 0) > getUploadedFileLimitBytes(file)) { if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext)) {
await removeUploadedFile(file); await removeUploadedFile(file);
const error = new Error('File must be ' + getUploadedFileLimitLabel(file) + ' or smaller.'); const error = new Error(String(uploadContext || '').trim().toLowerCase() === 'wysiwyg'
? 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.'
: 'File must be ' + getUploadedFileLimitLabel(file, uploadContext) + ' or smaller.');
error.statusCode = 400; error.statusCode = 400;
error.expose = true; error.expose = true;
throw error; throw error;
@@ -376,7 +393,9 @@ module.exports = function registerContentRoutes(app, deps) {
return res.status(400).json({ error: 'No file was uploaded.' }); return res.status(400).json({ error: 'No file was uploaded.' });
} }
await validateUploadedFiles([req.file]); const uploadContext = String(req.get('X-Upload-Context') || req.query.context || '').trim().toLowerCase();
await validateUploadedFiles([req.file], uploadContext);
res.json({ res.json({
path: '/media/uploads/' + req.file.filename, path: '/media/uploads/' + req.file.filename,
+122 -17
View File
@@ -11,6 +11,7 @@ module.exports = function registerManageRoutes(app, deps) {
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage; const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
const getScreenConnections = deps.getScreenConnections; const getScreenConnections = deps.getScreenConnections;
const forwardPlayerCommand = deps.forwardPlayerCommand; const forwardPlayerCommand = deps.forwardPlayerCommand;
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
const requirePermission = deps.requirePermission; const requirePermission = deps.requirePermission;
const SCREEN_NAME_MAX_LENGTH = 255; const SCREEN_NAME_MAX_LENGTH = 255;
@@ -25,13 +26,102 @@ module.exports = function registerManageRoutes(app, deps) {
return text.slice(0, limit); return text.slice(0, limit);
} }
async function fetchAllScreenSlugs() { function normalizeExplicitPlayerBaseUrl(value) {
const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC'); return String(value || '').trim().replace(/\/$/, '');
}
async function fetchPlayerRegistrations() {
if (typeof common.fetchPlayerRegistrations !== 'function') {
return [];
}
try {
const registrations = await common.fetchPlayerRegistrations(pool);
return Array.isArray(registrations) ? registrations : [];
} catch (_error) {
return [];
}
}
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
async function fetchLiveConnectionsForScreen(slug) {
if (typeof getScreenConnections !== 'function') {
return [];
}
try {
const response = await getScreenConnections(slug);
return Array.isArray(response && response.connections) ? response.connections : [];
} catch (_error) {
return [];
}
}
async function resolvePlayerBaseUrlsForConnections(connections) {
const connectionList = Array.isArray(connections) ? connections : [];
const seen = new Set();
const registrations = await fetchPlayerRegistrations();
return connectionList.map(function (connection) {
const selectedPublicBaseUrl = normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl);
if (!selectedPublicBaseUrl || seen.has(selectedPublicBaseUrl)) {
return '';
}
seen.add(selectedPublicBaseUrl);
const matchedPlayer = registrations.find(function (player) {
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === selectedPublicBaseUrl;
}) || null;
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || selectedPublicBaseUrl;
}).filter(Boolean);
}
async function forwardPlayerCommandForConnections(slug, connections, commandPayload) {
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections);
if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) {
return forwardPlayerCommandToBaseUrl(targetBaseUrl, slug, commandPayload);
}));
return {
ok: true,
sent: results.filter(function (result) {
return result.status === 'fulfilled';
}).length,
results: results
};
}
return forwardPlayerCommand(slug, commandPayload);
}
async function fetchAllScreenTargets() {
const [rows] = await pool.query(
`SELECT s.slug
FROM d_screens s
WHERE s.slug IS NOT NULL
ORDER BY s.slug ASC`
);
return (rows || []) return (rows || [])
.map(function (row) { .map(function (row) {
return String(row && row.slug ? row.slug : '').trim(); return {
slug: String(row && row.slug ? row.slug : '').trim(),
playerId: '',
publicBaseUrl: '',
internalBaseUrl: ''
};
}) })
.filter(Boolean); .filter(function (row) {
return Boolean(row.slug);
});
} }
app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) { app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
@@ -52,8 +142,8 @@ module.exports = function registerManageRoutes(app, deps) {
return res.status(400).json({ error: 'Unsupported command' }); return res.status(400).json({ error: 'Unsupported command' });
} }
const slugs = await fetchAllScreenSlugs(); const targets = await fetchAllScreenTargets();
if (!slugs.length) { if (!targets.length) {
await broadcastDashboardState(); await broadcastDashboardState();
return res.json({ ok: true, command: command, sent: 0 }); return res.json({ ok: true, command: command, sent: 0 });
} }
@@ -70,7 +160,22 @@ module.exports = function registerManageRoutes(app, deps) {
} }
: 'reload'; : 'reload';
const sentCount = await notifyPlayerScreens(slugs, payload); const results = await Promise.allSettled(targets.map(async function (target) {
const liveConnections = await fetchLiveConnectionsForScreen(target.slug);
if (liveConnections.length) {
return forwardPlayerCommandForConnections(target.slug, liveConnections, payload);
}
return forwardPlayerCommand(target.slug, payload);
}));
const sentCount = results.reduce(function (total, result) {
if (result.status !== 'fulfilled') {
return total;
}
const sentValue = Number(result.value && typeof result.value.sent === 'number' ? result.value.sent : 1);
return total + (Number.isFinite(sentValue) && sentValue > 0 ? sentValue : 1);
}, 0);
await broadcastDashboardState(); await broadcastDashboardState();
return res.json({ return res.json({
@@ -137,25 +242,25 @@ module.exports = function registerManageRoutes(app, deps) {
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) { if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
return res.status(400).send('A screen with that name already exists.'); return res.status(400).send('A screen with that name already exists.');
} }
const slugInput = typeof common.validateMaxLength === 'function'
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
: readLimitedText(req.body.slug || '', 255);
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null; const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
const previousPlaylistId = screen.playlist_id; const previousPlaylistId = screen.playlist_id;
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id); const slug = String(screen.slug || '').trim();
const previousSlug = String(screen.slug || '').trim(); const previousSlug = String(screen.slug || '').trim();
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]); await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
if (previousPlaylistId !== playlistId && previousSlug) { if (previousPlaylistId !== playlistId && previousSlug) {
await notifyPlayerScreens([previousSlug], 'refresh'); await notifyPlayerScreens([previousSlug], 'refresh');
} }
if (previousSlug && previousSlug !== slug) { if (previousSlug && previousSlug !== slug) {
const playerBaseUrl = typeof common.fetchPlayerPublicBaseUrl === 'function' const previousConnections = await fetchLiveConnectionsForScreen(previousSlug);
? await common.fetchPlayerPublicBaseUrl(pool) const redirectPayload = {
: '';
await forwardPlayerCommand(previousSlug, {
command: 'redirect', command: 'redirect',
url: playerBaseUrl ? `${playerBaseUrl}/screen/${encodeURIComponent(slug)}` : `/screen/${encodeURIComponent(slug)}` url: `/screen/${encodeURIComponent(slug)}`
}); };
if (previousConnections.length) {
await forwardPlayerCommandForConnections(previousSlug, previousConnections, redirectPayload);
} else {
await forwardPlayerCommand(previousSlug, redirectPayload);
}
} }
redirectAfterSave(req, res, '/screens?edit=' + screen.id, { redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
closeUrl: '/screens', closeUrl: '/screens',
+24 -4
View File
@@ -19,6 +19,11 @@
const LIST_PAGE_SIZE = 25; const LIST_PAGE_SIZE = 25;
function toSortIndex(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 999;
}
function slugifyRoleKey(name) { function slugifyRoleKey(name) {
const value = String(name || '').trim().toLowerCase(); const value = String(name || '').trim().toLowerCase();
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ROLE_KEY_MAX_LENGTH); const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ROLE_KEY_MAX_LENGTH);
@@ -73,8 +78,11 @@
resourceKey: definition ? definition.resourceKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'), resourceKey: definition ? definition.resourceKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
resourceName: definition ? definition.resourceName : permission.section_name, resourceName: definition ? definition.resourceName : permission.section_name,
categoryName: definition ? definition.sectionName : permission.section_name, categoryName: definition ? definition.sectionName : permission.section_name,
sectionIndex: definition ? toSortIndex(definition.sectionIndex) : 999,
sectionOrder: definition ? definition.sectionOrder : 999, sectionOrder: definition ? definition.sectionOrder : 999,
resourceIndex: definition ? toSortIndex(definition.resourceIndex) : 999,
resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999, resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999,
permissionIndex: definition ? toSortIndex(definition.permissionIndex) : 999,
permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999, permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999,
actionKey: definition ? definition.actionKey : 'read', actionKey: definition ? definition.actionKey : 'read',
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey), actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
@@ -103,7 +111,9 @@
id: sectionKey || 'permissions', id: sectionKey || 'permissions',
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(), title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
categoryName: String(permission.categoryName || '').trim(), categoryName: String(permission.categoryName || '').trim(),
sectionIndex: toSortIndex(permission.sectionIndex),
sectionOrder: Number(permission.sectionOrder) || 999, sectionOrder: Number(permission.sectionOrder) || 999,
resourceIndex: toSortIndex(permission.resourceIndex),
permissions: [] permissions: []
}; };
groupIndex.set(sectionKey, group); groupIndex.set(sectionKey, group);
@@ -114,8 +124,13 @@
groups.forEach(function (group) { groups.forEach(function (group) {
group.permissions.sort(function (left, right) { group.permissions.sort(function (left, right) {
const leftOrder = Number(left.permissionOrder) || 999; const leftIndex = toSortIndex(left.permissionIndex);
const rightOrder = Number(right.permissionOrder) || 999; const rightIndex = toSortIndex(right.permissionIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.permissionOrder);
const rightOrder = toSortIndex(right.permissionOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -124,8 +139,13 @@
}); });
groups.sort(function (left, right) { groups.sort(function (left, right) {
const leftOrder = Number(left.sectionOrder) || 999; const leftIndex = toSortIndex(left.sectionIndex);
const rightOrder = Number(right.sectionOrder) || 999; const rightIndex = toSortIndex(right.sectionIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.sectionOrder);
const rightOrder = toSortIndex(right.sectionOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -13,10 +13,13 @@ function toIsoTimestamp(value) {
function formatIntervalLabel(interval, unit) { function formatIntervalLabel(interval, unit) {
const value = Math.max(1, Number(interval) || 0); const value = Math.max(1, Number(interval) || 0);
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes'; const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
if (normalizedUnit === 'seconds') { if (normalizedUnit === 'seconds') {
return value === 1 ? 'Every second' : `Every ${value} seconds`; return value === 1 ? 'Every second' : `Every ${value} seconds`;
} }
if (normalizedUnit === 'hours') {
return value === 1 ? 'Every hour' : `Every ${value} hours`;
}
return value === 1 ? 'Every minute' : `Every ${value} minutes`; return value === 1 ? 'Every minute' : `Every ${value} minutes`;
} }
@@ -105,7 +105,9 @@ module.exports = function registerApiSourceRoutes(app, deps) {
itemsPath: apiSource.items_path || '', itemsPath: apiSource.items_path || '',
intervalLabel: apiSource.update_interval_unit === 'seconds' intervalLabel: apiSource.update_interval_unit === 'seconds'
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`) ? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`), : apiSource.update_interval_unit === 'hours'
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every hour' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} hours`)
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never', lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '', lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
inUse: usageIds.has(Number(apiSource.id)) inUse: usageIds.has(Number(apiSource.id))
@@ -4,10 +4,13 @@ const { renderView } = require('../../../view');
function formatIntervalLabel(interval, unit) { function formatIntervalLabel(interval, unit) {
const value = Math.max(1, Number(interval) || 0); const value = Math.max(1, Number(interval) || 0);
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes'; const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
if (normalizedUnit === 'seconds') { if (normalizedUnit === 'seconds') {
return value === 1 ? 'Every second' : `Every ${value} seconds`; return value === 1 ? 'Every second' : `Every ${value} seconds`;
} }
if (normalizedUnit === 'hours') {
return value === 1 ? 'Every hour' : `Every ${value} hours`;
}
return value === 1 ? 'Every minute' : `Every ${value} minutes`; return value === 1 ? 'Every minute' : `Every ${value} minutes`;
} }
@@ -9,7 +9,8 @@ function buildDuplicateTimetableGroup(timetableGroup, duplicateName) {
return Object.assign({}, timetableGroup, { return Object.assign({}, timetableGroup, {
id: null, id: null,
name: duplicateName, name: duplicateName,
shortDescription: timetableGroup.short_description || '' shortDescription: timetableGroup.short_description || '',
timezone: timetableGroup.timezone || 'UTC'
}); });
} }
@@ -1,14 +1,40 @@
// Shared timetable group form view-model builder. // Shared timetable group form view-model builder.
const { normalizeTimeZone } = require('../../../../data/timetables');
const COMMON_TIME_ZONES = [
'UTC',
'Europe/London',
'Europe/Dublin',
'Europe/Paris',
'Europe/Berlin',
'Europe/Madrid',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Toronto',
'America/Vancouver',
'America/Sao_Paulo',
'Asia/Dubai',
'Asia/Kolkata',
'Asia/Singapore',
'Asia/Tokyo',
'Asia/Seoul',
'Australia/Sydney',
'Pacific/Auckland'
];
function buildDefaultTimetableGroup() { function buildDefaultTimetableGroup() {
return { return {
id: null, id: null,
name: '', name: '',
shortDescription: '' shortDescription: '',
timezone: 'Europe/London'
}; };
} }
function formatDateTimeLocalValue(value) { function formatDateTimeLocalValue(value, timeZone) {
if (!value) { if (!value) {
return ''; return '';
} }
@@ -18,22 +44,49 @@ function formatDateTimeLocalValue(value) {
return ''; return '';
} }
const year = String(date.getFullYear()).padStart(4, '0'); const resolvedTimeZone = normalizeTimeZone(timeZone, 'Europe/London');
const month = String(date.getMonth() + 1).padStart(2, '0'); const parts = new Intl.DateTimeFormat('en-GB', {
const day = String(date.getDate()).padStart(2, '0'); timeZone: resolvedTimeZone,
const hours = String(date.getHours()).padStart(2, '0'); hour12: false,
const minutes = String(date.getMinutes()).padStart(2, '0'); year: 'numeric',
return `${year}-${month}-${day}T${hours}:${minutes}`; month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).formatToParts(date).reduce(function (acc, part) {
if (part && part.type && part.type !== 'literal') {
acc[part.type] = part.value;
}
return acc;
}, Object.create(null));
if (!parts.year || !parts.month || !parts.day || !parts.hour || !parts.minute) {
return '';
}
return [
String(parts.year).padStart(4, '0'),
'-',
String(parts.month).padStart(2, '0'),
'-',
String(parts.day).padStart(2, '0'),
'T',
String(parts.hour).padStart(2, '0'),
':',
String(parts.minute).padStart(2, '0')
].join('');
} }
function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit, options) { function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit, options) {
const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {}); const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {});
viewTimetableGroup.timezone = normalizeTimeZone(viewTimetableGroup.timezone, 'Europe/London');
const viewOptions = options || {}; const viewOptions = options || {};
const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length
? timetableEntries.map(function (entry) { ? timetableEntries.map(function (entry) {
return Object.assign({}, entry, { return Object.assign({}, entry, {
startValue: formatDateTimeLocalValue(entry.start_datetime), startValue: formatDateTimeLocalValue(entry.start_datetime, viewTimetableGroup.timezone),
endValue: formatDateTimeLocalValue(entry.end_datetime) endValue: formatDateTimeLocalValue(entry.end_datetime, viewTimetableGroup.timezone)
}); });
}) })
: [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }]; : [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }];
@@ -46,6 +99,7 @@ function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, mess
messageVariant: viewOptions.messageVariant || '', messageVariant: viewOptions.messageVariant || '',
isEdit: Boolean(isEdit), isEdit: Boolean(isEdit),
timetableGroup: viewTimetableGroup, timetableGroup: viewTimetableGroup,
timeZoneOptions: COMMON_TIME_ZONES,
timetableEntries: viewTimetableEntries, timetableEntries: viewTimetableEntries,
inUse: Boolean(viewTimetableGroup.inUse), inUse: Boolean(viewTimetableGroup.inUse),
showSaveSecondaryActions: true, showSaveSecondaryActions: true,
+29 -6
View File
@@ -1,22 +1,45 @@
// Timetable group list page renderer. // Timetable group list page renderer.
const { renderView } = require('../../../view'); const { renderView } = require('../../../view');
const { normalizeTimeZone } = require('../../../../data/timetables');
function formatNextStartLabel(value, formatDashboardDate) { function formatDateInTimeZone(value, timeZone) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const resolvedTimeZone = normalizeTimeZone(timeZone, 'Europe/London');
return new Intl.DateTimeFormat('en-US', {
timeZone: resolvedTimeZone,
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function formatNextStartLabel(value, timeZone) {
if (!value) { if (!value) {
return 'No entries'; return 'No entries';
} }
const label = typeof formatDashboardDate === 'function' const label = formatDateInTimeZone(value, timeZone) || String(value);
? formatDashboardDate(value)
: String(value);
return label || 'No entries'; return label || 'No entries';
} }
module.exports = function renderTimetableGroupsPage(data, message, currentUser, formatDashboardDate) { module.exports = function renderTimetableGroupsPage(data, message, currentUser) {
const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) { const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) {
const timeZone = normalizeTimeZone(timetableGroup.timezone, 'Europe/London');
return Object.assign({}, timetableGroup, { return Object.assign({}, timetableGroup, {
nextStartLabel: formatNextStartLabel(timetableGroup.next_start_datetime, formatDashboardDate), timeZone: timeZone,
nextStartLabel: formatNextStartLabel(timetableGroup.next_start_datetime, timeZone),
nextStartValue: timetableGroup.next_start_datetime ? new Date(timetableGroup.next_start_datetime).toISOString() : '' nextStartValue: timetableGroup.next_start_datetime ? new Date(timetableGroup.next_start_datetime).toISOString() : ''
}); });
}); });
@@ -42,7 +42,7 @@ async function getDataSourceUsageMaps(pool, common) {
return timetableGroupIds; return timetableGroupIds;
} }
function readScheduleArrayFieldValue(body, keys) { function readTimetableArrayFieldValue(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys]; const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) { for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index]; const key = searchKeys[index];
@@ -57,17 +57,17 @@ function readScheduleArrayFieldValue(body, keys) {
return []; return [];
} }
function buildScheduleEntryRows(req, parseDateTimeLocal) { function buildTimetableEntryRows(req, parseDateTimeLocal) {
const body = req.body || {}; const body = req.body || {};
const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']); const ids = readTimetableArrayFieldValue(body, ['entry_id[]', 'entry_id']);
const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']); const titles = readTimetableArrayFieldValue(body, ['entry_title[]', 'entry_title']);
const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']); const descriptions = readTimetableArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']); const starts = readTimetableArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']); const ends = readTimetableArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']);
const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean); const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean);
if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) { if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) {
const error = new Error('Schedule entry data is invalid.'); const error = new Error('Timetable entry data is invalid.');
error.statusCode = 400; error.statusCode = 400;
throw error; throw error;
} }
@@ -86,19 +86,19 @@ function buildScheduleEntryRows(req, parseDateTimeLocal) {
} }
if (!title) { if (!title) {
const error = new Error('Each schedule entry requires a title.'); const error = new Error('Each timetable entry requires a title.');
error.statusCode = 400; error.statusCode = 400;
throw error; throw error;
} }
if (!startDatetime) { if (!startDatetime) {
const error = new Error('Each schedule entry requires a start datetime.'); const error = new Error('Each timetable entry requires a start datetime.');
error.statusCode = 400; error.statusCode = 400;
throw error; throw error;
} }
if (endDatetime && endDatetime < startDatetime) { if (endDatetime && endDatetime < startDatetime) {
const error = new Error('Schedule entry end datetime must be after the start datetime.'); const error = new Error('Timetable entry end datetime must be after the start datetime.');
error.statusCode = 400; error.statusCode = 400;
throw error; throw error;
} }
@@ -191,7 +191,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
let duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name); let duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name);
let duplicateIndex = 2; let duplicateIndex = 2;
while (await common.fetchDuplicateName(pool, 'i_schedule_groups', duplicateName)) { while (await common.fetchDuplicateName(pool, 'i_timetable_groups', duplicateName)) {
duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name) + ' (' + duplicateIndex + ')'; duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name) + ' (' + duplicateIndex + ')';
duplicateIndex += 1; duplicateIndex += 1;
} }
@@ -207,16 +207,16 @@ module.exports = function registerTimetableRoutes(app, deps) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
const payload = common.buildTimetableGroupPayload(req, null); const payload = common.buildTimetableGroupPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) { if (await common.fetchDuplicateName(pool, 'i_timetable_groups', payload.name)) {
return res.redirect('/data-sources/timetables/new?message=' + encodeURIComponent('A timetable group with that name already exists.')); return res.redirect('/data-sources/timetables/new?message=' + encodeURIComponent('A timetable group with that name already exists.'));
} }
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal); const entryRows = buildTimetableEntryRows(req, parseDateTimeLocal);
const actorId = getAuditUserId(req); const actorId = getAuditUserId(req);
await connection.beginTransaction(); await connection.beginTransaction();
const [result] = await connection.query( const [result] = await connection.query(
'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)', 'INSERT INTO i_timetable_groups (name, short_description, timezone, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[payload.name, payload.shortDescription || null, actorId, actorId] [payload.name, payload.shortDescription || null, payload.timezone, actorId, actorId]
); );
if (entryRows.length) { if (entryRows.length) {
@@ -233,7 +233,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
}); });
await connection.query( await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?', 'INSERT INTO i_timetable_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows] [insertRows]
); );
} }
@@ -265,18 +265,18 @@ module.exports = function registerTimetableRoutes(app, deps) {
} }
const payload = common.buildTimetableGroupPayload(req, timetableGroup); const payload = common.buildTimetableGroupPayload(req, timetableGroup);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, timetableGroup.id)) { if (await common.fetchDuplicateName(pool, 'i_timetable_groups', payload.name, timetableGroup.id)) {
return res.redirect('/data-sources/timetables/' + timetableGroup.id + '/edit?message=' + encodeURIComponent('A timetable group with that name already exists.')); return res.redirect('/data-sources/timetables/' + timetableGroup.id + '/edit?message=' + encodeURIComponent('A timetable group with that name already exists.'));
} }
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal); const entryRows = buildTimetableEntryRows(req, parseDateTimeLocal);
const actorId = getAuditUserId(req); const actorId = getAuditUserId(req);
await connection.beginTransaction(); await connection.beginTransaction();
await connection.query( await connection.query(
'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?', 'UPDATE i_timetable_groups SET name = ?, short_description = ?, timezone = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.shortDescription || null, actorId, timetableGroup.id] [payload.name, payload.shortDescription || null, payload.timezone, actorId, timetableGroup.id]
); );
await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [timetableGroup.id]); await connection.query('DELETE FROM i_timetable_entries WHERE schedule_group_id = ?', [timetableGroup.id]);
if (entryRows.length) { if (entryRows.length) {
const insertRows = entryRows.map(function (entry) { const insertRows = entryRows.map(function (entry) {
@@ -292,7 +292,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
}); });
await connection.query( await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?', 'INSERT INTO i_timetable_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows] [insertRows]
); );
} }
@@ -330,7 +330,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
await connection.beginTransaction(); await connection.beginTransaction();
await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [timetableGroup.id]); await connection.query('DELETE FROM i_timetable_groups WHERE id = ?', [timetableGroup.id]);
await connection.commit(); await connection.commit();
} catch (error) { } catch (error) {
await connection.rollback(); await connection.rollback();
+63 -6
View File
@@ -1,6 +1,7 @@
// Internal sync trigger routes for player-driven queue flushes. // Internal sync trigger routes for player-driven queue flushes.
const { verifyRequestAuth } = require('#src/request-auth'); const { verifyRequestAuth } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
function requireRequestAuth(req, res, next) { function requireRequestAuth(req, res, next) {
if (!verifyRequestAuth(req)) { if (!verifyRequestAuth(req)) {
@@ -11,21 +12,77 @@ function requireRequestAuth(req, res, next) {
} }
module.exports = function registerInternalSyncRoutes(app, deps) { module.exports = function registerInternalSyncRoutes(app, deps) {
const backgroundTaskQueue = deps && deps.backgroundTaskQueue;
const uploadSyncService = deps && deps.uploadSyncService; const uploadSyncService = deps && deps.uploadSyncService;
const mediaDir = String(deps && deps.mediaDir || '').trim(); const mediaDir = String(deps && deps.mediaDir || '').trim();
if (!uploadSyncService || typeof uploadSyncService.flushPendingPlayerUploadSyncs !== 'function' || typeof uploadSyncService.runMediaSyncTask !== 'function' || !mediaDir) { if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function' || !uploadSyncService || !mediaDir) {
throw new Error('registerInternalSyncRoutes requires the sync dependencies.'); throw new Error('registerInternalSyncRoutes requires the sync dependencies.');
} }
app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) { app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) {
try { try {
await uploadSyncService.runMediaSyncTask({ const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
mode: 'initial', ? _req.body
uploadDir: mediaDir : {};
const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim();
const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim();
const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim();
const task = await backgroundTaskQueue.enqueueTask({
key: 'player-media-sync',
title: 'Player media sync',
category: 'media-sync',
taskType: 'media-sync',
metadata: {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null
},
payload: {
mode: 'initial',
uploadDir: mediaDir,
playerIdentifier: playerIdentifier,
playerPublicBaseUrl: playerPublicBaseUrl,
playerInternalBaseUrl: playerInternalBaseUrl
}
}); });
await uploadSyncService.flushPendingPlayerUploadSyncs(); res.status(202).json({ ok: true, queued: true, task: task });
res.json({ ok: true }); } catch (error) {
next(error);
}
});
app.post('/api/internal/sync/player-font', requireRequestAuth, async function (_req, res, next) {
try {
const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body)
? _req.body
: {};
const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim();
const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim();
const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim();
const operations = collectFontLibrarySyncOperations(mediaDir);
const task = await backgroundTaskQueue.enqueueTask({
key: 'player-font-sync',
title: 'Player font sync',
category: 'fonts',
taskType: 'font-sync',
metadata: {
playerIdentifier: playerIdentifier || null,
playerPublicBaseUrl: playerPublicBaseUrl || null,
playerInternalBaseUrl: playerInternalBaseUrl || null,
playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null
},
payload: {
mode: 'initial',
uploadDir: mediaDir,
operations: operations,
playerIdentifier: playerIdentifier,
playerPublicBaseUrl: playerPublicBaseUrl,
playerInternalBaseUrl: playerInternalBaseUrl
}
});
res.status(202).json({ ok: true, queued: true, task: task });
} catch (error) { } catch (error) {
next(error); next(error);
} }
+3
View File
@@ -25,6 +25,7 @@ function registerRoutes(app, deps) {
registerSignageRoutes(app, deps); registerSignageRoutes(app, deps);
registerSettingsAndContentRoutes(app, deps); registerSettingsAndContentRoutes(app, deps);
registerInternalSyncRoutes(app, { registerInternalSyncRoutes(app, {
backgroundTaskQueue: deps.backgroundTaskQueue,
uploadSyncService: deps.uploadSyncService, uploadSyncService: deps.uploadSyncService,
mediaDir: deps.mediaDir mediaDir: deps.mediaDir
}); });
@@ -125,6 +126,8 @@ function registerSignageRoutes(app, deps) {
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage, getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
getScreenConnections: deps.playerActionService.getScreenConnections, getScreenConnections: deps.playerActionService.getScreenConnections,
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand, forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
forwardPlayerCommandToDevice: deps.playerActionService.forwardPlayerCommandToDevice,
requirePermission: deps.requirePermission requirePermission: deps.requirePermission
}); });
@@ -17,6 +17,14 @@ const DATE_FILTER_FIELDS = {
function formatIntervalLabel(intervalMs) { function formatIntervalLabel(intervalMs) {
const value = Math.max(1, Number(intervalMs) || 0); const value = Math.max(1, Number(intervalMs) || 0);
if (value % 86400000 === 0) {
const days = Math.max(1, value / 86400000);
return days === 1 ? 'Every day' : `Every ${days} days`;
}
if (value % 3600000 === 0) {
const hours = Math.max(1, value / 3600000);
return hours === 1 ? 'Every hour' : `Every ${hours} hours`;
}
if (value % 60000 === 0) { if (value % 60000 === 0) {
const minutes = Math.max(1, value / 60000); const minutes = Math.max(1, value / 60000);
return minutes === 1 ? 'Every minute' : `Every ${minutes} minutes`; return minutes === 1 ? 'Every minute' : `Every ${minutes} minutes`;
@@ -167,6 +175,31 @@ function buildTaskPlayerLabel(metadata) {
return ''; return '';
} }
function buildTaskDescription(task, metadata) {
const playerLabel = buildTaskPlayerLabel(metadata);
const taskKey = String(task && task.key || '').trim();
let displayKey = taskKey;
if (playerLabel && displayKey) {
const playerPrefix = `${playerLabel}:`;
const playerSuffix = `:${playerLabel}`;
if (displayKey.startsWith(playerPrefix)) {
displayKey = displayKey.slice(playerPrefix.length);
}
if (displayKey.endsWith(playerSuffix)) {
displayKey = displayKey.slice(0, -playerSuffix.length);
}
if (displayKey === playerLabel) {
displayKey = '';
}
}
return [playerLabel, displayKey].filter(Boolean).join(':');
}
function buildQueuePageViewModel(data, message, currentUser) { function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || []; const tasks = (data && data.tasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 }; const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
@@ -262,7 +295,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
const visibleTasksWithSources = visibleTasks.map(function (task) { const visibleTasksWithSources = visibleTasks.map(function (task) {
const playerLabel = buildTaskPlayerLabel(task.metadata); const playerLabel = buildTaskPlayerLabel(task.metadata);
const taskDescription = [playerLabel, String(task && task.key || '').trim()].filter(Boolean).join(':'); const taskDescription = buildTaskDescription(task, task.metadata);
return Object.assign({}, task, { return Object.assign({}, task, {
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter), sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
@@ -4,6 +4,11 @@ function normalizeSectionId(value) {
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'); return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
} }
function toSortIndex(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 999;
}
function buildPermissionSections(permissionGroups) { function buildPermissionSections(permissionGroups) {
const sections = []; const sections = [];
const sectionIndex = new Map(); const sectionIndex = new Map();
@@ -23,7 +28,8 @@ function buildPermissionSections(permissionGroups) {
sectionIndex.set(sectionKey, { sectionIndex.set(sectionKey, {
id: sectionKey, id: sectionKey,
title: sectionTitle, title: sectionTitle,
sectionOrder: Number(group && group.sectionOrder) || 999, sourceIndex: toSortIndex(group && group.sectionIndex),
sectionOrder: toSortIndex(group && group.sectionOrder),
groups: [] groups: []
}); });
sections.push(sectionIndex.get(sectionKey)); sections.push(sectionIndex.get(sectionKey));
@@ -47,8 +53,14 @@ function buildPermissionSections(permissionGroups) {
}); });
section.groups.sort(function (left, right) { section.groups.sort(function (left, right) {
const leftOrder = Number(left.resourceOrder) || 999; const leftIndex = toSortIndex(left.resourceIndex);
const rightOrder = Number(right.resourceOrder) || 999; const rightIndex = toSortIndex(right.resourceIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.resourceOrder);
const rightOrder = toSortIndex(right.resourceOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -65,8 +77,14 @@ function buildPermissionSections(permissionGroups) {
}); });
sections.sort(function (left, right) { sections.sort(function (left, right) {
const leftOrder = Number(left.sectionOrder) || 999; const leftIndex = toSortIndex(left.sourceIndex);
const rightOrder = Number(right.sectionOrder) || 999; const rightIndex = toSortIndex(right.sourceIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.sectionOrder);
const rightOrder = toSortIndex(right.sectionOrder);
if (leftOrder !== rightOrder) { if (leftOrder !== rightOrder) {
return leftOrder - rightOrder; return leftOrder - rightOrder;
} }
@@ -134,5 +152,6 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us
module.exports = { module.exports = {
buildRbacAddViewModel: buildRbacAddViewModel, buildRbacAddViewModel: buildRbacAddViewModel,
buildRbacEditViewModel: buildRbacEditViewModel buildRbacEditViewModel: buildRbacEditViewModel,
buildPermissionSections: buildPermissionSections
}; };
-8
View File
@@ -18,7 +18,6 @@ module.exports = function registerClientsRoutes(app, deps) {
client: function (client) { return String(client && (client.client_name || client.name || client.clientId) || '').trim(); }, client: function (client) { return String(client && (client.client_name || client.name || client.clientId) || '').trim(); },
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); }, screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); }, slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
ip: function (client) { return String(client && client.clientIp || '').trim(); },
viewport: function (client) { viewport: function (client) {
const viewport = client && client.viewport; const viewport = client && client.viewport;
if (!viewport || !viewport.width || !viewport.height) { if (!viewport || !viewport.width || !viewport.height) {
@@ -37,12 +36,6 @@ module.exports = function registerClientsRoutes(app, deps) {
? [normalizedSortKey] ? [normalizedSortKey]
: ['client']; : ['client'];
if (sortKeys[0] === 'client') {
sortKeys.push('ip');
} else if (sortKeys[0] === 'ip') {
sortKeys.push('client');
}
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) { return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
for (let index = 0; index < sortKeys.length; index += 1) { for (let index = 0; index < sortKeys.length; index += 1) {
const sortKeyName = sortKeys[index]; const sortKeyName = sortKeys[index];
@@ -71,7 +64,6 @@ module.exports = function registerClientsRoutes(app, deps) {
'deviceId', 'deviceId',
'slug', 'slug',
'screen_slug', 'screen_slug',
'ipAddress',
'status' 'status'
]); ]);
const filteredClients = (data.clients || []).filter(matchesSearch); const filteredClients = (data.clients || []).filter(matchesSearch);
@@ -90,6 +90,7 @@
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select"> <select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option> <option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option> <option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
<option value="hours" {{#if (eq apiSource.updateIntervalUnit 'hours')}}selected{{/if}}>Hours</option>
</select> </select>
</div> </div>
</div> </div>

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