Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afaeaafb1 | ||
|
|
ef9cdd2986 | ||
|
|
2176fb9042 | ||
|
|
6cb45d7839 | ||
|
|
eb1f33d82f | ||
|
|
07ec17ad91 | ||
|
|
b5fbf79af7 | ||
|
|
4ca4da473f | ||
|
|
f4de31269d | ||
|
|
235d2da6aa | ||
|
|
d24c9c4035 | ||
|
|
04ddec50ad | ||
|
|
47613e61a2 | ||
|
|
bd130a1070 | ||
|
|
d48f0e779f | ||
|
|
90ec3bb2df | ||
|
|
0ca20035cd | ||
|
|
c18f597068 | ||
|
|
0e89892c94 | ||
|
|
9ee938fd2f | ||
|
|
ad514102e7 | ||
|
|
cceb5d8fde | ||
|
|
b0e72e40d4 | ||
|
|
db9d718cd8 | ||
|
|
8d3b7d557b | ||
|
|
9f08ccfea1 | ||
|
|
370ec81c33 | ||
|
|
5e7df5e55c | ||
|
|
cfca5bfe3b | ||
|
|
5eff70755b | ||
|
|
0666d5d07c | ||
|
|
8b479283e1 | ||
|
|
13e13d0d68 | ||
|
|
e051958bea | ||
|
|
7973ee0ea4 | ||
|
|
6416dbfd99 | ||
|
|
6fb413cb6d | ||
|
|
8393923c5a | ||
|
|
2ea8d389fa | ||
|
|
480ccdbe9c | ||
|
|
8020a12408 | ||
|
|
b7f1d800ef | ||
|
|
b0649d838e | ||
|
|
9f3fcbdaf2 | ||
|
|
31b10e6fd1 | ||
|
|
4e0e87c86c | ||
|
|
9070ded66d |
+4
-6
@@ -1,6 +1,4 @@
|
|||||||
node_modules
|
*
|
||||||
npm-debug.log
|
!package.json
|
||||||
uploads
|
!src/
|
||||||
.git
|
!src/**
|
||||||
.gitignore
|
|
||||||
*.tmp
|
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ MYSQL_ROOT_PASSWORD=root_password
|
|||||||
|
|
||||||
PLAYER_INTERNAL_BASE_URL=http://player:3001
|
PLAYER_INTERNAL_BASE_URL=http://player:3001
|
||||||
PLAYER_PUBLIC_BASE_URL=http://localhost:3001
|
PLAYER_PUBLIC_BASE_URL=http://localhost:3001
|
||||||
UPLOAD_DIR=/app/uploads
|
PULSE_SIGNAGE_SHARED_SECRET=
|
||||||
|
|
||||||
SESSION_MAX_AGE_DAYS=14
|
SESSION_MAX_AGE_DAYS=14
|
||||||
DASHBOARD_REFRESH_INTERVAL_MS=2000
|
DASHBOARD_REFRESH_INTERVAL_MS=2000
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Bug report
|
||||||
|
about: Report a problem with Pulse Signage
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please provide enough detail to reproduce the problem.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: List the actions that trigger the bug.
|
||||||
|
placeholder: |
|
||||||
|
1. Open the dashboard
|
||||||
|
2. Change the playlist on a screen
|
||||||
|
3. Save and wait for playback to refresh
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: What you expected to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual behavior
|
||||||
|
description: What actually happened.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: Include version, browser, player setup, and any relevant logs.
|
||||||
|
placeholder: |
|
||||||
|
Version:
|
||||||
|
Browser:
|
||||||
|
Player version:
|
||||||
|
Logs:
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Documentation
|
||||||
|
url: https://git.lzstealth.com/LZStealth/pulse-signage
|
||||||
|
about: Check the project repository and docs before opening an issue.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Feature request
|
||||||
|
about: Suggest an improvement or new capability
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Share the problem you're trying to solve and the outcome you'd like.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem statement
|
||||||
|
description: What limitation or workflow gap are you trying to address?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: Describe the change you would like to see.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: Any workarounds or other approaches you've considered.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Mockups, screenshots, examples, or related links.
|
||||||
@@ -32,6 +32,8 @@ jobs:
|
|||||||
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}}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
uploads/
|
media/
|
||||||
docker-compose.dev.yml
|
docker-compose.dev.yml
|
||||||
|
.vscode/
|
||||||
.env
|
.env
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- No unreleased changes recorded yet.
|
||||||
|
|
||||||
|
## 1.5.12 - 2026-07-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Shared web UI helpers are now loaded once in the admin shell and reused across the dashboard, admin, and template editor.
|
||||||
|
- Template lists now show slide counts, and the template editor tracks which regions are already in use.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Template editing now blocks removal of regions that are still referenced by slides.
|
||||||
|
|
||||||
|
## 1.5.11 - 2026-07-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Video regions are now supported end to end, including player rendering, admin editing, playlist scheduling, and player sync.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The slide, template, and playlist flows were updated so video content can be created, configured, and scheduled alongside existing region types.
|
||||||
|
- Player rendering and related media handling now recognize the new video region implementation.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Region and upload handling were adjusted so video media syncs cleanly through the player and web admin paths.
|
||||||
|
|
||||||
|
## 1.5.10 - 2026-07-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Scheduled background tasks now have their own settings page, separate permission gate, and manual run action alongside the main queue view.
|
||||||
|
- The slide editor now includes a refreshed preview/sidebar layout and a cropper-based image upload flow.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Background task and admin list views now use richer pagination and filtering so queue state, task sources, and recurring jobs stay easier to navigate.
|
||||||
|
- The admin shell and shared styling were refreshed to support the updated settings pages, slide editor layout, and task table presentation.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Upload sync and slide thumbnail refresh handling were tightened so slide changes queue follow-up work more reliably.
|
||||||
|
|
||||||
|
## 1.5.9 - 2026-07-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- User creation now keeps the role checkboxes inside the add-user form so selected roles are submitted correctly.
|
||||||
|
|
||||||
|
## 1.5.8 - 2026-07-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Slide thumbnail refresh now uses the player-rendered preview again and signs the preview GET request in the same way the player verifies it.
|
||||||
|
- Background tasks settings page and client script were repaired after a syntax break so the web app can boot cleanly.
|
||||||
|
|
||||||
|
## 1.5.7 - 2026-07-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images.
|
||||||
|
|
||||||
|
## 1.5.6 - 2026-07-25
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Playlist slide picker now uses a button to reveal already added slides, while still defaulting to showing only available slides.
|
||||||
|
- Already-added slides in the picker are now labeled and visually muted to make them easier to spot.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Thumbnails in slide and playlist tables now follow the slide canvas ratio and cap at 5.5rem tall.
|
||||||
|
- Thumbnail-only table columns are no longer sortable.
|
||||||
|
|
||||||
|
## 1.5.5 - 2026-07-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a migration gap that could leave `slides.thumbnail_path` missing on upgraded databases.
|
||||||
|
|
||||||
|
## 1.5.4 - 2026-07-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Cropper.js has been added to aid with image uploading.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded Multer to 2.x to remove the deprecated 1.x dependency warning.
|
||||||
|
- Tightened a few admin UI details, including slide editor and RBAC list spacing/typography adjustments.
|
||||||
|
|
||||||
|
## 1.5.3 - 2026-07-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A modal-based slide picker for playlist editing with search, multi-select, and slide thumbnails.
|
||||||
|
- Reusable modal and table-pagination helpers for the admin UI.
|
||||||
|
- Slide thumbnail previews in playlist schedules, with image fallbacks when a thumbnail is missing.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- RTMP playback now probes session readiness more aggressively and retries startup before surfacing failures.
|
||||||
|
- Playlist editing now includes richer scheduling controls, live slide counts, and support for skipping unavailable RTMP slides.
|
||||||
|
- Admin lists and background task pages now preserve filter and pagination state during AJAX navigation.
|
||||||
|
- Shared admin shell styling and helpers were refreshed to support the newer list, modal, and pagination UI.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Player upload sync and route handling now degrade more gracefully when backend services are unavailable.
|
||||||
|
- RTMP and playlist playback now recover more cleanly when a stream fails to initialize or respond in time.
|
||||||
|
|
||||||
|
## 1.5.2 - 2026-07-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed player media upload sync so signed upload requests are verified after the request body has been parsed.
|
||||||
|
|
||||||
|
## 1.5.1 - 2026-07-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the account page so it can save changes again.
|
||||||
|
|
||||||
|
## 1.5.0 - 2026-07-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- RTMP slide regions with ffmpeg-to-HLS playback support in the player.
|
||||||
|
- Service-worker-backed player caching and offline recovery paths for playlists and playback state.
|
||||||
|
- Broader slide, template, playlist, RSS, and API source support, including richer region handling and background refresh processing.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reworked the web admin app around a newer modular route and view layout, with shared helpers moving into `src/web/lib`, `src/web/pages`, and `src/web/views`.
|
||||||
|
- Refreshed the admin shell styling and theme bootstrapping, including local AdminLTE assets, vendored Bootstrap Icons, and earlier theme initialization on first paint.
|
||||||
|
- Rebuilt RBAC around CRUD permissions and updated the admin screens for roles, users, and action gating.
|
||||||
|
- Refactored the player onboarding flow, client-name handling, and runtime storage so screen identities persist more reliably across refreshes.
|
||||||
|
- Tightened duplicate-name checks and other admin-side validation paths.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved player fallback behavior when the database is unavailable.
|
||||||
|
- Cleaned up player runtime and routing paths so screen lookups and live updates degrade more gracefully.
|
||||||
|
|
||||||
|
### Docs and Ops
|
||||||
|
|
||||||
|
- Updated Docker, environment, and documentation files to match the current release structure and player/web APIs.
|
||||||
|
|
||||||
|
## 1.4.6 - 2026-07-22
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refined RBAC enforcement across the admin UI and route layer, especially for list actions and edit screens.
|
||||||
|
- Updated the dashboard and admin styling to match the revised permission model and page layout.
|
||||||
|
- Adjusted database helpers and admin data views to support the newer RBAC and list rendering behavior.
|
||||||
|
|
||||||
|
## 1.4.5 - 2026-07-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed the admin screen commands route to client commands and aligned the dashboard copy with client-focused terminology.
|
||||||
|
- Updated the RBAC and database helpers to support the revised client command flow.
|
||||||
|
- Refined the client list view and dashboard interactions around the new client command behavior.
|
||||||
|
|
||||||
|
## 1.4.4 - 2026-07-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Rendered 404 pages for unmatched routes.
|
||||||
|
|
||||||
|
## 1.4.3 - 2026-07-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed RBAC client action visibility.
|
||||||
|
|
||||||
|
## 1.4.2 - 2026-07-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed duplicate permissions cleanup.
|
||||||
|
|
||||||
|
## 1.4.1 - 2026-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- RBAC roles system support.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Version bump to 1.4.1.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Schema migration audit columns.
|
||||||
|
|
||||||
|
## 1.3.4 - 2026-07-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed duplicate onboarding client names.
|
||||||
|
|
||||||
|
## 1.3.3 - 2026-07-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added the first round of modular admin-page work, including a shared admin route layer and refreshed dashboard/list rendering.
|
||||||
|
- Updated the admin shell styling and layout handling for the newer page structure.
|
||||||
|
- Adjusted package metadata and lockfile state to match the release.
|
||||||
|
|
||||||
|
## 1.3.2 - 2026-07-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Version bump to 1.3.2.
|
||||||
|
|
||||||
|
## 1.3.0 - 2026-07-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Split shared helpers, bootstrap logic, route groups, and upload-sync behavior out of `web.js`.
|
||||||
|
- Split websocket and runtime handling into `runtime.js`.
|
||||||
|
- Split playlist assembly and revision hashing into `playlist.js`.
|
||||||
|
- Split onboarding and player HTTP routes into dedicated modules.
|
||||||
|
- Split render utilities and template loading into `render-helpers.js`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Kept `web.js` focused on wiring and server startup.
|
||||||
|
- Kept `player.js` mostly as startup and orchestration.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed screen playlist reassignment so changing a screen's playlist now triggers a refresh.
|
||||||
|
- Fixed single-slide playlist refresh behavior so updates do not get stuck behind the current slide.
|
||||||
|
|
||||||
|
## 1.1.5 - 2026-07-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed text pasting in the admin UI.
|
||||||
|
|
||||||
|
## 1.1.4 - 2026-07-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the admin table and playlist reorder UI.
|
||||||
|
|
||||||
|
## 1.1.3 - 2026-07-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Playlist drag sorting.
|
||||||
|
- Local SortableJS.
|
||||||
|
- Playlist UI updates.
|
||||||
|
|
||||||
|
## 1.1.2 - 2026-07-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated the admin layout and frame shell styling.
|
||||||
|
- Refined `src/web/view.js` so view rendering handles the revised shell structure.
|
||||||
|
- Adjusted package metadata for the release.
|
||||||
|
|
||||||
|
## 1.1.1 - 2026-07-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refreshed favicon and layout assets.
|
||||||
|
|
||||||
|
## 1.1.0 - 2026-07-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored the web server and release workflow.
|
||||||
|
|
||||||
|
## 1.0.2 - 2026-07-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Handled player redirects on screen slug change.
|
||||||
|
|
||||||
|
## 1.0.1 - 2026-07-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Made screen slugs editable.
|
||||||
|
|
||||||
|
## 1.0.0 - 2026-07-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Initial release.
|
||||||
+5
-3
@@ -2,13 +2,15 @@ FROM node:24-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg chromium nss freetype harfbuzz ttf-freefont
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|
||||||
RUN mkdir -p /app/uploads
|
RUN mkdir -p /app/media
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["npm", "run", "start:webui"]
|
CMD ["npm", "run", "start:web"]
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ It runs as two connected services:
|
|||||||
- Create and organize playlists and slides
|
- Create and organize playlists and slides
|
||||||
- Design reusable templates and canvas sizes
|
- Design reusable templates and canvas sizes
|
||||||
- Register screens and assign playlists to them
|
- Register screens and assign playlists to them
|
||||||
|
- Manage roles and permissions for the admin web UI
|
||||||
- Upload images and other media for use in slides and templates
|
- Upload images and other media for use in slides and templates
|
||||||
- View live screen connections and send player commands
|
- View live screen connections and send player commands
|
||||||
|
|
||||||
|
Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands.
|
Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands.
|
||||||
@@ -34,6 +37,8 @@ When the app starts for the first time, it creates the database tables it needs
|
|||||||
- Username: `admin`
|
- Username: `admin`
|
||||||
- Password: `admin`
|
- Password: `admin`
|
||||||
|
|
||||||
|
The first admin account is placed into the built-in `Administrators` role, which has full web-admin access through the CRUD permissions.
|
||||||
|
|
||||||
You can change the initial admin credentials with these optional environment variables:
|
You can change the initial admin credentials with these optional environment variables:
|
||||||
|
|
||||||
- `DEFAULT_ADMIN_USERNAME`
|
- `DEFAULT_ADMIN_USERNAME`
|
||||||
@@ -57,7 +62,7 @@ The app reads its settings from environment variables.
|
|||||||
- `WEB_PORT` - admin app port, default `3000`
|
- `WEB_PORT` - admin app port, default `3000`
|
||||||
- `PLAYER_INTERNAL_BASE_URL` - player address used by the server, default `http://player:3001`
|
- `PLAYER_INTERNAL_BASE_URL` - player address used by the server, default `http://player:3001`
|
||||||
- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links, default `http://localhost:3001`
|
- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links, default `http://localhost:3001`
|
||||||
- `UPLOAD_DIR` - storage location for uploaded files, default `./uploads`
|
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; page tokens auto-renew while the page stays active and request signatures must be fresh; leave unset to keep the auth checks disabled for compatibility
|
||||||
|
|
||||||
### Player App
|
### Player App
|
||||||
|
|
||||||
@@ -67,16 +72,21 @@ The app reads its settings from environment variables.
|
|||||||
|
|
||||||
The repository includes a `docker-compose.yml` file that starts three services:
|
The repository includes a `docker-compose.yml` file that starts three services:
|
||||||
|
|
||||||
- `webui` - the admin app on port `3000`
|
- `web` - the admin app on port `3000`
|
||||||
- `player` - the signage player on port `3001`
|
- `player` - the signage player on port `3001`
|
||||||
- `mysql` - the database on port `3306`
|
- `mysql` - the database on port `3306`
|
||||||
|
|
||||||
By default, `webui` and `player` use the published image from `git.lzstealth.com/LZStealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/LZStealth/pulse-signage:v1.0.0`.
|
By default, `web` and `player` use the published image from `git.lzstealth.com/lzstealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/lzstealth/pulse-signage:v1.0.0`.
|
||||||
|
|
||||||
The Compose file also defines a shared `uploads` volume for media and a `mysql_data` volume for database persistence.
|
The Compose file also defines a shared `media` volume for stored assets and a `mysql_data` volume for database persistence.
|
||||||
|
|
||||||
|
In Docker, media storage is controlled by the `media` volume mount at `/app/media`.
|
||||||
|
Outside Docker, the web app and player do not have to use the same upload location or even the same server, as long as each service can access its own configured media path.
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`.
|
- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`.
|
||||||
- When running in Docker, that value should point to the Docker service name, not `localhost`.
|
- When running in Docker, that value should point to the Docker service name, not `localhost`.
|
||||||
- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes.
|
- If `PULSE_SIGNAGE_SHARED_SECRET` is set, the web and player containers must use the same value, and any reverse proxy in front of the player must forward `/api/media/...` without rewriting the path.
|
||||||
|
- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes.
|
||||||
|
- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock.
|
||||||
+9
-8
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
webui:
|
web:
|
||||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||||
container_name: signage-webui
|
container_name: signage-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:3001}
|
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:3001}
|
||||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
|
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
|
||||||
UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads}
|
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||||
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
||||||
DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000}
|
DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000}
|
||||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||||
@@ -23,8 +23,8 @@ services:
|
|||||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
|
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
|
||||||
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
|
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/app/uploads
|
- media:/app/media
|
||||||
command: ["npm", "run", "start:webui"]
|
command: ["npm", "run", "start:web"]
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -40,14 +40,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: ${NODE_ENV:-production}
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
PLAYER_PORT: ${PLAYER_PORT:-3001}
|
PLAYER_PORT: ${PLAYER_PORT:-3001}
|
||||||
|
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
|
||||||
|
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}
|
||||||
DB_NAME: ${DB_NAME:-signage}
|
DB_NAME: ${DB_NAME:-signage}
|
||||||
DB_USER: ${DB_USER:-signage_user}
|
DB_USER: ${DB_USER:-signage_user}
|
||||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||||
UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads}
|
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/app/uploads
|
- media:/app/media
|
||||||
command: ["npm", "run", "start:player"]
|
command: ["npm", "run", "start:player"]
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
@@ -81,7 +82,7 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
uploads:
|
media:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
pulse_signage:
|
pulse_signage:
|
||||||
|
|||||||
+187
-3
@@ -4,25 +4,134 @@
|
|||||||
|
|
||||||
Player service base URL: `http://localhost:3001`
|
Player service base URL: `http://localhost:3001`
|
||||||
|
|
||||||
This document uses OpenAPI-style sections, but stays in plain markdown.
|
This document covers the player HTTP surface only. The admin dashboard exposes its own routes for screen commands and onboarding management.
|
||||||
|
|
||||||
|
Access note: most player endpoints are unauthenticated because they are meant to run inside a trusted deployment network. Anything that mutates state or writes files should be treated as internal-only unless you add your own auth layer in front of it.
|
||||||
|
|
||||||
|
When `PULSE_SIGNAGE_SHARED_SECRET` is set, the player pages sign same-origin API fetches with `x-pulse-page-auth`, and the web app signs server-to-player requests with `x-pulse-request-timestamp` plus `x-pulse-request-signature`. Page tokens auto-renew before expiry while the page stays active, and signed server requests are only accepted when their timestamp is fresh. If the secret is unset, those checks stay disabled for compatibility.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### `GET /`
|
### `GET /`
|
||||||
Returns a plain-text landing response for the player service.
|
Returns the player onboarding landing page.
|
||||||
|
Access: public within the trusted player deployment.
|
||||||
|
|
||||||
|
### `GET /onboard`
|
||||||
|
Returns the onboarding form page.
|
||||||
|
Access: public within the trusted player deployment.
|
||||||
|
|
||||||
### `GET /screen/{slug}`
|
### `GET /screen/{slug}`
|
||||||
Returns the rendered player page for a screen.
|
Returns the rendered player page for a screen.
|
||||||
|
Access: public within the trusted player deployment.
|
||||||
|
|
||||||
|
### `GET /api/onboarding/status`
|
||||||
|
Returns the persisted onboarding status for a device.
|
||||||
|
Access: public within the trusted player deployment.
|
||||||
|
|
||||||
|
Query fields:
|
||||||
|
|
||||||
|
- `deviceId` required
|
||||||
|
|
||||||
|
### `GET /api/onboarding/screens`
|
||||||
|
Returns the list of screens available for onboarding.
|
||||||
|
Access: public within the trusted player deployment.
|
||||||
|
|
||||||
|
### `GET /api/onboarding/qr`
|
||||||
|
Returns an SVG QR code that points to the onboarding form.
|
||||||
|
Access: public within the trusted player deployment.
|
||||||
|
|
||||||
|
Query fields:
|
||||||
|
|
||||||
|
- `deviceId` required
|
||||||
|
|
||||||
|
### `POST /api/auth/page`
|
||||||
|
Renews the current page-auth token before it expires.
|
||||||
|
Access: internal to the player page and onboarding page. The request must include a valid `x-pulse-page-auth` header.
|
||||||
|
|
||||||
|
Response fields:
|
||||||
|
|
||||||
|
- `token`
|
||||||
|
- `issuedAt`
|
||||||
|
- `expiresAt`
|
||||||
|
|
||||||
|
### `POST /api/onboarding`
|
||||||
|
Binds a device to a screen and client name.
|
||||||
|
Access: internal-only. Protect this endpoint if the player service is reachable outside your trusted network.
|
||||||
|
|
||||||
|
Accepted request fields:
|
||||||
|
|
||||||
|
- `deviceId` required
|
||||||
|
- `clientName` required
|
||||||
|
- `screenSlug` required
|
||||||
|
|
||||||
|
Response fields:
|
||||||
|
|
||||||
|
- `deviceId`
|
||||||
|
- `clientName`
|
||||||
|
- `screenId`
|
||||||
|
- `screenSlug`
|
||||||
|
- `screenName`
|
||||||
|
- `playerUrl`
|
||||||
|
- `queued`
|
||||||
|
|
||||||
|
### `GET /api/media/config`
|
||||||
|
Returns the upload directory configured for the player service.
|
||||||
|
Access: internal-only.
|
||||||
|
|
||||||
|
### `PUT /api/media/{filename}`
|
||||||
|
Writes an uploaded file into the player upload directory.
|
||||||
|
Access: internal-only and write-protected behind your deployment boundary.
|
||||||
|
|
||||||
|
### `DELETE /api/media/{filename}`
|
||||||
|
Deletes a file from the player upload directory.
|
||||||
|
Access: internal-only and write-protected behind your deployment boundary.
|
||||||
|
|
||||||
|
### `GET /api/rtmp/session`
|
||||||
|
Creates or reuses an RTMP-to-HLS session for a source URL.
|
||||||
|
Access: internal-only.
|
||||||
|
|
||||||
|
Query fields:
|
||||||
|
|
||||||
|
- `source` required
|
||||||
|
- `disableAudio` optional
|
||||||
|
|
||||||
|
### `GET /api/rtmp/streams/{key}/index.m3u8`
|
||||||
|
Returns the RTMP session HLS manifest.
|
||||||
|
Access: internal-only.
|
||||||
|
|
||||||
|
### `GET /api/rtmp/streams/{key}/{fileName}`
|
||||||
|
Returns an RTMP HLS segment or related stream file.
|
||||||
|
Access: internal-only.
|
||||||
|
|
||||||
### `GET /api/screens/{slug}/playlist`
|
### `GET /api/screens/{slug}/playlist`
|
||||||
Returns the current playlist payload for a screen.
|
Returns the current playlist payload for a screen.
|
||||||
|
Access: public within the trusted player deployment.
|
||||||
|
|
||||||
|
Response fields:
|
||||||
|
|
||||||
|
- `screen`
|
||||||
|
- `playlist`
|
||||||
|
- `slides`
|
||||||
|
- `rssFeeds`
|
||||||
|
- `apiSources`
|
||||||
|
- `revision`
|
||||||
|
|
||||||
### `GET /api/screens/{slug}/connections`
|
### `GET /api/screens/{slug}/connections`
|
||||||
### `GET /api/screens/{slug}/clients`
|
### `GET /api/screens/{slug}/clients`
|
||||||
Returns the live player connection snapshot for a screen.
|
Returns the live player connection snapshot for a screen.
|
||||||
|
Access: public within the trusted player deployment, but it exposes live connection state.
|
||||||
|
|
||||||
|
Response fields:
|
||||||
|
|
||||||
|
- `screen`
|
||||||
|
- `screenSlug`
|
||||||
|
- `count`
|
||||||
|
- `connections`
|
||||||
|
- `degraded`
|
||||||
|
|
||||||
### `POST /api/screens/{slug}/commands`
|
### `POST /api/screens/{slug}/commands`
|
||||||
Sends a command to the player connections for a screen.
|
Sends a command to the player connections for a screen.
|
||||||
|
Access: internal-only. The admin dashboard should remain the protected control surface for commands.
|
||||||
|
|
||||||
Accepted request fields:
|
Accepted request fields:
|
||||||
|
|
||||||
@@ -31,21 +140,87 @@ Accepted request fields:
|
|||||||
- `clientId` optional
|
- `clientId` optional
|
||||||
- `blackout` optional when `command=blackout`
|
- `blackout` optional when `command=blackout`
|
||||||
|
|
||||||
|
Command-specific fields:
|
||||||
|
|
||||||
|
- `url` for `redirect`
|
||||||
|
- `clientName` for `setclientname`
|
||||||
|
- `deviceId` for `setclientname`
|
||||||
|
|
||||||
Supported commands:
|
Supported commands:
|
||||||
|
|
||||||
- `refresh`
|
- `refresh`
|
||||||
- `reload`
|
- `reload`
|
||||||
|
- `redirect`
|
||||||
- `pause`
|
- `pause`
|
||||||
- `blackout`
|
- `blackout`
|
||||||
- `previous`
|
- `previous`
|
||||||
- `next`
|
- `next`
|
||||||
- `left`
|
- `left`
|
||||||
- `right`
|
- `right`
|
||||||
|
- `setclientname`
|
||||||
|
|
||||||
If `connectionId` or `clientId` is provided, the command targets a single player connection. Otherwise it is broadcast to all connections for that screen.
|
If `connectionId` or `clientId` is provided, the command targets a single player connection. Otherwise it is broadcast to all connections for that screen.
|
||||||
|
|
||||||
|
Response fields:
|
||||||
|
|
||||||
|
- `screen`
|
||||||
|
- `screenSlug`
|
||||||
|
- `command`
|
||||||
|
- `connectionId`
|
||||||
|
- `sent`
|
||||||
|
- `degraded`
|
||||||
|
|
||||||
## Response Shapes
|
## Response Shapes
|
||||||
|
|
||||||
|
### Onboarding Status Response
|
||||||
|
|
||||||
|
`GET /api/onboarding/status` returns an object with:
|
||||||
|
|
||||||
|
- `deviceId`
|
||||||
|
- `onboarded`
|
||||||
|
- `clientName`
|
||||||
|
- `screenId`
|
||||||
|
- `screenSlug`
|
||||||
|
- `screenName`
|
||||||
|
- `playerUrl`
|
||||||
|
|
||||||
|
### Onboarding Screens Response
|
||||||
|
|
||||||
|
`GET /api/onboarding/screens` returns an object with:
|
||||||
|
|
||||||
|
- `screens`
|
||||||
|
|
||||||
|
### QR Response
|
||||||
|
|
||||||
|
`GET /api/onboarding/qr` returns SVG markup.
|
||||||
|
|
||||||
|
### Upload Config Response
|
||||||
|
|
||||||
|
`GET /api/media/config` returns an object with:
|
||||||
|
|
||||||
|
- `uploadDir`
|
||||||
|
|
||||||
|
### RTMP Session Response
|
||||||
|
|
||||||
|
`GET /api/rtmp/session` returns an object with:
|
||||||
|
|
||||||
|
- `key`
|
||||||
|
- `playlistUrl`
|
||||||
|
- `disableAudio`
|
||||||
|
- `ready`
|
||||||
|
|
||||||
|
### Onboarding Write Response
|
||||||
|
|
||||||
|
`POST /api/onboarding` returns an object with:
|
||||||
|
|
||||||
|
- `deviceId`
|
||||||
|
- `clientName`
|
||||||
|
- `screenId`
|
||||||
|
- `screenSlug`
|
||||||
|
- `screenName`
|
||||||
|
- `playerUrl`
|
||||||
|
- `queued`
|
||||||
|
|
||||||
### Playlist Response
|
### Playlist Response
|
||||||
|
|
||||||
`GET /api/screens/{slug}/playlist` returns an object with:
|
`GET /api/screens/{slug}/playlist` returns an object with:
|
||||||
@@ -53,6 +228,9 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
|||||||
- `screen`
|
- `screen`
|
||||||
- `playlist`
|
- `playlist`
|
||||||
- `slides`
|
- `slides`
|
||||||
|
- `rssFeeds`
|
||||||
|
- `apiSources`
|
||||||
|
- `revision`
|
||||||
|
|
||||||
### Connections Response
|
### Connections Response
|
||||||
|
|
||||||
@@ -62,6 +240,7 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
|||||||
- `screenSlug`
|
- `screenSlug`
|
||||||
- `count`
|
- `count`
|
||||||
- `connections`
|
- `connections`
|
||||||
|
- `degraded`
|
||||||
|
|
||||||
### Command Response
|
### Command Response
|
||||||
|
|
||||||
@@ -72,6 +251,7 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
|||||||
- `command`
|
- `command`
|
||||||
- `connectionId`
|
- `connectionId`
|
||||||
- `sent`
|
- `sent`
|
||||||
|
- `degraded`
|
||||||
|
|
||||||
## Data Models
|
## Data Models
|
||||||
|
|
||||||
@@ -113,11 +293,15 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
|||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- `clientId`
|
- `clientId`
|
||||||
|
- `clientName`
|
||||||
|
- `deviceId`
|
||||||
- `label`
|
- `label`
|
||||||
- `userAgent`
|
- `userAgent`
|
||||||
- `viewport`
|
- `viewport`
|
||||||
- `page`
|
- `page`
|
||||||
- `currentSlide`
|
- `currentSlide`
|
||||||
|
- `currentSlideId`
|
||||||
|
- `currentSlideTitle`
|
||||||
- `paused`
|
- `paused`
|
||||||
- `blackout`
|
- `blackout`
|
||||||
- `clientIp`
|
- `clientIp`
|
||||||
@@ -128,4 +312,4 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The player API is the only surface documented here.
|
- The player API is the only surface documented here.
|
||||||
- Web UI/admin routes are intentionally omitted.
|
- Web UI/admin routes are intentionally omitted, except where the player service itself exposes onboarding and upload endpoints.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Player service websocket base URL: `ws://localhost:3001`
|
|||||||
|
|
||||||
This document uses OpenAPI-style sections, but stays in plain markdown.
|
This document uses OpenAPI-style sections, but stays in plain markdown.
|
||||||
|
|
||||||
|
The `PULSE_SIGNAGE_SHARED_SECRET` setting does not change the websocket message format here. It is used to sign the player control socket URL and the server-side snapshot subscription; the message payloads themselves remain the same.
|
||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
|
|
||||||
### `GET /ws/screens/{slug}`
|
### `GET /ws/screens/{slug}`
|
||||||
@@ -13,11 +15,15 @@ Player control channel.
|
|||||||
|
|
||||||
This is the bidirectional socket used by the player page. The player sends status messages to the server, and the server sends commands back to the player.
|
This is the bidirectional socket used by the player page. The player sends status messages to the server, and the server sends commands back to the player.
|
||||||
|
|
||||||
|
Access: the player page must include a valid `auth` query parameter signed with the shared secret. The browser page refreshes this token automatically while it is active.
|
||||||
|
|
||||||
### `GET /ws/screens/{slug}/events`
|
### `GET /ws/screens/{slug}/events`
|
||||||
Player snapshot channel.
|
Player snapshot channel.
|
||||||
|
|
||||||
This is the server-to-dashboard snapshot stream for live player connection state.
|
This is the server-to-dashboard snapshot stream for live player connection state.
|
||||||
|
|
||||||
|
Access: internal-only. The web backend subscribes with signed request headers; browsers should not connect directly.
|
||||||
|
|
||||||
## Player Control Channel
|
## Player Control Channel
|
||||||
|
|
||||||
### Messages from player to server
|
### Messages from player to server
|
||||||
@@ -35,6 +41,8 @@ Example payload:
|
|||||||
{
|
{
|
||||||
"type": "hello",
|
"type": "hello",
|
||||||
"clientId": "client-id",
|
"clientId": "client-id",
|
||||||
|
"clientName": "friendly label",
|
||||||
|
"deviceId": "device-id",
|
||||||
"userAgent": "browser ua",
|
"userAgent": "browser ua",
|
||||||
"page": "http://.../screen/demo",
|
"page": "http://.../screen/demo",
|
||||||
"viewport": { "width": 1920, "height": 1080 },
|
"viewport": { "width": 1920, "height": 1080 },
|
||||||
@@ -52,6 +60,8 @@ Example payload:
|
|||||||
{
|
{
|
||||||
"type": "state",
|
"type": "state",
|
||||||
"clientId": "client-id",
|
"clientId": "client-id",
|
||||||
|
"clientName": "friendly label",
|
||||||
|
"deviceId": "device-id",
|
||||||
"userAgent": "browser ua",
|
"userAgent": "browser ua",
|
||||||
"page": "http://.../screen/demo",
|
"page": "http://.../screen/demo",
|
||||||
"viewport": { "width": 1920, "height": 1080 },
|
"viewport": { "width": 1920, "height": 1080 },
|
||||||
@@ -66,6 +76,8 @@ Example payload:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The server recognizes `clientId`, `clientName`, `deviceId`, `userAgent`, `page`, `viewport`, `paused`, `blackout`, and `currentSlide` from player messages. When `currentSlide` is present, the player includes the active slide id, title, kind, and playlist signature.
|
||||||
|
|
||||||
### Messages from server to player
|
### Messages from server to player
|
||||||
|
|
||||||
The server sends command messages with:
|
The server sends command messages with:
|
||||||
@@ -75,17 +87,21 @@ The server sends command messages with:
|
|||||||
- optional `sentAt`
|
- optional `sentAt`
|
||||||
- optional `targetConnectionId`
|
- optional `targetConnectionId`
|
||||||
- optional `blackout` for blackout commands
|
- optional `blackout` for blackout commands
|
||||||
|
- optional `url` for redirect commands
|
||||||
|
- optional `clientName` and `deviceId` for client-name updates
|
||||||
|
|
||||||
Supported commands:
|
Supported commands:
|
||||||
|
|
||||||
- `refresh`
|
- `refresh`
|
||||||
- `reload`
|
- `reload`
|
||||||
|
- `redirect`
|
||||||
- `pause`
|
- `pause`
|
||||||
- `blackout`
|
- `blackout`
|
||||||
- `previous`
|
- `previous`
|
||||||
- `next`
|
- `next`
|
||||||
- `left`
|
- `left`
|
||||||
- `right`
|
- `right`
|
||||||
|
- `setclientname`
|
||||||
|
|
||||||
#### `refresh`
|
#### `refresh`
|
||||||
Asks the player to refetch the current playlist.
|
Asks the player to refetch the current playlist.
|
||||||
@@ -111,12 +127,19 @@ Example payload:
|
|||||||
|
|
||||||
Use `false` to restore and `true` to blackout.
|
Use `false` to restore and `true` to blackout.
|
||||||
|
|
||||||
|
#### `redirect`
|
||||||
|
Requests the player page to navigate to a new location. The destination is supplied as `url` by the caller that forwarded the command.
|
||||||
|
|
||||||
#### `previous` / `left`
|
#### `previous` / `left`
|
||||||
Moves to the previous slide.
|
Moves to the previous slide.
|
||||||
|
|
||||||
#### `next` / `right`
|
#### `next` / `right`
|
||||||
Moves to the next slide.
|
Moves to the next slide.
|
||||||
|
|
||||||
|
#### `setclientname`
|
||||||
|
Updates the client name associated with the player session and onboarding record.
|
||||||
|
The command payload should include `clientName` and may include `deviceId` when the caller is updating a specific onboarding binding.
|
||||||
|
|
||||||
## Snapshot Channel
|
## Snapshot Channel
|
||||||
|
|
||||||
### Messages from server to client
|
### Messages from server to client
|
||||||
@@ -142,11 +165,15 @@ The snapshot channel is server-to-client only.
|
|||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- `clientId`
|
- `clientId`
|
||||||
|
- `clientName`
|
||||||
|
- `deviceId`
|
||||||
- `label`
|
- `label`
|
||||||
- `userAgent`
|
- `userAgent`
|
||||||
- `viewport`
|
- `viewport`
|
||||||
- `page`
|
- `page`
|
||||||
- `currentSlide`
|
- `currentSlide`
|
||||||
|
- `currentSlideId`
|
||||||
|
- `currentSlideTitle`
|
||||||
- `paused`
|
- `paused`
|
||||||
- `blackout`
|
- `blackout`
|
||||||
- `clientIp`
|
- `clientIp`
|
||||||
|
|||||||
Generated
+1946
-88
File diff suppressed because it is too large
Load Diff
+19
-12
@@ -1,26 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.0.0",
|
"version": "1.5.12",
|
||||||
"private": true,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media uploads",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.lzstealth.com/LZStealth/pulse-signage.git"
|
||||||
|
},
|
||||||
"main": "src/common.js",
|
"main": "src/common.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node -r dotenv/config src/webui.js",
|
"start": "node -r dotenv/config src/web.js",
|
||||||
"start:webui": "node -r dotenv/config src/webui.js",
|
"start:web": "node -r dotenv/config src/web.js",
|
||||||
"start:player": "node -r dotenv/config src/player.js",
|
"start:player": "node -r dotenv/config src/player.js",
|
||||||
"dev:webui": "nodemon -r dotenv/config src/webui.js",
|
"dev:web": "nodemon -r dotenv/config src/web.js",
|
||||||
"dev:player": "nodemon -r dotenv/config src/player.js",
|
"dev:player": "nodemon -r dotenv/config src/player.js"
|
||||||
"docker:build": "docker build -t pulse-signage:test .",
|
|
||||||
"docker:up": "docker-compose up -d",
|
|
||||||
"docker:down": "docker-compose down",
|
|
||||||
"docker:logs": "docker-compose logs -f --tail=100"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@sparticuz/chromium": "^137.0.0",
|
||||||
|
"bootstrap-icons": "1.11.3",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"multer": "^1.4.5-lts.1",
|
"hls.js": "^1.5.15",
|
||||||
|
"multer": "^2.2.0",
|
||||||
"mysql2": "^3.14.3",
|
"mysql2": "^3.14.3",
|
||||||
|
"puppeteer-core": "^24.16.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+22
-1
@@ -9,7 +9,25 @@ module.exports = {
|
|||||||
uniqueScreenSlug: data.uniqueScreenSlug,
|
uniqueScreenSlug: data.uniqueScreenSlug,
|
||||||
parseJsonSafe: data.parseJsonSafe,
|
parseJsonSafe: data.parseJsonSafe,
|
||||||
fetchAdminData: data.fetchAdminData,
|
fetchAdminData: data.fetchAdminData,
|
||||||
|
fetchPlaylistsPage: data.fetchPlaylistsPage,
|
||||||
|
fetchSlidesPage: data.fetchSlidesPage,
|
||||||
|
fetchTemplatesPage: data.fetchTemplatesPage,
|
||||||
|
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
|
||||||
|
fetchScreensPage: data.fetchScreensPage,
|
||||||
fetchPlaylistById: data.fetchPlaylistById,
|
fetchPlaylistById: data.fetchPlaylistById,
|
||||||
|
fetchApiSourcesData: data.fetchApiSourcesData,
|
||||||
|
fetchApiSourcesPage: data.fetchApiSourcesPage,
|
||||||
|
fetchApiSourceById: data.fetchApiSourceById,
|
||||||
|
fetchApiSourceResponse: data.fetchApiSourceResponse,
|
||||||
|
buildApiSourcePayload: data.buildApiSourcePayload,
|
||||||
|
fetchRssFeedsData: data.fetchRssFeedsData,
|
||||||
|
fetchRssFeedsPage: data.fetchRssFeedsPage,
|
||||||
|
fetchRssFeedById: data.fetchRssFeedById,
|
||||||
|
fetchRssFeedItemsByFeedId: data.fetchRssFeedItemsByFeedId,
|
||||||
|
normalizeRssFeedItem: data.normalizeRssFeedItem,
|
||||||
|
buildRssFeedPayload: data.buildRssFeedPayload,
|
||||||
|
fetchRssFeedItems: data.fetchRssFeedItems,
|
||||||
|
replaceRssFeedItems: data.replaceRssFeedItems,
|
||||||
fetchScreenById: data.fetchScreenById,
|
fetchScreenById: data.fetchScreenById,
|
||||||
fetchScreenEditData: data.fetchScreenEditData,
|
fetchScreenEditData: data.fetchScreenEditData,
|
||||||
fetchTemplateById: data.fetchTemplateById,
|
fetchTemplateById: data.fetchTemplateById,
|
||||||
@@ -21,6 +39,9 @@ module.exports = {
|
|||||||
buildSlidePayload: data.buildSlidePayload,
|
buildSlidePayload: data.buildSlidePayload,
|
||||||
extractTemplateRegions: data.extractTemplateRegions,
|
extractTemplateRegions: data.extractTemplateRegions,
|
||||||
buildTemplatePayload: data.buildTemplatePayload,
|
buildTemplatePayload: data.buildTemplatePayload,
|
||||||
|
fetchDuplicateName: data.fetchDuplicateName,
|
||||||
mediaKind: player.mediaKind,
|
mediaKind: player.mediaKind,
|
||||||
renderPlayerPage: player.renderPlayerPage
|
renderPlayerPage: player.renderPlayerPage,
|
||||||
|
renderPlayerOnboardingLandingPage: player.renderPlayerOnboardingLandingPage,
|
||||||
|
renderPlayerOnboardingFormPage: player.renderPlayerOnboardingFormPage
|
||||||
};
|
};
|
||||||
|
|||||||
+87
-6
@@ -1,16 +1,18 @@
|
|||||||
|
const { fetchPagedRows } = require('./utils');
|
||||||
|
|
||||||
async function fetchAdminData(pool) {
|
async function fetchAdminData(pool) {
|
||||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||||
const [templates] = await pool.query(`
|
const [templates] = await pool.query(`
|
||||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
||||||
FROM slide_templates st
|
FROM slide_templates st
|
||||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
ORDER BY st.id DESC
|
ORDER BY st.id DESC
|
||||||
`);
|
`);
|
||||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||||
const [slides] = await pool.query(`
|
const [slides] = await pool.query(`
|
||||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
FROM slides s
|
FROM slides s
|
||||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
@@ -23,7 +25,7 @@ async function fetchAdminData(pool) {
|
|||||||
ORDER BY s.id DESC
|
ORDER BY s.id DESC
|
||||||
`);
|
`);
|
||||||
const [playlistSlides] = await pool.query(`
|
const [playlistSlides] = await pool.query(`
|
||||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, cs.width AS canvas_width, cs.height AS canvas_height
|
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
FROM playlist_slides ps
|
FROM playlist_slides ps
|
||||||
JOIN slides sl ON sl.id = ps.slide_id
|
JOIN slides sl ON sl.id = ps.slide_id
|
||||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||||
@@ -33,6 +35,85 @@ async function fetchAdminData(pool) {
|
|||||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchPlaylistsPage(pool, page, pageSize) {
|
||||||
|
const paged = await fetchPagedRows(pool, {
|
||||||
|
selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by,
|
||||||
|
(SELECT COUNT(*) FROM playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count
|
||||||
|
FROM playlists p
|
||||||
|
ORDER BY p.id DESC`,
|
||||||
|
countSql: 'SELECT COUNT(*) AS count FROM playlists',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ playlists: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSlidesPage(pool, page, pageSize) {
|
||||||
|
const paged = await fetchPagedRows(pool, {
|
||||||
|
selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
|
FROM slides s
|
||||||
|
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||||
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
|
ORDER BY s.id DESC`,
|
||||||
|
countSql: 'SELECT COUNT(*) AS count FROM slides',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ slides: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTemplatesPage(pool, page, pageSize) {
|
||||||
|
const paged = await fetchPagedRows(pool, {
|
||||||
|
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||||
|
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
|
||||||
|
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count,
|
||||||
|
(SELECT COUNT(*) FROM slides s WHERE s.template_id = st.id) AS slide_count
|
||||||
|
FROM slide_templates st
|
||||||
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
|
ORDER BY st.id DESC`,
|
||||||
|
countSql: 'SELECT COUNT(*) AS count FROM slide_templates',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ templates: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCanvasSizesPage(pool, page, pageSize) {
|
||||||
|
const paged = await fetchPagedRows(pool, {
|
||||||
|
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
|
||||||
|
(SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count
|
||||||
|
FROM canvas_sizes
|
||||||
|
ORDER BY width ASC, height ASC, name ASC`,
|
||||||
|
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ canvasSizes: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchScreensPage(pool, page, pageSize) {
|
||||||
|
const paged = await fetchPagedRows(pool, {
|
||||||
|
selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||||
|
FROM screens s
|
||||||
|
LEFT JOIN playlists p ON p.id = s.playlist_id
|
||||||
|
ORDER BY s.id DESC`,
|
||||||
|
countSql: 'SELECT COUNT(*) AS count FROM screens',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ screens: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fetchAdminData
|
fetchAdminData,
|
||||||
|
fetchPlaylistsPage,
|
||||||
|
fetchSlidesPage,
|
||||||
|
fetchTemplatesPage,
|
||||||
|
fetchCanvasSizesPage,
|
||||||
|
fetchScreensPage
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const { fetchPagedRows } = require('./utils');
|
||||||
|
|
||||||
|
function normalizeUpdateIntervalUnit(value) {
|
||||||
|
const unit = String(value || '').trim().toLowerCase();
|
||||||
|
return unit === 'seconds' ? 'seconds' : 'minutes';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchApiSourcesData(pool) {
|
||||||
|
const [apiSources] = await pool.query(
|
||||||
|
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC'
|
||||||
|
);
|
||||||
|
|
||||||
|
return { apiSources: apiSources };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchApiSourcesPage(pool, page, pageSize) {
|
||||||
|
const paged = await fetchPagedRows(pool, {
|
||||||
|
selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC',
|
||||||
|
countSql: 'SELECT COUNT(*) AS count FROM api_sources',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ apiSources: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchApiSourceById(pool, id) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources WHERE id = ?',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUrlText(urlValue) {
|
||||||
|
if (typeof fetch === 'function') {
|
||||||
|
const response = await fetch(urlValue, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
||||||
|
'User-Agent': 'Pulse Signage API Reader'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: response.status,
|
||||||
|
contentType: response.headers.get('content-type') || '',
|
||||||
|
bodyText: await response.text(),
|
||||||
|
ok: response.ok
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise(function (resolve, reject) {
|
||||||
|
const url = new URL(urlValue);
|
||||||
|
const transport = url.protocol === 'https:' ? https : http;
|
||||||
|
const request = transport.get(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
||||||
|
'User-Agent': 'Pulse Signage API Reader'
|
||||||
|
}
|
||||||
|
}, function (response) {
|
||||||
|
response.setEncoding('utf8');
|
||||||
|
let body = '';
|
||||||
|
response.on('data', function (chunk) {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
response.on('end', function () {
|
||||||
|
resolve({
|
||||||
|
statusCode: response.statusCode || 0,
|
||||||
|
contentType: String(response.headers['content-type'] || ''),
|
||||||
|
bodyText: body,
|
||||||
|
ok: !response.statusCode || response.statusCode < 400
|
||||||
|
});
|
||||||
|
});
|
||||||
|
response.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchApiSourceResponse(apiUrl) {
|
||||||
|
const response = await loadUrlText(apiUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unable to load API response (${response.statusCode}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(response.bodyText || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('API response did not return JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch (_error) {
|
||||||
|
throw new Error('API response was not valid JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
responseJson: JSON.stringify(parsed, null, 2),
|
||||||
|
responseStatus: response.statusCode,
|
||||||
|
responseContentType: response.contentType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApiSourcePayload(req, existingApiSource) {
|
||||||
|
const fallback = existingApiSource || {};
|
||||||
|
const name = String(req.body.name || fallback.name || '').trim();
|
||||||
|
const apiUrl = String(req.body.api_url || req.body.apiUrl || fallback.api_url || '').trim();
|
||||||
|
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||||
|
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
const error = new Error('API source name is required.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiUrl) {
|
||||||
|
const error = new Error('API source URL is required.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(apiUrl);
|
||||||
|
} catch (_error) {
|
||||||
|
const error = new Error('Enter a valid API URL.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||||
|
const error = new Error('API URL must start with http or https.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(updateIntervalValue)) {
|
||||||
|
const error = new Error('Update interval must be a number.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
apiUrl: parsedUrl.toString(),
|
||||||
|
updateIntervalValue: Math.floor(updateIntervalValue),
|
||||||
|
updateIntervalUnit: updateIntervalUnit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchApiSourcesData: fetchApiSourcesData,
|
||||||
|
fetchApiSourcesPage: fetchApiSourcesPage,
|
||||||
|
fetchApiSourceById: fetchApiSourceById,
|
||||||
|
fetchApiSourceResponse: fetchApiSourceResponse,
|
||||||
|
buildApiSourcePayload: buildApiSourcePayload
|
||||||
|
};
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
|
const { fetchPagedRows } = require('./utils');
|
||||||
|
|
||||||
async function fetchCanvasSizesData(pool) {
|
async function fetchCanvasSizesData(pool) {
|
||||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||||
return { canvasSizes };
|
return { canvasSizes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchCanvasSizesPage(pool, page, pageSize) {
|
||||||
|
const paged = await fetchPagedRows(pool, {
|
||||||
|
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC',
|
||||||
|
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ canvasSizes: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCanvasSizeById(pool, id) {
|
async function fetchCanvasSizeById(pool, id) {
|
||||||
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]);
|
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
@@ -28,6 +41,7 @@ function buildCanvasSizePayload(req, existingCanvasSize) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fetchCanvasSizesData,
|
fetchCanvasSizesData,
|
||||||
|
fetchCanvasSizesPage,
|
||||||
fetchCanvasSizeById,
|
fetchCanvasSizeById,
|
||||||
buildCanvasSizePayload
|
buildCanvasSizePayload
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function normalizeClientName(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceId(value) {
|
||||||
|
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectLiveConnections(liveConnections) {
|
||||||
|
return Array.isArray(liveConnections) ? liveConnections : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConnections) {
|
||||||
|
const normalizedName = normalizeClientName(clientName);
|
||||||
|
if (!normalizedName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDeviceId = normalizeDeviceId(excludeDeviceId);
|
||||||
|
const live = collectLiveConnections(liveConnections);
|
||||||
|
const lowerName = normalizedName.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pool) {
|
||||||
|
const [deviceRows] = await pool.query(
|
||||||
|
`SELECT device_id
|
||||||
|
FROM player_onboarding_devices
|
||||||
|
WHERE client_name IS NOT NULL
|
||||||
|
AND TRIM(client_name) <> ''
|
||||||
|
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
|
||||||
|
AND device_id <> ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[normalizedName, normalizedDeviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deviceRows.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const connection of live) {
|
||||||
|
const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : '');
|
||||||
|
if (!existingName || existingName.toLowerCase() !== lowerName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : '');
|
||||||
|
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
for (const connection of live) {
|
||||||
|
const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : '');
|
||||||
|
if (!existingName || existingName.toLowerCase() !== lowerName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : '');
|
||||||
|
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClientNameLockName(clientName) {
|
||||||
|
return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withClientNameReservation(pool, clientName, handler) {
|
||||||
|
if (!pool || typeof pool.getConnection !== 'function') {
|
||||||
|
return handler();
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = normalizeClientName(clientName);
|
||||||
|
if (!normalizedName) {
|
||||||
|
return handler();
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
const lockName = buildClientNameLockName(normalizedName);
|
||||||
|
let lockAcquired = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [lockRows] = await connection.query('SELECT GET_LOCK(?, 5) AS lock_result', [lockName]);
|
||||||
|
const lockResult = lockRows && lockRows[0] ? Number(lockRows[0].lock_result) : 0;
|
||||||
|
if (lockResult !== 1) {
|
||||||
|
const error = new Error('Client name is busy. Please try again.');
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
lockAcquired = true;
|
||||||
|
return await handler();
|
||||||
|
} finally {
|
||||||
|
if (lockAcquired) {
|
||||||
|
try {
|
||||||
|
await connection.query('SELECT RELEASE_LOCK(?)', [lockName]);
|
||||||
|
} catch (_error) {}
|
||||||
|
}
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeClientName: normalizeClientName,
|
||||||
|
normalizeDeviceId: normalizeDeviceId,
|
||||||
|
collectLiveConnections: collectLiveConnections,
|
||||||
|
isClientNameAvailable: isClientNameAvailable,
|
||||||
|
withClientNameReservation: withClientNameReservation
|
||||||
|
};
|
||||||
+24
-3
@@ -1,17 +1,37 @@
|
|||||||
const { fetchAdminData } = require('./admin');
|
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin');
|
||||||
const { fetchPlaylistById } = require('./playlists');
|
const { fetchPlaylistById } = require('./playlists');
|
||||||
|
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||||
|
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
|
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
|
||||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
||||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||||
const { parseJsonSafe } = require('./utils');
|
const { parseJsonSafe, fetchDuplicateName } = require('./utils');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
slugify,
|
slugify,
|
||||||
uniqueScreenSlug,
|
uniqueScreenSlug,
|
||||||
parseJsonSafe,
|
parseJsonSafe,
|
||||||
fetchAdminData,
|
fetchAdminData,
|
||||||
|
fetchPlaylistsPage,
|
||||||
|
fetchSlidesPage,
|
||||||
|
fetchTemplatesPage,
|
||||||
|
fetchCanvasSizesPage,
|
||||||
|
fetchScreensPage,
|
||||||
fetchPlaylistById,
|
fetchPlaylistById,
|
||||||
|
fetchApiSourcesData,
|
||||||
|
fetchApiSourcesPage,
|
||||||
|
fetchApiSourceById,
|
||||||
|
fetchApiSourceResponse,
|
||||||
|
buildApiSourcePayload,
|
||||||
|
fetchRssFeedsData,
|
||||||
|
fetchRssFeedsPage,
|
||||||
|
fetchRssFeedById,
|
||||||
|
fetchRssFeedItemsByFeedId,
|
||||||
|
normalizeRssFeedItem,
|
||||||
|
buildRssFeedPayload,
|
||||||
|
fetchRssFeedItems,
|
||||||
|
replaceRssFeedItems,
|
||||||
fetchScreenById,
|
fetchScreenById,
|
||||||
fetchScreenEditData,
|
fetchScreenEditData,
|
||||||
fetchTemplateById,
|
fetchTemplateById,
|
||||||
@@ -22,5 +42,6 @@ module.exports = {
|
|||||||
buildCanvasSizePayload,
|
buildCanvasSizePayload,
|
||||||
buildSlidePayload,
|
buildSlidePayload,
|
||||||
extractTemplateRegions,
|
extractTemplateRegions,
|
||||||
buildTemplatePayload
|
buildTemplatePayload,
|
||||||
|
fetchDuplicateName
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
async function fetchPlaylistById(pool, id) {
|
async function fetchPlaylistById(pool, id) {
|
||||||
const [rows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]);
|
const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const { fetchPagedRows } = require('./utils');
|
||||||
|
|
||||||
|
function normalizeUpdateIntervalUnit(value) {
|
||||||
|
const unit = String(value || '').trim().toLowerCase();
|
||||||
|
return unit === 'seconds' ? 'seconds' : 'minutes';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRssFeedsData(pool) {
|
||||||
|
const [rssFeeds] = await pool.query(
|
||||||
|
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC'
|
||||||
|
);
|
||||||
|
|
||||||
|
return { rssFeeds: rssFeeds };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRssFeedsPage(pool, page, pageSize) {
|
||||||
|
const paged = await fetchPagedRows(pool, {
|
||||||
|
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC',
|
||||||
|
countSql: 'SELECT COUNT(*) AS count FROM rss_feeds',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ rssFeeds: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRssFeedById(pool, id) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRssFeedItemsByFeedId(pool, rssFeedId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT id, rss_feed_id, position, item_json, created_at, modified_at
|
||||||
|
FROM rss_feed_items
|
||||||
|
WHERE rss_feed_id = ?
|
||||||
|
ORDER BY position ASC, id ASC`,
|
||||||
|
[rssFeedId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(function (row) {
|
||||||
|
return normalizeRssFeedItem(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRssFeedItem(row) {
|
||||||
|
let itemJson = null;
|
||||||
|
if (row && row.item_json) {
|
||||||
|
try {
|
||||||
|
itemJson = JSON.parse(row.item_json);
|
||||||
|
} catch (_error) {
|
||||||
|
itemJson = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.assign({}, row || {}, itemJson || {}, {
|
||||||
|
itemJson: itemJson
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUrlText(urlValue) {
|
||||||
|
if (typeof fetch === 'function') {
|
||||||
|
const response = await fetch(urlValue, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
|
||||||
|
'User-Agent': 'Pulse Signage RSS Reader'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unable to load RSS feed (${response.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise(function (resolve, reject) {
|
||||||
|
const url = new URL(urlValue);
|
||||||
|
const transport = url.protocol === 'https:' ? https : http;
|
||||||
|
const request = transport.get(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
|
||||||
|
'User-Agent': 'Pulse Signage RSS Reader'
|
||||||
|
}
|
||||||
|
}, function (response) {
|
||||||
|
if (response.statusCode && response.statusCode >= 400) {
|
||||||
|
reject(new Error(`Unable to load RSS feed (${response.statusCode}).`));
|
||||||
|
response.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.setEncoding('utf8');
|
||||||
|
let body = '';
|
||||||
|
response.on('data', function (chunk) {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
response.on('end', function () {
|
||||||
|
resolve(body);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXmlEntities(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCdata(value) {
|
||||||
|
return String(value || '').replace(/^<!\[CDATA\[|\]\]>$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractXmlTag(content, tagName) {
|
||||||
|
const safeTagName = escapeRegExp(tagName);
|
||||||
|
const pattern = new RegExp(`<${safeTagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\/${safeTagName}>`, 'i');
|
||||||
|
const match = pattern.exec(content || '');
|
||||||
|
if (!match) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return decodeXmlEntities(stripCdata(match[1]).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAtomLink(content) {
|
||||||
|
const linkMatch = /<link\b[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["'][^>]*\/>/i.exec(content || '')
|
||||||
|
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*\/>/i.exec(content || '')
|
||||||
|
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*>/i.exec(content || '');
|
||||||
|
if (!linkMatch) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return decodeXmlEntities(String(linkMatch[1] || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRssItems(xmlText, itemLimit) {
|
||||||
|
const normalizedXml = String(xmlText || '');
|
||||||
|
const itemMatches = Array.from(normalizedXml.matchAll(/<item\b[\s\S]*?<\/item>|<entry\b[\s\S]*?<\/entry>/gi)).map(function (match) {
|
||||||
|
return String(match[0] || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!itemMatches.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemMatches.slice(0, Math.max(1, Number(itemLimit) || 1)).map(function (itemXml, index) {
|
||||||
|
const title = extractXmlTag(itemXml, 'title') || `Item ${index + 1}`;
|
||||||
|
const link = extractXmlTag(itemXml, 'link') || extractAtomLink(itemXml);
|
||||||
|
const pubDate = extractXmlTag(itemXml, 'pubDate') || extractXmlTag(itemXml, 'updated') || extractXmlTag(itemXml, 'dc:date');
|
||||||
|
const description = extractXmlTag(itemXml, 'description') || extractXmlTag(itemXml, 'summary') || extractXmlTag(itemXml, 'content:encoded');
|
||||||
|
const author = extractXmlTag(itemXml, 'author') || extractXmlTag(itemXml, 'dc:creator');
|
||||||
|
const comments = extractXmlTag(itemXml, 'comments');
|
||||||
|
const guidMatch = /<guid\b([^>]*)>([\s\S]*?)<\/guid>/i.exec(itemXml || '');
|
||||||
|
const guid = guidMatch ? decodeXmlEntities(stripCdata(String(guidMatch[2] || '').trim())) : '';
|
||||||
|
const guidIsPermaLinkMatch = guidMatch && /\bisPermaLink\s*=\s*(["']?)(true|false)\1/i.exec(String(guidMatch[1] || ''));
|
||||||
|
const categories = Array.from(String(itemXml || '').matchAll(/<category\b([^>]*)>([\s\S]*?)<\/category>/gi)).map(function (match) {
|
||||||
|
const categoryAttributes = String(match[1] || '');
|
||||||
|
const categoryDomainMatch = /\bdomain\s*=\s*(["'])([^"']+)\1/i.exec(categoryAttributes);
|
||||||
|
return {
|
||||||
|
value: decodeXmlEntities(stripCdata(String(match[2] || '').trim())),
|
||||||
|
domain: categoryDomainMatch ? decodeXmlEntities(String(categoryDomainMatch[2] || '').trim()) : ''
|
||||||
|
};
|
||||||
|
}).filter(function (category) {
|
||||||
|
return Boolean(category.value);
|
||||||
|
});
|
||||||
|
const enclosureMatch = /<enclosure\b([^>]*)\/?>(?:\s*)/i.exec(itemXml || '');
|
||||||
|
const enclosureUrlMatch = enclosureMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
||||||
|
const enclosureLengthMatch = enclosureMatch ? /\blength\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
||||||
|
const enclosureTypeMatch = enclosureMatch ? /\btype\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
||||||
|
const sourceMatch = /<source\b([^>]*)>([\s\S]*?)<\/source>/i.exec(itemXml || '');
|
||||||
|
const sourceUrlMatch = sourceMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(sourceMatch[1] || '')) : null;
|
||||||
|
|
||||||
|
const parsedItem = {
|
||||||
|
title: title,
|
||||||
|
link: link,
|
||||||
|
description: description,
|
||||||
|
author: author,
|
||||||
|
comments: comments,
|
||||||
|
guid: guid,
|
||||||
|
guidIsPermaLink: guidIsPermaLinkMatch ? String(guidIsPermaLinkMatch[2] || '').toLowerCase() === 'true' : null,
|
||||||
|
pubDate: pubDate,
|
||||||
|
categories: categories,
|
||||||
|
enclosure: enclosureMatch ? {
|
||||||
|
url: enclosureUrlMatch ? decodeXmlEntities(String(enclosureUrlMatch[2] || '').trim()) : '',
|
||||||
|
length: enclosureLengthMatch ? Number(enclosureLengthMatch[2]) || null : null,
|
||||||
|
type: enclosureTypeMatch ? decodeXmlEntities(String(enclosureTypeMatch[2] || '').trim()) : ''
|
||||||
|
} : null,
|
||||||
|
source: sourceMatch ? {
|
||||||
|
title: decodeXmlEntities(stripCdata(String(sourceMatch[2] || '').trim())),
|
||||||
|
url: sourceUrlMatch ? decodeXmlEntities(String(sourceUrlMatch[2] || '').trim()) : ''
|
||||||
|
} : null,
|
||||||
|
rawXml: itemXml
|
||||||
|
};
|
||||||
|
|
||||||
|
return parsedItem;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRssFeedItems(feedUrl, itemLimit) {
|
||||||
|
const xmlText = await loadUrlText(feedUrl);
|
||||||
|
return parseRssItems(xmlText, itemLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceRssFeedItems(connection, rssFeedId, items) {
|
||||||
|
const normalizedItems = Array.isArray(items) ? items : [];
|
||||||
|
await connection.query('DELETE FROM rss_feed_items WHERE rss_feed_id = ?', [rssFeedId]);
|
||||||
|
|
||||||
|
if (!normalizedItems.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertValues = normalizedItems.map(function (item, index) {
|
||||||
|
return [
|
||||||
|
rssFeedId,
|
||||||
|
index + 1,
|
||||||
|
JSON.stringify(item || {})
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
await connection.query(
|
||||||
|
'INSERT INTO rss_feed_items (rss_feed_id, position, item_json) VALUES ?',
|
||||||
|
[insertValues]
|
||||||
|
);
|
||||||
|
|
||||||
|
return insertValues.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRssFeedPayload(req, existingRssFeed) {
|
||||||
|
const fallback = existingRssFeed || {};
|
||||||
|
const name = String(req.body.name || fallback.name || '').trim();
|
||||||
|
const feedUrl = String(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '').trim();
|
||||||
|
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||||
|
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||||
|
const itemLimit = Math.max(1, Number(req.body.item_limit || req.body.itemLimit || fallback.item_limit || 1));
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
const error = new Error('RSS feed name is required.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feedUrl) {
|
||||||
|
const error = new Error('RSS feed URL is required.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(feedUrl);
|
||||||
|
} catch (_error) {
|
||||||
|
const error = new Error('Enter a valid RSS feed URL.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||||
|
const error = new Error('RSS feed URL must start with http or https.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(updateIntervalValue)) {
|
||||||
|
const error = new Error('Update interval must be a number.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(itemLimit)) {
|
||||||
|
const error = new Error('Item count must be a number.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
feedUrl: parsedUrl.toString(),
|
||||||
|
updateIntervalValue: Math.floor(updateIntervalValue),
|
||||||
|
updateIntervalUnit: updateIntervalUnit,
|
||||||
|
itemLimit: Math.floor(itemLimit)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchRssFeedsData,
|
||||||
|
fetchRssFeedsPage,
|
||||||
|
fetchRssFeedById,
|
||||||
|
fetchRssFeedItemsByFeedId,
|
||||||
|
normalizeRssFeedItem,
|
||||||
|
buildRssFeedPayload,
|
||||||
|
fetchRssFeedItems,
|
||||||
|
replaceRssFeedItems
|
||||||
|
};
|
||||||
+1
-1
@@ -38,7 +38,7 @@ async function fetchScreenById(pool, id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchScreenEditData(pool) {
|
async function fetchScreenEditData(pool) {
|
||||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||||
return { playlists };
|
return { playlists };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+67
-3
@@ -2,6 +2,7 @@ const { fetchTemplateById } = require('./templates');
|
|||||||
const { parseJsonSafe } = require('./utils');
|
const { parseJsonSafe } = require('./utils');
|
||||||
|
|
||||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
||||||
|
const DEFAULT_FONT_SIZE = 32;
|
||||||
|
|
||||||
function sanitizeRichText(html) {
|
function sanitizeRichText(html) {
|
||||||
let output = String(html || '');
|
let output = String(html || '');
|
||||||
@@ -26,7 +27,7 @@ function sanitizeRichText(html) {
|
|||||||
|
|
||||||
async function fetchSlideById(pool, id) {
|
async function fetchSlideById(pool, id) {
|
||||||
const [slides] = await pool.query(`
|
const [slides] = await pool.query(`
|
||||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at,
|
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at,
|
||||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
FROM slides s
|
FROM slides s
|
||||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||||
@@ -60,10 +61,19 @@ function sanitizeTextColor(value, fallback) {
|
|||||||
return fallback || '#000000';
|
return fallback || '#000000';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeFontSize(value, fallback) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
const parsed = Math.round(Number(raw.replace(/[^0-9.]/g, '')));
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return Math.max(8, Number(fallback || DEFAULT_FONT_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
function getTextRegionStyle(body, region, existingContent) {
|
function getTextRegionStyle(body, region, existingContent) {
|
||||||
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||||
const fontFamily = String(body[`region_font_family_${region.id}`] || existing.font_family || region.font_family || 'Arial').trim() || 'Arial';
|
const fontFamily = String(body[`region_font_family_${region.id}`] || existing.font_family || region.font_family || 'Arial').trim() || 'Arial';
|
||||||
const fontSize = Math.max(8, Number(body[`region_font_size_${region.id}`] || existing.font_size || 24));
|
const fontSize = sanitizeFontSize(body[`region_font_size_${region.id}`], existing.font_size || DEFAULT_FONT_SIZE);
|
||||||
const fontColor = sanitizeTextColor(body[`region_font_color_${region.id}`] || existing.font_color || region.font_color || '#000000');
|
const fontColor = sanitizeTextColor(body[`region_font_color_${region.id}`] || existing.font_color || region.font_color || '#000000');
|
||||||
return {
|
return {
|
||||||
font_family: fontFamily,
|
font_family: fontFamily,
|
||||||
@@ -80,7 +90,19 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
|||||||
const existing = body[`existing_region_image_${region.id}`];
|
const existing = body[`existing_region_image_${region.id}`];
|
||||||
content[region.region_key] = {
|
content[region.region_key] = {
|
||||||
type: 'image',
|
type: 'image',
|
||||||
value: uploaded ? `/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
||||||
|
};
|
||||||
|
} else if (region.region_type === 'video') {
|
||||||
|
const uploaded = filesByField[`region_video_${region.id}`];
|
||||||
|
const existing = body[`existing_region_video_${region.id}`];
|
||||||
|
const durationValue = body[`existing_region_video_duration_${region.id}`];
|
||||||
|
const existingDuration = existingContent && existingContent[region.region_key] ? Number(existingContent[region.region_key].duration_seconds || 0) : 0;
|
||||||
|
const parsedDuration = Number(durationValue || existingDuration || 0);
|
||||||
|
const normalizedDuration = Math.round(parsedDuration * 1000) / 1000;
|
||||||
|
content[region.region_key] = {
|
||||||
|
type: 'video',
|
||||||
|
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : ''),
|
||||||
|
duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null
|
||||||
};
|
};
|
||||||
} else if (region.region_type === 'webpage') {
|
} else if (region.region_type === 'webpage') {
|
||||||
const submitted = body[`region_webpage_${region.id}`];
|
const submitted = body[`region_webpage_${region.id}`];
|
||||||
@@ -89,6 +111,14 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
|||||||
type: 'webpage',
|
type: 'webpage',
|
||||||
value: submitted === undefined ? current : String(submitted || '').trim()
|
value: submitted === undefined ? current : String(submitted || '').trim()
|
||||||
};
|
};
|
||||||
|
} else if (region.region_type === 'rtmp') {
|
||||||
|
const submitted = body[`region_rtmp_${region.id}`];
|
||||||
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||||
|
content[region.region_key] = {
|
||||||
|
type: 'rtmp',
|
||||||
|
value: submitted === undefined ? String(current.value || '').trim() : String(submitted || '').trim(),
|
||||||
|
disable_audio: Boolean(body[`region_disable_audio_${region.id}`])
|
||||||
|
};
|
||||||
} else if (region.region_type === 'html') {
|
} else if (region.region_type === 'html') {
|
||||||
const submitted = body[`region_html_${region.id}`];
|
const submitted = body[`region_html_${region.id}`];
|
||||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||||
@@ -96,6 +126,40 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
|||||||
type: 'html',
|
type: 'html',
|
||||||
value: submitted === undefined ? current : String(submitted || '')
|
value: submitted === undefined ? current : String(submitted || '')
|
||||||
};
|
};
|
||||||
|
} else if (region.region_type === 'rss') {
|
||||||
|
const submitted = body[`region_text_${region.id}`];
|
||||||
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||||
|
const style = getTextRegionStyle(body, region, existingContent);
|
||||||
|
const feedId = body[`region_rss_feed_id_${region.id}`];
|
||||||
|
const itemNumber = body[`region_rss_item_number_${region.id}`];
|
||||||
|
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
|
||||||
|
content[region.region_key] = {
|
||||||
|
type: 'rss',
|
||||||
|
value: submitted === undefined ? String(current.value || '') : String(submitted || ''),
|
||||||
|
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId),
|
||||||
|
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||||
|
variable_name: 'item',
|
||||||
|
font_family: style.font_family,
|
||||||
|
font_size: style.font_size,
|
||||||
|
font_color: style.font_color
|
||||||
|
};
|
||||||
|
} else if (region.region_type === 'api') {
|
||||||
|
const submitted = body[`region_text_${region.id}`];
|
||||||
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||||
|
const style = getTextRegionStyle(body, region, existingContent);
|
||||||
|
const sourceId = body[`region_api_source_id_${region.id}`];
|
||||||
|
const itemNumber = body[`region_api_item_number_${region.id}`];
|
||||||
|
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
|
||||||
|
content[region.region_key] = {
|
||||||
|
type: 'api',
|
||||||
|
value: submitted === undefined ? String(current.value || '') : String(submitted || ''),
|
||||||
|
source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId),
|
||||||
|
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||||
|
variable_name: 'item',
|
||||||
|
font_family: style.font_family,
|
||||||
|
font_size: style.font_size,
|
||||||
|
font_color: style.font_color
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
const submitted = body[`region_text_${region.id}`];
|
const submitted = body[`region_text_${region.id}`];
|
||||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||||
|
|||||||
+71
-30
@@ -1,15 +1,54 @@
|
|||||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||||
|
|
||||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html'];
|
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'];
|
||||||
|
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
|
||||||
|
|
||||||
|
function sanitizeBackgroundColor(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return '#111111';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTemplateRegionType(value) {
|
function normalizeTemplateRegionType(value) {
|
||||||
const rawType = String(value || 'text').trim();
|
const rawType = String(value || 'text').trim();
|
||||||
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text';
|
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateRegionLockRatio(value) {
|
||||||
|
const rawRatio = String(value || '').trim();
|
||||||
|
if (!/^\d+\s*:\s*\d+$/.test(rawRatio)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return rawRatio.replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateRegionName(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUniqueTemplateRegionNames(regions) {
|
||||||
|
const seen = new Map();
|
||||||
|
for (let i = 0; i < regions.length; i += 1) {
|
||||||
|
const region = regions[i];
|
||||||
|
const regionName = normalizeTemplateRegionName(region.region_key || region.label);
|
||||||
|
if (!regionName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized = regionName.toLowerCase();
|
||||||
|
if (seen.has(normalized)) {
|
||||||
|
const error = new Error('Region names must be unique on this template.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
seen.set(normalized, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchTemplateById(pool, id) {
|
async function fetchTemplateById(pool, id) {
|
||||||
const [templates] = await pool.query(`
|
const [templates] = await pool.query(`
|
||||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
||||||
FROM slide_templates st
|
FROM slide_templates st
|
||||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
@@ -19,20 +58,20 @@ async function fetchTemplateById(pool, id) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const template = templates[0];
|
const template = templates[0];
|
||||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||||
template.regions = regions;
|
template.regions = regions;
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTemplatesData(pool) {
|
async function fetchTemplatesData(pool) {
|
||||||
const [templates] = await pool.query(`
|
const [templates] = await pool.query(`
|
||||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
||||||
FROM slide_templates st
|
FROM slide_templates st
|
||||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
ORDER BY st.id DESC
|
ORDER BY st.id DESC
|
||||||
`);
|
`);
|
||||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||||
return { templates, templateRegions };
|
return { templates, templateRegions };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,17 +80,21 @@ function extractTemplateRegions(body) {
|
|||||||
if (regionsJson) {
|
if (regionsJson) {
|
||||||
const parsed = parseJsonSafe(regionsJson);
|
const parsed = parseJsonSafe(regionsJson);
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
return parsed.map((region) => ({
|
return parsed.map((region) => {
|
||||||
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
const regionType = normalizeTemplateRegionType(region.region_type);
|
||||||
region_type: normalizeTemplateRegionType(region.region_type),
|
return {
|
||||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
||||||
font_family: ['text', 'html'].includes(normalizeTemplateRegionType(region.region_type)) ? String(region.font_family || '').trim() || null : null,
|
region_type: regionType,
|
||||||
x: Number(region.x || 0),
|
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||||
y: Number(region.y || 0),
|
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(region.font_family || '').trim() || null : null,
|
||||||
width: Number(region.width || 100),
|
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
||||||
height: Number(region.height || 100),
|
x: Number(region.x || 0),
|
||||||
z_index: Number(region.z_index || 0)
|
y: Number(region.y || 0),
|
||||||
})).filter((region) => region.region_key && region.label);
|
width: Number(region.width || 100),
|
||||||
|
height: Number(region.height || 100),
|
||||||
|
z_index: Number(region.z_index || 0)
|
||||||
|
};
|
||||||
|
}).filter((region) => region.region_key && region.label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +102,7 @@ function extractTemplateRegions(body) {
|
|||||||
const names = readFormArray(body, 'region_name[]');
|
const names = readFormArray(body, 'region_name[]');
|
||||||
const labels = readFormArray(body, 'region_label[]');
|
const labels = readFormArray(body, 'region_label[]');
|
||||||
const types = readFormArray(body, 'region_type[]');
|
const types = readFormArray(body, 'region_type[]');
|
||||||
|
const ratios = readFormArray(body, 'region_lock_ratio[]');
|
||||||
const xs = readFormArray(body, 'region_x[]');
|
const xs = readFormArray(body, 'region_x[]');
|
||||||
const ys = readFormArray(body, 'region_y[]');
|
const ys = readFormArray(body, 'region_y[]');
|
||||||
const widths = readFormArray(body, 'region_width[]');
|
const widths = readFormArray(body, 'region_width[]');
|
||||||
@@ -78,7 +122,8 @@ function extractTemplateRegions(body) {
|
|||||||
region_key: name,
|
region_key: name,
|
||||||
region_type: regionType,
|
region_type: regionType,
|
||||||
label: name,
|
label: name,
|
||||||
font_family: ['text', 'html'].includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
|
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
|
||||||
|
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
||||||
x: Number(xs[i] || 0),
|
x: Number(xs[i] || 0),
|
||||||
y: Number(ys[i] || 0),
|
y: Number(ys[i] || 0),
|
||||||
width: Number(widths[i] || 100),
|
width: Number(widths[i] || 100),
|
||||||
@@ -106,9 +151,13 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
|||||||
let regions = extractTemplateRegions(req.body);
|
let regions = extractTemplateRegions(req.body);
|
||||||
const filesByField = getFilesByField(req.files || []);
|
const filesByField = getFilesByField(req.files || []);
|
||||||
const backgroundImage = filesByField.background_image;
|
const backgroundImage = filesByField.background_image;
|
||||||
|
const removeBackgroundImage = Boolean(req.body.remove_background_image);
|
||||||
|
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
|
||||||
const backgroundImagePath = backgroundImage
|
const backgroundImagePath = backgroundImage
|
||||||
? `/uploads/${backgroundImage.filename}`
|
? `/media/uploads/${backgroundImage.filename}`
|
||||||
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
|
: removeBackgroundImage
|
||||||
|
? null
|
||||||
|
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
const error = new Error('Template name is required.');
|
const error = new Error('Template name is required.');
|
||||||
@@ -137,25 +186,17 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
|||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
regions = [{
|
|
||||||
region_key: 'region_1',
|
|
||||||
region_type: 'text',
|
|
||||||
label: 'Region 1',
|
|
||||||
font_family: 'Arial',
|
|
||||||
x: 120,
|
|
||||||
y: 120,
|
|
||||||
width: Math.max(200, Math.round(canvasWidth * 0.22)),
|
|
||||||
height: Math.max(120, Math.round(canvasHeight * 0.15)),
|
|
||||||
z_index: 1
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureUniqueTemplateRegionNames(regions);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
canvasSizeId: resolvedCanvasSizeId,
|
canvasSizeId: resolvedCanvasSizeId,
|
||||||
canvasSizeWidth: canvasWidth,
|
canvasSizeWidth: canvasWidth,
|
||||||
canvasSizeHeight: canvasHeight,
|
canvasSizeHeight: canvasHeight,
|
||||||
backgroundImagePath,
|
backgroundImagePath,
|
||||||
|
backgroundColor,
|
||||||
regions
|
regions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-1
@@ -22,7 +22,61 @@ function readFormArray(body, key) {
|
|||||||
return [body[key]];
|
return [body[key]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePageNumber(value) {
|
||||||
|
const pageNumber = Math.floor(Number(value) || 1);
|
||||||
|
return Math.max(1, pageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPagedRows(pool, options) {
|
||||||
|
const selectSql = String(options && options.selectSql || '').trim();
|
||||||
|
const countSql = String(options && options.countSql || '').trim();
|
||||||
|
const params = Array.isArray(options && options.params) ? options.params : [];
|
||||||
|
const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
|
||||||
|
const currentPage = normalizePageNumber(options && options.page);
|
||||||
|
|
||||||
|
if (!selectSql || !countSql) {
|
||||||
|
throw new Error('fetchPagedRows requires selectSql and countSql.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [countRows] = await pool.query(countSql, params);
|
||||||
|
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||||
|
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||||||
|
const offset = (safeCurrentPage - 1) * pageSize;
|
||||||
|
const [rows] = await pool.query(`${selectSql} LIMIT ? OFFSET ?`, params.concat([pageSize, offset]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: rows || [],
|
||||||
|
totalItems: totalItems,
|
||||||
|
totalPages: totalPages,
|
||||||
|
currentPage: safeCurrentPage,
|
||||||
|
pageSize: pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) {
|
||||||
|
const normalizedName = String(name || '').trim();
|
||||||
|
if (!normalizedName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedColumnName = String(columnName || 'name').trim() || 'name';
|
||||||
|
const params = [normalizedName];
|
||||||
|
let sql = `SELECT id, \`${normalizedColumnName}\` AS name FROM \`${tableName}\` WHERE LOWER(TRIM(\`${normalizedColumnName}\`)) = LOWER(TRIM(?))`;
|
||||||
|
if (excludeId !== undefined && excludeId !== null) {
|
||||||
|
sql += ' AND id <> ?';
|
||||||
|
params.push(excludeId);
|
||||||
|
}
|
||||||
|
sql += ' LIMIT 1';
|
||||||
|
|
||||||
|
const [rows] = await pool.query(sql, params);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
parseJsonSafe,
|
parseJsonSafe,
|
||||||
readFormArray
|
readFormArray,
|
||||||
|
normalizePageNumber,
|
||||||
|
fetchPagedRows,
|
||||||
|
fetchDuplicateName
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
const { hashPassword } = require('./auth');
|
|
||||||
|
|
||||||
function createPool() {
|
|
||||||
return mysql.createPool({
|
|
||||||
host: process.env.DB_HOST || '127.0.0.1',
|
|
||||||
port: Number(process.env.DB_PORT || 3306),
|
|
||||||
user: process.env.DB_USER || 'signage_user',
|
|
||||||
password: process.env.DB_PASSWORD || 'signage_password',
|
|
||||||
database: process.env.DB_NAME || 'signage',
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 10,
|
|
||||||
namedPlaceholders: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addColumnIfMissing(pool, tableName, columnName, columnDefinition) {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT COUNT(*) AS column_count
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = DATABASE()
|
|
||||||
AND table_name = ?
|
|
||||||
AND column_name = ?`,
|
|
||||||
[tableName, columnName]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length && Number(rows[0].column_count) > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureSchema(pool) {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS canvas_sizes (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
width INT NOT NULL,
|
|
||||||
height INT NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE KEY uq_canvas_sizes_dimensions (width, height)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
|
||||||
await addColumnIfMissing(pool, 'canvas_sizes', 'created_by', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_by', 'INT NULL');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS playlists (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
|
|
||||||
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
|
||||||
await addColumnIfMissing(pool, 'playlists', 'created_by', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'playlists', 'modified_by', 'INT NULL');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS slide_templates (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
canvas_size_id INT NULL,
|
|
||||||
background_image_path VARCHAR(512) NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
|
|
||||||
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
|
|
||||||
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
|
||||||
await addColumnIfMissing(pool, 'slide_templates', 'created_by', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'slide_templates', 'modified_by', 'INT NULL');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES
|
|
||||||
('Full HD', 1920, 1080),
|
|
||||||
('HD', 1280, 720),
|
|
||||||
('4K UHD', 3840, 2160),
|
|
||||||
('Portrait Full HD', 1080, 1920),
|
|
||||||
('Portrait HD', 720, 1280)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const [legacyTemplateColumns] = await pool.query(`
|
|
||||||
SELECT COUNT(*) AS column_count
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = DATABASE()
|
|
||||||
AND table_name = 'slide_templates'
|
|
||||||
AND column_name IN ('canvas_width', 'canvas_height')
|
|
||||||
`);
|
|
||||||
if (legacyTemplateColumns[0] && Number(legacyTemplateColumns[0].column_count) === 2) {
|
|
||||||
await pool.query(`
|
|
||||||
UPDATE slide_templates st
|
|
||||||
JOIN canvas_sizes cs ON cs.width = st.canvas_width AND cs.height = st.canvas_height
|
|
||||||
SET st.canvas_size_id = cs.id
|
|
||||||
WHERE st.canvas_size_id IS NULL
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS slide_template_regions (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
template_id INT NOT NULL,
|
|
||||||
region_key VARCHAR(100) NOT NULL,
|
|
||||||
region_type VARCHAR(20) NOT NULL,
|
|
||||||
label VARCHAR(255) NOT NULL,
|
|
||||||
x INT NOT NULL DEFAULT 0,
|
|
||||||
y INT NOT NULL DEFAULT 0,
|
|
||||||
width INT NOT NULL DEFAULT 100,
|
|
||||||
height INT NOT NULL DEFAULT 100,
|
|
||||||
z_index INT NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
|
|
||||||
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
|
|
||||||
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
|
||||||
await addColumnIfMissing(pool, 'slide_template_regions', 'created_by', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_by', 'INT NULL');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS slides (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
body TEXT NULL,
|
|
||||||
template_id INT NULL,
|
|
||||||
content_json JSON NULL,
|
|
||||||
media_path VARCHAR(512) NULL,
|
|
||||||
media_type VARCHAR(100) NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
|
|
||||||
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
|
|
||||||
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
|
|
||||||
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
|
|
||||||
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
|
||||||
await addColumnIfMissing(pool, 'slides', 'created_by', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'slides', 'modified_by', 'INT NULL');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS playlist_slides (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
playlist_id INT NOT NULL,
|
|
||||||
slide_id INT NOT NULL,
|
|
||||||
position INT NOT NULL DEFAULT 0,
|
|
||||||
duration_seconds INT NOT NULL DEFAULT 10,
|
|
||||||
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
|
|
||||||
schedule_start_datetime DATETIME NULL,
|
|
||||||
schedule_end_datetime DATETIME NULL,
|
|
||||||
schedule_start_time TIME NULL,
|
|
||||||
schedule_end_time TIME NULL,
|
|
||||||
schedule_days_json JSON NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
|
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
|
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
|
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
|
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
|
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_time', 'TIME NULL');
|
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL');
|
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL');
|
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'created_by', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'modified_by', 'INT NULL');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS screens (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
playlist_id INT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
|
|
||||||
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
|
||||||
await addColumnIfMissing(pool, 'screens', 'created_by', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'screens', 'modified_by', 'INT NULL');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NULL,
|
|
||||||
username VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
password_hash CHAR(64) NOT NULL,
|
|
||||||
password_salt VARCHAR(64) NOT NULL,
|
|
||||||
password_iterations INT NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
await addColumnIfMissing(pool, 'users', 'name', 'VARCHAR(255) NULL');
|
|
||||||
await addColumnIfMissing(pool, 'users', 'password_hash', 'CHAR(64) NOT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
|
||||||
await addColumnIfMissing(pool, 'users', 'created_by', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'users', 'modified_by', 'INT NULL');
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
||||||
session_hash CHAR(64) PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
expires_at DATETIME NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
`);
|
|
||||||
await addColumnIfMissing(pool, 'auth_sessions', 'created_by', 'INT NULL');
|
|
||||||
await addColumnIfMissing(pool, 'auth_sessions', 'modified_by', 'INT NULL');
|
|
||||||
|
|
||||||
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
|
|
||||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
|
||||||
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
|
|
||||||
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
|
|
||||||
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
|
|
||||||
const passwordRecord = hashPassword(password);
|
|
||||||
await pool.query(
|
|
||||||
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
||||||
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createPool,
|
|
||||||
ensureSchema
|
|
||||||
};
|
|
||||||
+382
@@ -0,0 +1,382 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const { hashPassword } = require('../auth');
|
||||||
|
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
|
||||||
|
const migrations = require('./migrations');
|
||||||
|
|
||||||
|
function createPool() {
|
||||||
|
return mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || '127.0.0.1',
|
||||||
|
port: Number(process.env.DB_PORT || 3306),
|
||||||
|
user: process.env.DB_USER || 'signage_user',
|
||||||
|
password: process.env.DB_PASSWORD || 'signage_password',
|
||||||
|
database: process.env.DB_NAME || 'signage',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
namedPlaceholders: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneStaleOnboardingDevices(pool) {
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM player_onboarding_devices
|
||||||
|
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSchema(pool, options) {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS canvas_sizes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
width INT NOT NULL,
|
||||||
|
height INT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL,
|
||||||
|
UNIQUE KEY uq_canvas_sizes_dimensions (width, height)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
skip_unavailable_rtmp TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS slide_templates (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
canvas_size_id INT NULL,
|
||||||
|
background_image_path VARCHAR(512) NULL,
|
||||||
|
background_color VARCHAR(32) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES
|
||||||
|
('Full HD', 1920, 1080),
|
||||||
|
('HD', 1280, 720),
|
||||||
|
('4K UHD', 3840, 2160),
|
||||||
|
('Portrait Full HD', 1080, 1920),
|
||||||
|
('Portrait HD', 720, 1280)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS slide_template_regions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
template_id INT NOT NULL,
|
||||||
|
region_key VARCHAR(100) NOT NULL,
|
||||||
|
region_type VARCHAR(20) NOT NULL,
|
||||||
|
label VARCHAR(255) NOT NULL,
|
||||||
|
lock_ratio VARCHAR(20) NULL,
|
||||||
|
x INT NOT NULL DEFAULT 0,
|
||||||
|
y INT NOT NULL DEFAULT 0,
|
||||||
|
width INT NOT NULL DEFAULT 100,
|
||||||
|
height INT NOT NULL DEFAULT 100,
|
||||||
|
z_index INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS slides (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NULL,
|
||||||
|
template_id INT NULL,
|
||||||
|
content_json JSON NULL,
|
||||||
|
media_path VARCHAR(512) NULL,
|
||||||
|
media_type VARCHAR(100) NULL,
|
||||||
|
thumbnail_path VARCHAR(512) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS playlist_slides (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
playlist_id INT NOT NULL,
|
||||||
|
slide_id INT NOT NULL,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
|
||||||
|
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
|
||||||
|
schedule_start_datetime DATETIME NULL,
|
||||||
|
schedule_end_datetime DATETIME NULL,
|
||||||
|
schedule_start_time TIME NULL,
|
||||||
|
schedule_end_time TIME NULL,
|
||||||
|
schedule_days_json JSON NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL,
|
||||||
|
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS screens (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
playlist_id INT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL,
|
||||||
|
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS rss_feeds (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
feed_url VARCHAR(1024) NOT NULL,
|
||||||
|
update_interval_value INT NOT NULL DEFAULT 60,
|
||||||
|
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
|
||||||
|
item_limit INT NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS rss_feed_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
rss_feed_id INT NOT NULL,
|
||||||
|
position INT NOT NULL,
|
||||||
|
item_json MEDIUMTEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL,
|
||||||
|
CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES rss_feeds(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY uq_rss_feed_items_feed_position (rss_feed_id, position)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS api_sources (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
api_url VARCHAR(1024) NOT NULL,
|
||||||
|
update_interval_value INT NOT NULL DEFAULT 60,
|
||||||
|
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
|
||||||
|
last_pulled_at TIMESTAMP NULL,
|
||||||
|
last_pull_error MEDIUMTEXT NULL,
|
||||||
|
last_response_status INT NULL,
|
||||||
|
last_response_content_type VARCHAR(255) NULL,
|
||||||
|
last_response_json MEDIUMTEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS player_onboarding_devices (
|
||||||
|
device_id VARCHAR(128) PRIMARY KEY,
|
||||||
|
client_name VARCHAR(255) NULL,
|
||||||
|
screen_id INT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL,
|
||||||
|
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NULL,
|
||||||
|
username VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash CHAR(64) NOT NULL,
|
||||||
|
password_salt VARCHAR(64) NOT NULL,
|
||||||
|
password_iterations INT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
role_key VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS permissions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
permission_key VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
section_name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const permission of PERMISSIONS) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO permissions (permission_key, name, section_name, description, created_by, modified_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
|
||||||
|
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||||
|
role_id INT NOT NULL,
|
||||||
|
permission_id INT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL,
|
||||||
|
PRIMARY KEY (role_id, permission_id),
|
||||||
|
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
role_id INT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL,
|
||||||
|
PRIMARY KEY (user_id, role_id),
|
||||||
|
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||||
|
session_hash CHAR(64) PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL,
|
||||||
|
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS background_tasks (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
task_key VARCHAR(191) NULL,
|
||||||
|
task_type VARCHAR(100) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
category VARCHAR(100) NOT NULL DEFAULT 'general',
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
payload_json MEDIUMTEXT NULL,
|
||||||
|
metadata_json MEDIUMTEXT NULL,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
started_at TIMESTAMP NULL,
|
||||||
|
finished_at TIMESTAMP NULL,
|
||||||
|
error_message MEDIUMTEXT NULL,
|
||||||
|
INDEX idx_background_tasks_status (status),
|
||||||
|
INDEX idx_background_tasks_key (task_key),
|
||||||
|
INDEX idx_background_tasks_type (task_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
|
||||||
|
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
|
||||||
|
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||||
|
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
|
||||||
|
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
|
||||||
|
const passwordRecord = hashPassword(password);
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO roles (role_key, name, description, created_by, modified_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
|
||||||
|
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
|
||||||
|
);
|
||||||
|
|
||||||
|
await migrations.runMigrations(pool, options || {});
|
||||||
|
|
||||||
|
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||||
|
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||||
|
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
|
||||||
|
if (defaultRoleId) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by)
|
||||||
|
SELECT id, ?, NULL, NULL FROM users`,
|
||||||
|
[defaultRoleId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [defaultAdminRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
|
||||||
|
if (defaultAdminRows.length) {
|
||||||
|
const defaultAdminId = Number(defaultAdminRows[0].id);
|
||||||
|
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM user_roles WHERE user_id = ?', [defaultAdminId]);
|
||||||
|
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
|
||||||
|
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||||
|
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
|
||||||
|
if (defaultRoleId) {
|
||||||
|
await pool.query(
|
||||||
|
'INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
|
||||||
|
[defaultAdminId, defaultRoleId, null, null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPool,
|
||||||
|
ensureSchema,
|
||||||
|
pruneStaleOnboardingDevices
|
||||||
|
};
|
||||||
@@ -0,0 +1,848 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { version: appVersion } = require('../../package.json');
|
||||||
|
|
||||||
|
function parseVersion(value) {
|
||||||
|
const parts = String(value || '0.0.0').split('.').map(function (part) {
|
||||||
|
return Math.max(0, Number(part) || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
major: parts[0] || 0,
|
||||||
|
minor: parts[1] || 0,
|
||||||
|
patch: parts[2] || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(left, right) {
|
||||||
|
const leftVersion = parseVersion(left);
|
||||||
|
const rightVersion = parseVersion(right);
|
||||||
|
|
||||||
|
if (leftVersion.major !== rightVersion.major) {
|
||||||
|
return leftVersion.major - rightVersion.major;
|
||||||
|
}
|
||||||
|
if (leftVersion.minor !== rightVersion.minor) {
|
||||||
|
return leftVersion.minor - rightVersion.minor;
|
||||||
|
}
|
||||||
|
if (leftVersion.patch !== rightVersion.patch) {
|
||||||
|
return leftVersion.patch - rightVersion.patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addColumnIfMissing(pool, tableName, columnName, columnDefinition) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS column_count
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = ?
|
||||||
|
AND column_name = ?`,
|
||||||
|
[tableName, columnName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length && Number(rows[0].column_count) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropColumnIfPresent(pool, tableName, columnName) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS column_count
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = ?
|
||||||
|
AND column_name = ?`,
|
||||||
|
[tableName, columnName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length || Number(rows[0].column_count) === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE \`${tableName}\` DROP COLUMN \`${columnName}\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addForeignKeyIfMissing(pool, tableName, columnName, constraintName, referencedTable, referencedColumn, onDeleteAction) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS constraint_count
|
||||||
|
FROM information_schema.table_constraints
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = ?
|
||||||
|
AND constraint_name = ?`,
|
||||||
|
[tableName, constraintName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length && Number(rows[0].constraint_count) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`ALTER TABLE \`${tableName}\`
|
||||||
|
ADD CONSTRAINT \`${constraintName}\`
|
||||||
|
FOREIGN KEY (\`${columnName}\`) REFERENCES \`${referencedTable}\`(\`${referencedColumn}\`)
|
||||||
|
ON DELETE ${onDeleteAction}
|
||||||
|
ON UPDATE CASCADE`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasSingleColumnUniqueIndex(pool, tableName, columnName) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT INDEX_NAME, COUNT(*) AS column_count
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = ?
|
||||||
|
AND non_unique = 0
|
||||||
|
AND column_name = ?
|
||||||
|
GROUP BY INDEX_NAME`,
|
||||||
|
[tableName, columnName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (rows || []).some(function (row) {
|
||||||
|
return Number(row.column_count) === 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUniqueIndexIfMissing(pool, tableName, columnName, indexName) {
|
||||||
|
const hasUniqueIndex = await hasSingleColumnUniqueIndex(pool, tableName, columnName);
|
||||||
|
if (hasUniqueIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE \`${tableName}\` ADD UNIQUE KEY \`${indexName}\` (\`${columnName}\`)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dedupePermissionRows(pool) {
|
||||||
|
const [rows] = await pool.query('SELECT id, permission_key FROM permissions ORDER BY id ASC');
|
||||||
|
const canonicalIdByKey = new Map();
|
||||||
|
const duplicateRowsByKey = new Map();
|
||||||
|
|
||||||
|
for (const row of rows || []) {
|
||||||
|
const permissionKey = String((row && row.permission_key) || '').trim().toLowerCase();
|
||||||
|
const permissionId = Number(row.id);
|
||||||
|
if (!permissionKey || !Number.isInteger(permissionId) || permissionId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canonicalIdByKey.has(permissionKey)) {
|
||||||
|
canonicalIdByKey.set(permissionKey, permissionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!duplicateRowsByKey.has(permissionKey)) {
|
||||||
|
duplicateRowsByKey.set(permissionKey, []);
|
||||||
|
}
|
||||||
|
duplicateRowsByKey.get(permissionKey).push(permissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!duplicateRowsByKey.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [permissionKey, duplicateIds] of duplicateRowsByKey.entries()) {
|
||||||
|
const canonicalId = canonicalIdByKey.get(permissionKey);
|
||||||
|
for (const duplicateId of duplicateIds) {
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE IGNORE role_permissions SET permission_id = ? WHERE permission_id = ?',
|
||||||
|
[canonicalId, duplicateId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateIds = [];
|
||||||
|
for (const duplicateList of duplicateRowsByKey.values()) {
|
||||||
|
duplicateIds.push.apply(duplicateIds, duplicateList);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateIds.length) {
|
||||||
|
await pool.query('DELETE FROM permissions WHERE id IN (?)', [duplicateIds]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAuditColumns(pool, tableName) {
|
||||||
|
await addColumnIfMissing(pool, tableName, 'created_by', 'INT NULL');
|
||||||
|
await addColumnIfMissing(pool, tableName, 'modified_by', 'INT NULL');
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE \`${tableName}\` t
|
||||||
|
LEFT JOIN users created_user ON created_user.id = t.created_by
|
||||||
|
SET t.created_by = NULL
|
||||||
|
WHERE t.created_by IS NOT NULL`
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE \`${tableName}\` t
|
||||||
|
LEFT JOIN users modified_user ON modified_user.id = t.modified_by
|
||||||
|
SET t.modified_by = NULL
|
||||||
|
WHERE t.modified_by IS NOT NULL`
|
||||||
|
);
|
||||||
|
|
||||||
|
await addForeignKeyIfMissing(pool, tableName, 'created_by', `fk_${tableName}_created_by`, 'users', 'id', 'SET NULL');
|
||||||
|
await addForeignKeyIfMissing(pool, tableName, 'modified_by', `fk_${tableName}_modified_by`, 'users', 'id', 'SET NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasColumn(pool, tableName, columnName) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS column_count
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = ?
|
||||||
|
AND column_name = ?`,
|
||||||
|
[tableName, columnName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.length && Number(rows[0].column_count) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillLegacyRssFeedItemJson(pool) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'rss_feed_items'`
|
||||||
|
);
|
||||||
|
const columnNames = new Set((rows || []).map(function (row) {
|
||||||
|
return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase();
|
||||||
|
}).filter(Boolean));
|
||||||
|
|
||||||
|
if (!['title', 'link', 'pub_date', 'description'].every(function (columnName) {
|
||||||
|
return columnNames.has(columnName);
|
||||||
|
})) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE rss_feed_items
|
||||||
|
SET item_json = JSON_OBJECT(
|
||||||
|
'title', title,
|
||||||
|
'link', link,
|
||||||
|
'pubDate', pub_date,
|
||||||
|
'description', description
|
||||||
|
)
|
||||||
|
WHERE item_json IS NULL`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillLegacySlideTemplateCanvasSize(pool) {
|
||||||
|
const [legacyTemplateColumns] = await pool.query(`
|
||||||
|
SELECT COUNT(*) AS column_count
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'slide_templates'
|
||||||
|
AND column_name IN ('canvas_width', 'canvas_height')
|
||||||
|
`);
|
||||||
|
if (!legacyTemplateColumns[0] || Number(legacyTemplateColumns[0].column_count) !== 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
UPDATE slide_templates st
|
||||||
|
JOIN canvas_sizes cs ON cs.width = st.canvas_width AND cs.height = st.canvas_height
|
||||||
|
SET st.canvas_size_id = cs.id
|
||||||
|
WHERE st.canvas_size_id IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceUploadPrefixInValue(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.replace(/\/uploads\//g, '/media/');
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(function (item) {
|
||||||
|
return replaceUploadPrefixInValue(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.keys(value).reduce(function (result, key) {
|
||||||
|
result[key] = replaceUploadPrefixInValue(value[key]);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceLegacyMediaUploadPathInValue(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!value.startsWith('/media/') || value.startsWith('/media/uploads/')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return '/media/uploads/' + path.basename(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(function (item) {
|
||||||
|
return replaceLegacyMediaUploadPathInValue(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.keys(value).reduce(function (result, key) {
|
||||||
|
result[key] = replaceLegacyMediaUploadPathInValue(value[key]);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonValue(value) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (_error) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillLegacyMediaPaths(pool) {
|
||||||
|
const [slides] = await pool.query(`
|
||||||
|
SELECT id, media_path, content_json
|
||||||
|
FROM slides
|
||||||
|
WHERE media_path LIKE '/uploads/%'
|
||||||
|
OR content_json LIKE '%/uploads/%'
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const slide of slides || []) {
|
||||||
|
let mediaPath = String(slide.media_path || '').trim() || null;
|
||||||
|
let contentJson = slide.content_json;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (mediaPath && mediaPath.startsWith('/uploads/')) {
|
||||||
|
mediaPath = mediaPath.replace(/^\/uploads\//, '/media/');
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContent = parseJsonValue(contentJson);
|
||||||
|
if (parsedContent && typeof parsedContent === 'object') {
|
||||||
|
const updatedContent = replaceUploadPrefixInValue(parsedContent);
|
||||||
|
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
|
||||||
|
contentJson = JSON.stringify(updatedContent);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/uploads/') !== -1) {
|
||||||
|
contentJson = parsedContent.replace(/\/uploads\//g, '/media/');
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [templates] = await pool.query(`
|
||||||
|
SELECT id, background_image_path
|
||||||
|
FROM slide_templates
|
||||||
|
WHERE background_image_path LIKE '/uploads/%'
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const template of templates || []) {
|
||||||
|
const backgroundImagePath = String(template.background_image_path || '').trim();
|
||||||
|
if (!backgroundImagePath.startsWith('/uploads/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
|
||||||
|
[backgroundImagePath.replace(/^\/uploads\//, '/media/'), template.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveFileIfMissing(sourcePath, targetPath) {
|
||||||
|
try {
|
||||||
|
await fs.promises.access(targetPath, fs.constants.F_OK);
|
||||||
|
return false;
|
||||||
|
} catch (_error) {
|
||||||
|
// target does not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.access(sourcePath, fs.constants.F_OK);
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(sourcePath, targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'EXDEV') {
|
||||||
|
await fs.promises.copyFile(sourcePath, targetPath);
|
||||||
|
await fs.promises.unlink(sourcePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillLegacyMediaUploadsToSubfolder(pool, mediaDir) {
|
||||||
|
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
|
||||||
|
if (!normalizedMediaDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
|
||||||
|
await fs.promises.mkdir(uploadsDir, { recursive: true });
|
||||||
|
|
||||||
|
const [slides] = await pool.query(`
|
||||||
|
SELECT id, media_path, content_json
|
||||||
|
FROM slides
|
||||||
|
WHERE media_path LIKE '/media/%'
|
||||||
|
OR content_json LIKE '%/media/%'
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const slide of slides || []) {
|
||||||
|
let mediaPath = String(slide.media_path || '').trim() || null;
|
||||||
|
let contentJson = slide.content_json;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
|
||||||
|
const fileName = path.basename(mediaPath);
|
||||||
|
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
|
||||||
|
mediaPath = '/media/uploads/' + fileName;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContent = parseJsonValue(contentJson);
|
||||||
|
if (parsedContent && typeof parsedContent === 'object') {
|
||||||
|
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||||
|
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
|
||||||
|
contentJson = JSON.stringify(updatedContent);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
|
||||||
|
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||||
|
if (updatedContent !== parsedContent) {
|
||||||
|
contentJson = updatedContent;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [templates] = await pool.query(`
|
||||||
|
SELECT id, background_image_path
|
||||||
|
FROM slide_templates
|
||||||
|
WHERE background_image_path LIKE '/media/%'
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const template of templates || []) {
|
||||||
|
const backgroundImagePath = String(template.background_image_path || '').trim();
|
||||||
|
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = path.basename(backgroundImagePath);
|
||||||
|
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
|
||||||
|
['/media/uploads/' + fileName, template.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillLooseMediaFilesToUploadsSubfolder(pool, mediaDir) {
|
||||||
|
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
|
||||||
|
if (!normalizedMediaDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
|
||||||
|
await fs.promises.mkdir(uploadsDir, { recursive: true });
|
||||||
|
|
||||||
|
const directoryEntries = await fs.promises.readdir(normalizedMediaDir, { withFileTypes: true });
|
||||||
|
for (const entry of directoryEntries || []) {
|
||||||
|
if (!entry || !entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = String(entry.name || '').trim();
|
||||||
|
if (!fileName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = path.join(normalizedMediaDir, fileName);
|
||||||
|
const targetPath = path.join(uploadsDir, fileName);
|
||||||
|
await moveFileIfMissing(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [slides] = await pool.query(`
|
||||||
|
SELECT id, media_path, content_json
|
||||||
|
FROM slides
|
||||||
|
WHERE media_path LIKE '/media/%'
|
||||||
|
OR content_json LIKE '%/media/%'
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const slide of slides || []) {
|
||||||
|
let mediaPath = String(slide.media_path || '').trim() || null;
|
||||||
|
let contentJson = slide.content_json;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
|
||||||
|
mediaPath = '/media/uploads/' + path.basename(mediaPath);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContent = parseJsonValue(contentJson);
|
||||||
|
if (parsedContent && typeof parsedContent === 'object') {
|
||||||
|
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||||
|
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
|
||||||
|
contentJson = JSON.stringify(updatedContent);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
|
||||||
|
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||||
|
if (updatedContent !== parsedContent) {
|
||||||
|
contentJson = updatedContent;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [templates] = await pool.query(`
|
||||||
|
SELECT id, background_image_path
|
||||||
|
FROM slide_templates
|
||||||
|
WHERE background_image_path LIKE '/media/%'
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const template of templates || []) {
|
||||||
|
const backgroundImagePath = String(template.background_image_path || '').trim();
|
||||||
|
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = path.basename(backgroundImagePath);
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
|
||||||
|
['/media/uploads/' + fileName, template.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureMigrationTable(pool) {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
migration_key VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
app_version VARCHAR(32) NOT NULL,
|
||||||
|
comment TEXT NOT NULL,
|
||||||
|
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAppliedMigrationRows(pool) {
|
||||||
|
await ensureMigrationTable(pool);
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT migration_key, app_version, comment, applied_at FROM schema_migrations ORDER BY id ASC'
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestAppliedVersion(rows) {
|
||||||
|
let latestVersion = '0.0.0';
|
||||||
|
|
||||||
|
for (const row of rows || []) {
|
||||||
|
const candidateVersion = String(row.app_version || '0.0.0');
|
||||||
|
if (compareVersions(candidateVersion, latestVersion) > 0) {
|
||||||
|
latestVersion = candidateVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordMigration(pool, migration) {
|
||||||
|
await pool.query(
|
||||||
|
'INSERT IGNORE INTO schema_migrations (migration_key, app_version, comment) VALUES (?, ?, ?)',
|
||||||
|
[migration.key, migration.version, migration.comment]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrations = [
|
||||||
|
{
|
||||||
|
key: 'interval-value-rename',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Rename RSS and API refresh interval columns to update_interval_value so seconds and minutes share one neutral numeric field.',
|
||||||
|
order: 10,
|
||||||
|
up: async function (pool) {
|
||||||
|
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||||
|
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
|
||||||
|
|
||||||
|
if (await hasColumn(pool, 'rss_feeds', 'update_interval_minutes')) {
|
||||||
|
await pool.query(`
|
||||||
|
UPDATE rss_feeds
|
||||||
|
SET update_interval_value = update_interval_minutes
|
||||||
|
`);
|
||||||
|
await dropColumnIfPresent(pool, 'rss_feeds', 'update_interval_minutes');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await hasColumn(pool, 'api_sources', 'update_interval_minutes')) {
|
||||||
|
await pool.query(`
|
||||||
|
UPDATE api_sources
|
||||||
|
SET update_interval_value = update_interval_minutes
|
||||||
|
`);
|
||||||
|
await dropColumnIfPresent(pool, 'api_sources', 'update_interval_minutes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'schema-columns-current',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Backfill the released schema columns for older databases.',
|
||||||
|
order: 20,
|
||||||
|
up: async function (pool) {
|
||||||
|
// Current released schema: keep canvas_sizes audit fields available in older databases.
|
||||||
|
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'canvas_sizes');
|
||||||
|
|
||||||
|
// Current released schema: ensure playlists carry the playback and audit fields.
|
||||||
|
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'playlists');
|
||||||
|
|
||||||
|
// Current released schema: keep slide template canvas and background fields available.
|
||||||
|
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'slide_templates');
|
||||||
|
await backfillLegacySlideTemplateCanvasSize(pool);
|
||||||
|
|
||||||
|
// Current released schema: keep slide template region typography and audit fields available.
|
||||||
|
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'slide_template_regions');
|
||||||
|
|
||||||
|
// Current released schema: keep structured slide content and media fields available.
|
||||||
|
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'slides');
|
||||||
|
|
||||||
|
// Current released schema: keep playlist slide duration, schedule, and audit fields available.
|
||||||
|
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'DECIMAL(10,3) NOT NULL DEFAULT 10.000');
|
||||||
|
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
|
||||||
|
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
|
||||||
|
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
|
||||||
|
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_time', 'TIME NULL');
|
||||||
|
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL');
|
||||||
|
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL');
|
||||||
|
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'playlist_slides');
|
||||||
|
|
||||||
|
// Current released schema: keep screen playlist bindings and audit fields available.
|
||||||
|
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'screens');
|
||||||
|
|
||||||
|
// Current released schema: keep RSS feed interval and audit fields available.
|
||||||
|
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||||
|
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
|
||||||
|
await addColumnIfMissing(pool, 'rss_feeds', 'item_limit', 'INT NOT NULL DEFAULT 1');
|
||||||
|
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'rss_feeds');
|
||||||
|
|
||||||
|
// Current released schema: keep normalized RSS feed item snapshots available.
|
||||||
|
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await backfillLegacyRssFeedItemJson(pool);
|
||||||
|
|
||||||
|
// Current released schema: keep API source polling and snapshot fields available.
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'last_pulled_at', 'TIMESTAMP NULL');
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'last_pull_error', 'MEDIUMTEXT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'last_response_status', 'INT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'last_response_content_type', 'VARCHAR(255) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'last_response_json', 'MEDIUMTEXT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'api_sources');
|
||||||
|
|
||||||
|
// Current released schema: keep onboarding device and RBAC audit fields available.
|
||||||
|
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'player_onboarding_devices');
|
||||||
|
|
||||||
|
await addColumnIfMissing(pool, 'users', 'name', 'VARCHAR(255) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'users', 'password_hash', 'CHAR(64) NOT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'users');
|
||||||
|
|
||||||
|
await addColumnIfMissing(pool, 'roles', 'role_key', 'VARCHAR(100) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'roles', 'description', 'TEXT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'roles');
|
||||||
|
|
||||||
|
await addColumnIfMissing(pool, 'permissions', 'permission_key', 'VARCHAR(100) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'permissions', 'name', 'VARCHAR(255) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'permissions', 'section_name', 'VARCHAR(255) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'permissions', 'description', 'TEXT NULL');
|
||||||
|
await addColumnIfMissing(pool, 'permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'permissions');
|
||||||
|
await dedupePermissionRows(pool);
|
||||||
|
await addUniqueIndexIfMissing(pool, 'permissions', 'permission_key', 'uq_permissions_permission_key');
|
||||||
|
|
||||||
|
await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'role_permissions');
|
||||||
|
|
||||||
|
await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
|
await addAuditColumns(pool, 'user_roles');
|
||||||
|
|
||||||
|
await addAuditColumns(pool, 'auth_sessions');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'playlist-skip-unavailable-rtmp',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Add the playlist flag that skips RTMP slides when streams are unavailable.',
|
||||||
|
order: 21,
|
||||||
|
up: async function (pool) {
|
||||||
|
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'slides-thumbnail-path-column',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Backfill the slides.thumbnail_path column for databases that already recorded the broader schema migration.',
|
||||||
|
order: 22,
|
||||||
|
up: async function (pool) {
|
||||||
|
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'playlist-slide-use-video-duration-flag',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Add the playlist slide flag that follows the current video duration.',
|
||||||
|
order: 23,
|
||||||
|
up: async function (pool) {
|
||||||
|
await addColumnIfMissing(pool, 'playlist_slides', 'use_video_duration', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'playlist-slide-duration-decimals',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Widen playlist slide durations to preserve fractional seconds.',
|
||||||
|
order: 24,
|
||||||
|
up: async function (pool) {
|
||||||
|
await pool.query('ALTER TABLE playlist_slides MODIFY duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'media-path-prefix-rename',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Rename stored media URLs from /uploads to /media so existing slides and templates keep working after the storage root move.',
|
||||||
|
order: 25,
|
||||||
|
up: async function (pool) {
|
||||||
|
await backfillLegacyMediaPaths(pool);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'media-upload-subfolder-move',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Move existing upload files and references from the media root into media/uploads.',
|
||||||
|
order: 26,
|
||||||
|
up: async function (pool, options) {
|
||||||
|
await backfillLegacyMediaUploadsToSubfolder(pool, options && options.mediaDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'media-upload-subfolder-rescue',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Rescan loose media files and promote them into media/uploads.',
|
||||||
|
order: 27,
|
||||||
|
up: async function (pool, options) {
|
||||||
|
await backfillLooseMediaFilesToUploadsSubfolder(pool, options && options.mediaDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'background-tasks-table',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Persist background tasks so queued work survives a web restart.',
|
||||||
|
order: 30,
|
||||||
|
up: async function (pool) {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS background_tasks (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
task_key VARCHAR(191) NULL,
|
||||||
|
task_type VARCHAR(100) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
category VARCHAR(100) NOT NULL DEFAULT 'general',
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
payload_json MEDIUMTEXT NULL,
|
||||||
|
metadata_json MEDIUMTEXT NULL,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
started_at TIMESTAMP NULL,
|
||||||
|
finished_at TIMESTAMP NULL,
|
||||||
|
error_message MEDIUMTEXT NULL,
|
||||||
|
INDEX idx_background_tasks_status (status),
|
||||||
|
INDEX idx_background_tasks_key (task_key),
|
||||||
|
INDEX idx_background_tasks_type (task_type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function runMigrations(pool, options) {
|
||||||
|
const appliedRows = await getAppliedMigrationRows(pool);
|
||||||
|
const appliedKeys = new Set((appliedRows || []).map(function (row) {
|
||||||
|
return String(row.migration_key || '').trim();
|
||||||
|
}).filter(Boolean));
|
||||||
|
|
||||||
|
const pendingMigrations = migrations
|
||||||
|
.filter(function (migration) {
|
||||||
|
return compareVersions(migration.version, appVersion) <= 0
|
||||||
|
&& !appliedKeys.has(migration.key);
|
||||||
|
})
|
||||||
|
.sort(function (left, right) {
|
||||||
|
const versionOrder = compareVersions(left.version, right.version);
|
||||||
|
if (versionOrder !== 0) {
|
||||||
|
return versionOrder;
|
||||||
|
}
|
||||||
|
return Number(left.order || 0) - Number(right.order || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const migration of pendingMigrations) {
|
||||||
|
await migration.up(pool, options || {});
|
||||||
|
await recordMigration(pool, migration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
appVersion: appVersion,
|
||||||
|
compareVersions: compareVersions,
|
||||||
|
runMigrations: runMigrations
|
||||||
|
};
|
||||||
+71
-536
@@ -1,435 +1,57 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const crypto = require('crypto');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { WebSocketServer, WebSocket } = require('ws');
|
|
||||||
const common = require('./common');
|
const common = require('./common');
|
||||||
|
const { createPlayerRuntime } = require('./player/runtime');
|
||||||
async function buildScreenPlaylist(pool, slug) {
|
const { createPlayerPlaylistService } = require('./player/playlist');
|
||||||
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
|
const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
|
||||||
if (!screenRows.length) {
|
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
|
||||||
return { screen: null, playlist: null, slides: [] };
|
const { createOnboardingStore } = require('./player/onboarding/store');
|
||||||
}
|
const { registerPlayerRoutes } = require('./player/routes');
|
||||||
|
const { pruneStaleOnboardingDevices } = require('./db');
|
||||||
const screen = screenRows[0];
|
|
||||||
if (!screen.playlist_id) {
|
|
||||||
return {
|
|
||||||
screen,
|
|
||||||
playlist: null,
|
|
||||||
slides: [],
|
|
||||||
revision: getPlaylistRevision(screen, null, [], [], [])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
|
|
||||||
const playlist = playlistRows[0] || null;
|
|
||||||
const [slideRows] = await pool.query(`
|
|
||||||
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
|
|
||||||
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
|
||||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
|
||||||
FROM playlist_slides ps
|
|
||||||
JOIN slides sl ON sl.id = ps.slide_id
|
|
||||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
|
||||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
|
||||||
WHERE ps.playlist_id = ?
|
|
||||||
ORDER BY ps.position ASC, ps.id ASC
|
|
||||||
`, [screen.playlist_id]);
|
|
||||||
|
|
||||||
const templateIds = slideRows
|
|
||||||
.filter(function (slide) { return slide.template_id; })
|
|
||||||
.map(function (slide) { return slide.template_id; });
|
|
||||||
const templatesById = {};
|
|
||||||
let templateRows = [];
|
|
||||||
let regionRows = [];
|
|
||||||
if (templateIds.length) {
|
|
||||||
[templateRows] = await pool.query(`
|
|
||||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
|
||||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
|
||||||
FROM slide_templates st
|
|
||||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
|
||||||
WHERE st.id IN (?)
|
|
||||||
`, [templateIds]);
|
|
||||||
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
|
|
||||||
templateRows.forEach(function (template) {
|
|
||||||
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
|
|
||||||
templatesById[template.id] = template;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const slides = slideRows.map(function (slide) {
|
|
||||||
return {
|
|
||||||
id: slide.id,
|
|
||||||
title: slide.title,
|
|
||||||
body: slide.body,
|
|
||||||
duration_seconds: slide.duration_seconds,
|
|
||||||
schedule_mode: slide.schedule_mode,
|
|
||||||
schedule_start_datetime: slide.schedule_start_datetime,
|
|
||||||
schedule_end_datetime: slide.schedule_end_datetime,
|
|
||||||
schedule_start_time: slide.schedule_start_time,
|
|
||||||
schedule_end_time: slide.schedule_end_time,
|
|
||||||
schedule_days_json: slide.schedule_days_json,
|
|
||||||
media_url: slide.media_path,
|
|
||||||
media_type: slide.media_type,
|
|
||||||
kind: common.mediaKind(slide.media_path),
|
|
||||||
template_id: slide.template_id,
|
|
||||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
|
||||||
content: common.parseJsonSafe(slide.content_json) || {}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows);
|
|
||||||
|
|
||||||
return { screen, playlist, slides, revision };
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePlaylistRevisionHash(hash, value) {
|
|
||||||
hash.update(String(value === null || value === undefined ? '' : value));
|
|
||||||
hash.update('\0');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows) {
|
|
||||||
const hash = crypto.createHash('sha1');
|
|
||||||
|
|
||||||
updatePlaylistRevisionHash(hash, screen && screen.id);
|
|
||||||
updatePlaylistRevisionHash(hash, screen && screen.playlist_id);
|
|
||||||
updatePlaylistRevisionHash(hash, screen && screen.modified_at);
|
|
||||||
|
|
||||||
updatePlaylistRevisionHash(hash, playlist && playlist.id);
|
|
||||||
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
|
|
||||||
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
|
|
||||||
|
|
||||||
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
|
|
||||||
updatePlaylistRevisionHash(hash, slide.id);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.title);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.body);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.template_id);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.content_json);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.media_path);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.media_type);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.modified_at);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.position);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_end_time);
|
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_days_json);
|
|
||||||
});
|
|
||||||
|
|
||||||
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
|
|
||||||
updatePlaylistRevisionHash(hash, template.id);
|
|
||||||
updatePlaylistRevisionHash(hash, template.name);
|
|
||||||
updatePlaylistRevisionHash(hash, template.canvas_size_id);
|
|
||||||
updatePlaylistRevisionHash(hash, template.canvas_size_width);
|
|
||||||
updatePlaylistRevisionHash(hash, template.canvas_size_height);
|
|
||||||
updatePlaylistRevisionHash(hash, template.background_image_path);
|
|
||||||
updatePlaylistRevisionHash(hash, template.modified_at);
|
|
||||||
});
|
|
||||||
|
|
||||||
(Array.isArray(regionRows) ? regionRows : []).forEach(function (region) {
|
|
||||||
updatePlaylistRevisionHash(hash, region.id);
|
|
||||||
updatePlaylistRevisionHash(hash, region.template_id);
|
|
||||||
updatePlaylistRevisionHash(hash, region.region_key);
|
|
||||||
updatePlaylistRevisionHash(hash, region.region_type);
|
|
||||||
updatePlaylistRevisionHash(hash, region.label);
|
|
||||||
updatePlaylistRevisionHash(hash, region.font_family);
|
|
||||||
updatePlaylistRevisionHash(hash, region.x);
|
|
||||||
updatePlaylistRevisionHash(hash, region.y);
|
|
||||||
updatePlaylistRevisionHash(hash, region.width);
|
|
||||||
updatePlaylistRevisionHash(hash, region.height);
|
|
||||||
updatePlaylistRevisionHash(hash, region.z_index);
|
|
||||||
updatePlaylistRevisionHash(hash, region.modified_at);
|
|
||||||
});
|
|
||||||
|
|
||||||
return hash.digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
// Player runtime, media API, and websocket wiring.
|
||||||
async function start() {
|
async function start() {
|
||||||
const app = express();
|
const app = express();
|
||||||
const pool = common.createPool();
|
const pool = common.createPool();
|
||||||
const PORT = Number(process.env.PLAYER_PORT || 3001);
|
const PORT = Number(process.env.PLAYER_PORT || 3001);
|
||||||
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
|
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||||
const connectionsBySlug = new Map();
|
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
|
||||||
const dashboardListenersBySlug = new Map();
|
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
|
||||||
|
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
|
||||||
|
const playerRuntime = createPlayerRuntime({
|
||||||
|
pool: pool,
|
||||||
|
normalizeDeviceId: normalizeDeviceId
|
||||||
|
});
|
||||||
|
const playerPlaylistService = createPlayerPlaylistService({
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
|
||||||
|
});
|
||||||
|
const rtmpStreamService = createRtmpStreamService({
|
||||||
|
mediaDir: MEDIA_DIR
|
||||||
|
});
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
playerRuntime.installWebsocket(server);
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
registerPlayerOnboardingRoutes(app, {
|
||||||
app.use('/assets', express.static(ASSET_DIR));
|
pool: pool,
|
||||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
common: common,
|
||||||
|
playerRuntime: playerRuntime,
|
||||||
function getConnectionBucket(slug) {
|
onboardingStore: onboardingStore,
|
||||||
const key = String(slug || '').trim();
|
QRCode: require('qrcode')
|
||||||
if (!key) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!connectionsBySlug.has(key)) {
|
|
||||||
connectionsBySlug.set(key, new Map());
|
|
||||||
}
|
|
||||||
return connectionsBySlug.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeConnection(slug, connectionId) {
|
|
||||||
const bucket = connectionsBySlug.get(slug);
|
|
||||||
if (!bucket) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bucket.delete(connectionId);
|
|
||||||
if (!bucket.size) {
|
|
||||||
connectionsBySlug.delete(slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDashboardListenerBucket(slug) {
|
|
||||||
const key = String(slug || '').trim();
|
|
||||||
if (!key) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!dashboardListenersBySlug.has(key)) {
|
|
||||||
dashboardListenersBySlug.set(key, new Set());
|
|
||||||
}
|
|
||||||
return dashboardListenersBySlug.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeDashboardListener(slug, socket) {
|
|
||||||
const key = String(slug || '').trim();
|
|
||||||
const bucket = dashboardListenersBySlug.get(key);
|
|
||||||
if (!bucket) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bucket.delete(socket);
|
|
||||||
if (!bucket.size) {
|
|
||||||
dashboardListenersBySlug.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildClientLabel(connection) {
|
|
||||||
const clientId = String(connection.clientId || '').trim();
|
|
||||||
const userAgent = String(connection.userAgent || '').trim();
|
|
||||||
const clientIp = String(connection.clientIp || '').trim();
|
|
||||||
const viewport = connection.viewport && typeof connection.viewport === 'object'
|
|
||||||
? connection.viewport
|
|
||||||
: null;
|
|
||||||
const labelParts = [];
|
|
||||||
|
|
||||||
if (userAgent) {
|
|
||||||
labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientId) {
|
|
||||||
labelParts.push(`id ${clientId.slice(-6)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientIp) {
|
|
||||||
labelParts.push(clientIp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
|
|
||||||
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!labelParts.length) {
|
|
||||||
return connection.remoteAddress || 'connected client';
|
|
||||||
}
|
|
||||||
|
|
||||||
return labelParts.join(' • ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotConnections(slug) {
|
|
||||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
|
||||||
if (!bucket) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(bucket.values()).map(function (connection) {
|
|
||||||
return {
|
|
||||||
id: connection.id,
|
|
||||||
clientId: connection.clientId || null,
|
|
||||||
label: connection.label,
|
|
||||||
userAgent: connection.userAgent || null,
|
|
||||||
viewport: connection.viewport || null,
|
|
||||||
page: connection.page || null,
|
|
||||||
currentSlide: connection.currentSlide || null,
|
|
||||||
paused: Boolean(connection.paused),
|
|
||||||
blackout: Boolean(connection.blackout),
|
|
||||||
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
|
|
||||||
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null,
|
|
||||||
clientIp: connection.clientIp || null,
|
|
||||||
remoteAddress: connection.remoteAddress || null,
|
|
||||||
connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
|
|
||||||
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastConnectionSnapshot(slug) {
|
|
||||||
const key = String(slug || '').trim();
|
|
||||||
const bucket = dashboardListenersBySlug.get(key);
|
|
||||||
if (!bucket || !bucket.size) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
type: 'snapshot',
|
|
||||||
slug: key,
|
|
||||||
connections: snapshotConnections(slug),
|
|
||||||
sentAt: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
bucket.forEach(function (socket) {
|
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(payload);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendCommandToConnection(slug, connectionId, commandOrPayload) {
|
|
||||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
|
||||||
if (!bucket || !bucket.size) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = bucket.get(String(connectionId || '').trim());
|
|
||||||
if (!target || target.socket.readyState !== WebSocket.OPEN) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
|
||||||
? Object.assign({}, commandOrPayload)
|
|
||||||
: { command: commandOrPayload };
|
|
||||||
payload.type = 'command';
|
|
||||||
payload.targetConnectionId = target.id;
|
|
||||||
payload.sentAt = new Date().toISOString();
|
|
||||||
|
|
||||||
target.socket.send(JSON.stringify(payload));
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastCommand(slug, commandOrPayload) {
|
|
||||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
|
||||||
if (!bucket || !bucket.size) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sent = 0;
|
|
||||||
bucket.forEach(function (connection) {
|
|
||||||
if (connection.socket.readyState !== WebSocket.OPEN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
|
||||||
? Object.assign({}, commandOrPayload)
|
|
||||||
: { command: commandOrPayload };
|
|
||||||
payload.type = 'command';
|
|
||||||
payload.sentAt = new Date().toISOString();
|
|
||||||
|
|
||||||
connection.socket.send(JSON.stringify(payload));
|
|
||||||
sent += 1;
|
|
||||||
});
|
|
||||||
return sent;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/', function (_req, res) {
|
|
||||||
res.send('Pulse Signage player service');
|
|
||||||
});
|
});
|
||||||
|
registerPlayerRoutes(app, {
|
||||||
app.get('/screen/:slug', function (req, res) {
|
pool: pool,
|
||||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
common: common,
|
||||||
res.set('Pragma', 'no-cache');
|
mediaDir: MEDIA_DIR,
|
||||||
buildScreenPlaylist(pool, req.params.slug).then(function (data) {
|
assetDir: ASSET_DIR,
|
||||||
res.send(common.renderPlayerPage(req.params.slug, data));
|
playerRuntime: playerRuntime,
|
||||||
}).catch(function (error) {
|
playerPlaylistService: playerPlaylistService,
|
||||||
console.error(error);
|
rtmpStreamService: rtmpStreamService
|
||||||
res.status(500).send('Internal server error');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/screens/:slug/playlist', async function (req, res, next) {
|
|
||||||
try {
|
|
||||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
||||||
const data = await buildScreenPlaylist(pool, req.params.slug);
|
|
||||||
if (!data.screen) {
|
|
||||||
return res.status(404).json({ error: 'Screen not found' });
|
|
||||||
}
|
|
||||||
const etag = '"' + String(data.revision || '') + '"';
|
|
||||||
res.set('ETag', etag);
|
|
||||||
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
|
|
||||||
return String(value || '').trim();
|
|
||||||
}).includes(etag)) {
|
|
||||||
return res.status(304).end();
|
|
||||||
}
|
|
||||||
res.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) {
|
|
||||||
try {
|
|
||||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
|
||||||
if (!screenRows.length) {
|
|
||||||
return res.status(404).json({ error: 'Screen not found' });
|
|
||||||
}
|
|
||||||
const connections = snapshotConnections(req.params.slug);
|
|
||||||
res.json({
|
|
||||||
screen: screenRows[0],
|
|
||||||
screenSlug: req.params.slug,
|
|
||||||
count: connections.length,
|
|
||||||
connections: connections
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/screens/:slug/commands', async function (req, res, next) {
|
|
||||||
try {
|
|
||||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
|
||||||
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
|
|
||||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
|
||||||
? req.body.blackout
|
|
||||||
: req.query.blackout;
|
|
||||||
if (!command) {
|
|
||||||
return res.status(400).json({ error: 'Command is required' });
|
|
||||||
}
|
|
||||||
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) {
|
|
||||||
return res.status(400).json({ error: 'Unsupported command' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRedirectCommand = command === 'redirect';
|
|
||||||
let screenRows = [];
|
|
||||||
if (!isRedirectCommand) {
|
|
||||||
[screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
|
||||||
if (!screenRows.length) {
|
|
||||||
return res.status(404).json({ error: 'Screen not found' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
|
||||||
? Object.assign({}, req.body, { command: command })
|
|
||||||
: command;
|
|
||||||
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
|
||||||
commandPayload.blackout = blackoutValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sent = connectionId
|
|
||||||
? sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
|
||||||
: broadcastCommand(req.params.slug, commandPayload);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
screen: screenRows[0] || null,
|
|
||||||
screenSlug: req.params.slug,
|
|
||||||
command: command,
|
|
||||||
connectionId: connectionId || null,
|
|
||||||
sent: sent
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(function (error, _req, res, _next) {
|
app.use(function (error, _req, res, _next) {
|
||||||
@@ -437,129 +59,41 @@ async function start() {
|
|||||||
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
|
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
|
||||||
});
|
});
|
||||||
|
|
||||||
await common.ensureSchema(pool);
|
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
||||||
server.on('upgrade', function (request, socket, head) {
|
|
||||||
let pathname = '';
|
|
||||||
try {
|
|
||||||
pathname = new URL(request.url, 'http://localhost').pathname;
|
|
||||||
} catch (_error) {
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
|
|
||||||
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
|
|
||||||
if (!dashboardMatch && !playerMatch) {
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]);
|
|
||||||
wss.handleUpgrade(request, socket, head, function (ws) {
|
|
||||||
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
wss.on('connection', function (socket, request, slug, role) {
|
|
||||||
if (role === 'dashboard') {
|
|
||||||
const listenerBucket = getDashboardListenerBucket(slug);
|
|
||||||
if (!listenerBucket) {
|
|
||||||
socket.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
listenerBucket.add(socket);
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
type: 'snapshot',
|
|
||||||
slug: String(slug || '').trim(),
|
|
||||||
connections: snapshotConnections(slug),
|
|
||||||
sentAt: new Date().toISOString()
|
|
||||||
}));
|
|
||||||
|
|
||||||
socket.on('close', function () {
|
|
||||||
removeDashboardListener(slug, socket);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', function () {
|
|
||||||
removeDashboardListener(slug, socket);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
|
|
||||||
const forwardedFor = String(request.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
|
||||||
const connectionId = crypto.randomUUID();
|
|
||||||
const connection = {
|
|
||||||
id: connectionId,
|
|
||||||
slug: slug,
|
|
||||||
socket: socket,
|
|
||||||
clientId: null,
|
|
||||||
userAgent: null,
|
|
||||||
viewport: null,
|
|
||||||
page: null,
|
|
||||||
paused: false,
|
|
||||||
blackout: false,
|
|
||||||
clientIp: forwardedFor || remoteAddress,
|
|
||||||
remoteAddress: remoteAddress,
|
|
||||||
label: forwardedFor || remoteAddress || 'connected client',
|
|
||||||
connectedAt: new Date(),
|
|
||||||
lastSeenAt: new Date()
|
|
||||||
};
|
|
||||||
const bucket = getConnectionBucket(slug);
|
|
||||||
|
|
||||||
if (!bucket) {
|
|
||||||
socket.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket.set(connectionId, connection);
|
|
||||||
|
|
||||||
socket.on('message', function (rawMessage) {
|
|
||||||
connection.lastSeenAt = new Date();
|
|
||||||
let payload = null;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(String(rawMessage || ''));
|
|
||||||
} catch (_error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
|
|
||||||
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
|
|
||||||
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
|
|
||||||
connection.page = payload.page ? String(payload.page).trim() : connection.page;
|
|
||||||
connection.paused = Boolean(payload.paused);
|
|
||||||
connection.blackout = Boolean(payload.blackout);
|
|
||||||
connection.clientIp = payload.clientIp ? String(payload.clientIp).trim() : connection.clientIp;
|
|
||||||
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
|
|
||||||
id: payload.currentSlide.id || null,
|
|
||||||
title: payload.currentSlide.title || '',
|
|
||||||
kind: payload.currentSlide.kind || '',
|
|
||||||
playlistSignature: payload.currentSlide.playlistSignature || ''
|
|
||||||
} : connection.currentSlide;
|
|
||||||
connection.label = buildClientLabel(connection);
|
|
||||||
connection.lastSeenAt = new Date();
|
|
||||||
broadcastConnectionSnapshot(slug);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', function () {
|
|
||||||
removeConnection(slug, connectionId);
|
|
||||||
broadcastConnectionSnapshot(slug);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', function () {
|
|
||||||
removeConnection(slug, connectionId);
|
|
||||||
broadcastConnectionSnapshot(slug);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(PORT, function () {
|
server.listen(PORT, function () {
|
||||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function syncDatabaseState() {
|
||||||
|
try {
|
||||||
|
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||||
|
|
||||||
|
if (playerRuntime.snapshotAllConnections().length > 0) {
|
||||||
|
await pruneStaleOnboardingDevices(pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
await onboardingStore.flushBindings(function (entry) {
|
||||||
|
return commitDeviceBinding(
|
||||||
|
pool,
|
||||||
|
entry.deviceId,
|
||||||
|
entry.clientName,
|
||||||
|
entry.screenSlug,
|
||||||
|
playerRuntime.isClientNameAvailableOnScreen,
|
||||||
|
playerRuntime.snapshotAllConnections()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncDatabaseState();
|
||||||
|
setInterval(function () {
|
||||||
|
syncDatabaseState().catch(function (error) {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}, DB_SYNC_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { start };
|
module.exports = { start };
|
||||||
@@ -570,3 +104,4 @@ if (require.main === module) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,452 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
function createRtmpStreamService(options) {
|
||||||
|
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
|
||||||
|
const ffmpegPath = options && options.ffmpegPath ? options.ffmpegPath : 'ffmpeg';
|
||||||
|
const ffprobePath = options && options.ffprobePath ? options.ffprobePath : 'ffprobe';
|
||||||
|
const probeIntervalMs = options && options.probeIntervalMs ? Number(options.probeIntervalMs) : 2000;
|
||||||
|
const sessions = new Map();
|
||||||
|
const watchedSources = new Set();
|
||||||
|
const sourceStatusCache = new Map();
|
||||||
|
const sourceProbePromises = new Map();
|
||||||
|
let probeTimer = null;
|
||||||
|
|
||||||
|
if (!mediaDir) {
|
||||||
|
throw new Error('mediaDir is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheRoot = path.join(mediaDir, 'rtmp-cache');
|
||||||
|
|
||||||
|
function normalizeSourceUrl(value) {
|
||||||
|
const sourceUrl = String(value || '').trim();
|
||||||
|
if (!sourceUrl || !/^rtmps?:\/\//i.test(sourceUrl)) {
|
||||||
|
const error = new Error('A valid RTMP URL is required.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return sourceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionKey(sourceUrl, disableAudio) {
|
||||||
|
return crypto.createHash('sha1').update(String(sourceUrl)).update('\0').update(disableAudio ? '1' : '0').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDirectory(dirPath) {
|
||||||
|
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFile(filePath, timeoutMs) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(filePath);
|
||||||
|
if (stat.isFile() && stat.size > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// keep waiting
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command, args, timeoutMs) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var child = spawn(command, args, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
var stdout = '';
|
||||||
|
var stderr = '';
|
||||||
|
var finished = false;
|
||||||
|
var timer = null;
|
||||||
|
|
||||||
|
function done(result) {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finished = true;
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.on('data', function (chunk) {
|
||||||
|
stdout += String(chunk || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', function (chunk) {
|
||||||
|
stderr += String(chunk || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', function (error) {
|
||||||
|
done({ ok: false, error: error, stdout: stdout, stderr: stderr, timedOut: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', function (code, signal) {
|
||||||
|
done({ ok: code === 0, code: code, signal: signal, stdout: stdout, stderr: stderr, timedOut: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
timer = setTimeout(function () {
|
||||||
|
try {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore timeout cleanup errors
|
||||||
|
}
|
||||||
|
done({ ok: false, code: null, signal: 'SIGKILL', stdout: stdout, stderr: stderr, timedOut: true });
|
||||||
|
}, Math.max(1000, Number(timeoutMs || 0) || 4000));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeSourceUrl(sourceUrl) {
|
||||||
|
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||||
|
const result = await runCommand(ffprobePath, [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'error',
|
||||||
|
'-rw_timeout', '3000000',
|
||||||
|
'-show_streams',
|
||||||
|
'-of', 'json',
|
||||||
|
normalizedSource
|
||||||
|
], 4000);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
live: false,
|
||||||
|
timedOut: Boolean(result.timedOut),
|
||||||
|
stderr: String(result.stderr || '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(result.stdout || '{}'));
|
||||||
|
const streams = Array.isArray(parsed && parsed.streams) ? parsed.streams : [];
|
||||||
|
return {
|
||||||
|
live: streams.length > 0,
|
||||||
|
timedOut: false,
|
||||||
|
stderr: String(result.stderr || '').trim()
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
return {
|
||||||
|
live: false,
|
||||||
|
timedOut: false,
|
||||||
|
stderr: String(result.stderr || '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceStatus(sourceUrl) {
|
||||||
|
return sourceStatusCache.get(String(sourceUrl || '').trim()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSourceStatus(sourceUrl, nextStatus) {
|
||||||
|
const normalizedSource = String(sourceUrl || '').trim();
|
||||||
|
if (!normalizedSource) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const status = Object.assign({
|
||||||
|
live: false,
|
||||||
|
probing: false,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
timedOut: false,
|
||||||
|
stderr: ''
|
||||||
|
}, nextStatus || {});
|
||||||
|
status.live = Boolean(status.live);
|
||||||
|
status.probing = Boolean(status.probing);
|
||||||
|
status.checkedAt = Number(status.checkedAt || Date.now());
|
||||||
|
sourceStatusCache.set(normalizedSource, status);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSourceWatch(sourceUrl) {
|
||||||
|
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||||
|
watchedSources.add(normalizedSource);
|
||||||
|
return normalizedSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSourceStatus(sourceUrl) {
|
||||||
|
const normalizedSource = registerSourceWatch(sourceUrl);
|
||||||
|
const existingPromise = sourceProbePromises.get(normalizedSource) || null;
|
||||||
|
if (existingPromise) {
|
||||||
|
return existingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceStatus(normalizedSource, Object.assign({}, getSourceStatus(normalizedSource) || {}, {
|
||||||
|
probing: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
const probePromise = probeSourceUrl(normalizedSource).then(function (probe) {
|
||||||
|
return setSourceStatus(normalizedSource, {
|
||||||
|
live: Boolean(probe.live),
|
||||||
|
probing: false,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
timedOut: Boolean(probe.timedOut),
|
||||||
|
stderr: String(probe.stderr || '')
|
||||||
|
});
|
||||||
|
}).catch(function (error) {
|
||||||
|
return setSourceStatus(normalizedSource, {
|
||||||
|
live: false,
|
||||||
|
probing: false,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
timedOut: false,
|
||||||
|
stderr: String(error && error.message ? error.message : '')
|
||||||
|
});
|
||||||
|
}).finally(function () {
|
||||||
|
sourceProbePromises.delete(normalizedSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
sourceProbePromises.set(normalizedSource, probePromise);
|
||||||
|
return probePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSourceRefresh(sourceUrl) {
|
||||||
|
const normalizedSource = registerSourceWatch(sourceUrl);
|
||||||
|
const currentStatus = getSourceStatus(normalizedSource);
|
||||||
|
if (currentStatus && currentStatus.probing) {
|
||||||
|
return sourceProbePromises.get(normalizedSource) || Promise.resolve(currentStatus);
|
||||||
|
}
|
||||||
|
return refreshSourceStatus(normalizedSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSourceStatusFresh(status) {
|
||||||
|
if (!status || !status.checkedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Date.now() - Number(status.checkedAt || 0) < probeIntervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sweepWatchedSources() {
|
||||||
|
watchedSources.forEach(function (sourceUrl) {
|
||||||
|
const status = getSourceStatus(sourceUrl);
|
||||||
|
if (!status || !isSourceStatusFresh(status) || status.live === false) {
|
||||||
|
scheduleSourceRefresh(sourceUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probeIntervalMs > 0) {
|
||||||
|
probeTimer = setInterval(function () {
|
||||||
|
sweepWatchedSources();
|
||||||
|
}, probeIntervalMs);
|
||||||
|
if (probeTimer && typeof probeTimer.unref === 'function') {
|
||||||
|
probeTimer.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isSessionLive(session) {
|
||||||
|
if (!session || !session.process) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (session.exitCode !== undefined && session.exitCode !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (session.exitSignal !== undefined && session.exitSignal !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(session.manifestPath);
|
||||||
|
return stat.isFile() && stat.size > 0;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaylistUrl(key) {
|
||||||
|
return '/api/rtmp/streams/' + encodeURIComponent(key) + '/index.m3u8';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSession(sourceUrl, disableAudio) {
|
||||||
|
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||||
|
const normalizedDisableAudio = Boolean(disableAudio);
|
||||||
|
const key = getSessionKey(normalizedSource, normalizedDisableAudio);
|
||||||
|
const directory = path.join(cacheRoot, key);
|
||||||
|
const manifestPath = path.join(directory, 'index.m3u8');
|
||||||
|
|
||||||
|
const existingSession = sessions.get(key) || null;
|
||||||
|
if (existingSession && existingSession.process && existingSession.exitCode === null && existingSession.exitSignal === null) {
|
||||||
|
return existingSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingSession) {
|
||||||
|
sessions.delete(key);
|
||||||
|
try {
|
||||||
|
if (existingSession.process && typeof existingSession.process.kill === 'function') {
|
||||||
|
existingSession.process.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(directory, { recursive: true, force: true });
|
||||||
|
} catch (_error2) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureDirectory(directory);
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'warning',
|
||||||
|
'-nostdin',
|
||||||
|
'-i', normalizedSource,
|
||||||
|
'-fflags', '+genpts',
|
||||||
|
'-f', 'hls',
|
||||||
|
'-hls_time', '1',
|
||||||
|
'-hls_init_time', '1',
|
||||||
|
'-hls_list_size', '8',
|
||||||
|
'-hls_flags', 'delete_segments+append_list+omit_endlist+independent_segments+program_date_time',
|
||||||
|
'-hls_segment_filename', path.join(directory, 'segment-%05d.ts')
|
||||||
|
];
|
||||||
|
|
||||||
|
if (normalizedDisableAudio) {
|
||||||
|
args.push('-an');
|
||||||
|
args.push('-c:v', 'copy');
|
||||||
|
} else {
|
||||||
|
args.push('-c', 'copy');
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(manifestPath);
|
||||||
|
|
||||||
|
const child = spawn(ffmpegPath, args, {
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', function (chunk) {
|
||||||
|
const message = String(chunk || '').trim();
|
||||||
|
if (message) {
|
||||||
|
console.error('[rtmp]', message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', function (code, signal) {
|
||||||
|
const session = sessions.get(key);
|
||||||
|
if (session && session.process === child) {
|
||||||
|
session.process = null;
|
||||||
|
session.exitCode = code;
|
||||||
|
session.exitSignal = signal;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
key: key,
|
||||||
|
sourceUrl: normalizedSource,
|
||||||
|
disableAudio: normalizedDisableAudio,
|
||||||
|
directory: directory,
|
||||||
|
manifestPath: manifestPath,
|
||||||
|
playlistUrl: buildPlaylistUrl(key),
|
||||||
|
process: child,
|
||||||
|
exitCode: null,
|
||||||
|
exitSignal: null,
|
||||||
|
ready: waitForFile(manifestPath, 5000)
|
||||||
|
};
|
||||||
|
|
||||||
|
sessions.set(key, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPlaylistUrl(sourceUrl, disableAudio) {
|
||||||
|
const session = await ensureSession(sourceUrl, disableAudio);
|
||||||
|
await session.ready.catch(function () {
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return session.playlistUrl + '?t=' + Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSessionStatus(sourceUrl, disableAudio) {
|
||||||
|
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||||
|
const normalizedDisableAudio = Boolean(disableAudio);
|
||||||
|
registerSourceWatch(normalizedSource);
|
||||||
|
|
||||||
|
let sourceStatus = getSourceStatus(normalizedSource);
|
||||||
|
if (!sourceStatus) {
|
||||||
|
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
sourceStatus = getSourceStatus(normalizedSource);
|
||||||
|
} else if (!isSourceStatusFresh(sourceStatus) && !sourceStatus.probing) {
|
||||||
|
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceStatus || !sourceStatus.live) {
|
||||||
|
return {
|
||||||
|
session: null,
|
||||||
|
ready: false,
|
||||||
|
live: false,
|
||||||
|
probing: Boolean(sourceStatus && sourceStatus.probing),
|
||||||
|
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
|
||||||
|
timedOut: Boolean(sourceStatus && sourceStatus.timedOut),
|
||||||
|
stderr: String(sourceStatus && sourceStatus.stderr || '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await ensureSession(normalizedSource, normalizedDisableAudio);
|
||||||
|
const ready = await session.ready.catch(function () {
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const live = ready && Boolean(session && session.process && session.exitCode === null && session.exitSignal === null);
|
||||||
|
return {
|
||||||
|
session: session,
|
||||||
|
ready: ready,
|
||||||
|
live: live,
|
||||||
|
probing: Boolean(sourceStatus && sourceStatus.probing),
|
||||||
|
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
|
||||||
|
timedOut: false,
|
||||||
|
stderr: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionByKey(key) {
|
||||||
|
return sessions.get(String(key || '').trim()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getManifestFilePath(key) {
|
||||||
|
const session = getSessionByKey(key);
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!session.process || session.exitCode !== null || session.exitSignal !== null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const live = await isSessionLive(session);
|
||||||
|
if (!live) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session.manifestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSegmentFilePath(key, fileName) {
|
||||||
|
const session = getSessionByKey(key);
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segmentName = path.basename(String(fileName || '').trim());
|
||||||
|
if (!segmentName || segmentName === 'index.m3u8') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.join(session.directory, segmentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ensureSession: ensureSession,
|
||||||
|
getPlaylistUrl: getPlaylistUrl,
|
||||||
|
getSessionByKey: getSessionByKey,
|
||||||
|
isSessionLive: isSessionLive,
|
||||||
|
getSessionStatus: getSessionStatus,
|
||||||
|
refreshSourceStatus: refreshSourceStatus,
|
||||||
|
scheduleSourceRefresh: scheduleSourceRefresh,
|
||||||
|
getSourceStatus: getSourceStatus,
|
||||||
|
getManifestFilePath: getManifestFilePath,
|
||||||
|
getSegmentFilePath: getSegmentFilePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRtmpStreamService: createRtmpStreamService
|
||||||
|
};
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
const { isClientNameAvailable, withClientNameReservation } = require('../../data/client-name-check');
|
||||||
|
const { getSharedSecret, verifyPageAuthToken } = require('../../request-auth');
|
||||||
|
const { isTransientDbError } = require('./store');
|
||||||
|
|
||||||
|
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
const ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS = 8;
|
||||||
|
const onboardingSignupAttempts = new Map();
|
||||||
|
|
||||||
|
function normalizeDeviceId(value) {
|
||||||
|
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicBaseUrl(req) {
|
||||||
|
const configured = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
||||||
|
if (configured) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
||||||
|
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
|
||||||
|
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0];
|
||||||
|
const host = forwardedHost || String(req.headers.host || '').trim();
|
||||||
|
return `${protocol}://${host}`.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestIp(req) {
|
||||||
|
const forwardedFor = String(req && req.headers && req.headers['x-forwarded-for'] || '').trim().split(',')[0];
|
||||||
|
if (forwardedFor) {
|
||||||
|
return forwardedFor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteAddress = req && req.socket && req.socket.remoteAddress ? String(req.socket.remoteAddress).trim() : '';
|
||||||
|
if (!remoteAddress) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return remoteAddress.toLowerCase().startsWith('::ffff:') ? remoteAddress.slice(7) : remoteAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOnboardingLimitKey(req, deviceId) {
|
||||||
|
return [getRequestIp(req), normalizeDeviceId(deviceId) || 'anonymous'].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOnboardingSignupAttempts() {
|
||||||
|
if (onboardingSignupAttempts.size > 1000) {
|
||||||
|
onboardingSignupAttempts.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnboardingSignupRateLimited(req, deviceId) {
|
||||||
|
const now = Date.now();
|
||||||
|
const key = getOnboardingLimitKey(req, deviceId);
|
||||||
|
const attempts = onboardingSignupAttempts.get(key) || [];
|
||||||
|
const windowStart = now - ONBOARDING_SIGNUP_LIMIT_WINDOW_MS;
|
||||||
|
const recentAttempts = attempts.filter(function (timestamp) {
|
||||||
|
return timestamp >= windowStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentAttempts.length >= ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS) {
|
||||||
|
onboardingSignupAttempts.set(key, recentAttempts);
|
||||||
|
return Math.max(1, Math.ceil((recentAttempts[0] + ONBOARDING_SIGNUP_LIMIT_WINDOW_MS - now) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
recentAttempts.push(now);
|
||||||
|
onboardingSignupAttempts.set(key, recentAttempts);
|
||||||
|
clearOnboardingSignupAttempts();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOnboardingStatus(pool, deviceId) {
|
||||||
|
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
||||||
|
if (!normalizedDeviceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id
|
||||||
|
FROM player_onboarding_devices d
|
||||||
|
LEFT JOIN screens s ON s.id = d.screen_id
|
||||||
|
WHERE d.device_id = ?`,
|
||||||
|
[normalizedDeviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections) {
|
||||||
|
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
||||||
|
const normalizedClientName = String(clientName || '').trim();
|
||||||
|
const normalizedScreenSlug = String(screenSlug || '').trim();
|
||||||
|
if (!normalizedDeviceId) {
|
||||||
|
throw new Error('Device ID is required.');
|
||||||
|
}
|
||||||
|
if (!normalizedClientName) {
|
||||||
|
throw new Error('Client name is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedScreenSlug) {
|
||||||
|
throw new Error('Screen is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return withClientNameReservation(pool, normalizedClientName, async function () {
|
||||||
|
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]);
|
||||||
|
if (!screenRows.length) {
|
||||||
|
throw new Error('Screen not found.');
|
||||||
|
}
|
||||||
|
const screen = screenRows[0];
|
||||||
|
|
||||||
|
const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections);
|
||||||
|
if (!available) {
|
||||||
|
const error = new Error('Client name already exists.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
|
||||||
|
[normalizedDeviceId, normalizedClientName, screen.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return getOnboardingStatus(pool, normalizedDeviceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
|
||||||
|
const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function'
|
||||||
|
? playerRuntime.snapshotAllConnections()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isTransientDbError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onboardingStore && typeof onboardingStore.enqueueBinding === 'function') {
|
||||||
|
await onboardingStore.enqueueBinding({
|
||||||
|
deviceId: deviceId,
|
||||||
|
clientName: clientName,
|
||||||
|
screenSlug: screenSlug,
|
||||||
|
queuedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
device_id: normalizeDeviceId(deviceId),
|
||||||
|
client_name: String(clientName || '').trim(),
|
||||||
|
screen_slug: String(screenSlug || '').trim(),
|
||||||
|
queued: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerPlayerOnboardingRoutes(app, options) {
|
||||||
|
const pool = options && options.pool ? options.pool : null;
|
||||||
|
const common = options && options.common ? options.common : null;
|
||||||
|
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||||
|
const QRCode = options && options.QRCode ? options.QRCode : null;
|
||||||
|
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
|
||||||
|
|
||||||
|
if (!app || !pool || !common || !playerRuntime || !QRCode) {
|
||||||
|
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedSecret = getSharedSecret();
|
||||||
|
|
||||||
|
function requireOnboardingPageAuth(req, res, next) {
|
||||||
|
if (!sharedSecret) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
|
||||||
|
const payload = verifyPageAuthToken(token);
|
||||||
|
if (!payload || String(payload.scope || '').trim() !== 'onboarding') {
|
||||||
|
return res.status(401).json({ error: 'Onboarding page authentication required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.playerPageAuth = payload;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/', function (_req, res) {
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.send(common.renderPlayerOnboardingLandingPage());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/onboard', function (req, res) {
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/onboarding/status', function (req, res, next) {
|
||||||
|
if (sharedSecret) {
|
||||||
|
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
|
||||||
|
const payload = verifyPageAuthToken(token);
|
||||||
|
if (!payload || ['onboarding', 'player'].indexOf(String(payload.scope || '').trim()) === -1) {
|
||||||
|
return res.status(401).json({ error: 'Onboarding page authentication required.' });
|
||||||
|
}
|
||||||
|
req.playerPageAuth = payload;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}, async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const status = await getOnboardingStatus(pool, req.query.deviceId);
|
||||||
|
res.json({
|
||||||
|
deviceId: normalizeDeviceId(req.query.deviceId),
|
||||||
|
onboarded: Boolean(status && status.screen_id),
|
||||||
|
clientName: status ? status.client_name : null,
|
||||||
|
screenId: status ? status.screen_id : null,
|
||||||
|
screenSlug: status ? status.screen_slug : null,
|
||||||
|
screenName: status ? status.screen_name : null,
|
||||||
|
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT id, name, slug FROM screens ORDER BY name ASC, id ASC');
|
||||||
|
res.json({ screens: rows });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/onboarding/qr', async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const deviceId = normalizeDeviceId(req.query.deviceId);
|
||||||
|
if (!deviceId) {
|
||||||
|
return res.status(400).json({ error: 'Device ID is required' });
|
||||||
|
}
|
||||||
|
const onboardingUrl = `${getPublicBaseUrl(req)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
|
||||||
|
const svg = await QRCode.toString(onboardingUrl, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
|
||||||
|
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
res.send(svg);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/onboarding', requireOnboardingPageAuth, async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const deviceId = normalizeDeviceId(req.body && req.body.deviceId);
|
||||||
|
const clientName = String((req.body && req.body.clientName) || '').trim();
|
||||||
|
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
|
||||||
|
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
|
||||||
|
if (retryAfterSeconds) {
|
||||||
|
res.set('Retry-After', String(retryAfterSeconds));
|
||||||
|
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
|
||||||
|
}
|
||||||
|
if (!deviceId) {
|
||||||
|
return res.status(400).json({ error: 'Device ID is required' });
|
||||||
|
}
|
||||||
|
if (!clientName) {
|
||||||
|
return res.status(400).json({ error: 'Client name is required' });
|
||||||
|
}
|
||||||
|
if (!screenSlug) {
|
||||||
|
return res.status(400).json({ error: 'Screen is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
|
||||||
|
res.json({
|
||||||
|
deviceId: deviceId,
|
||||||
|
clientName: status ? status.client_name : clientName,
|
||||||
|
screenId: status && status.screen_id ? status.screen_id : null,
|
||||||
|
screenSlug: status ? status.screen_slug : screenSlug,
|
||||||
|
screenName: status && status.screen_name ? status.screen_name : null,
|
||||||
|
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`,
|
||||||
|
queued: Boolean(status && status.queued)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeDeviceId: normalizeDeviceId,
|
||||||
|
getPublicBaseUrl: getPublicBaseUrl,
|
||||||
|
getOnboardingStatus: getOnboardingStatus,
|
||||||
|
commitDeviceBinding: commitDeviceBinding,
|
||||||
|
bindDeviceToScreen: bindDeviceToScreen,
|
||||||
|
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var deviceKey = "pulse-signage-player-device-id";
|
||||||
|
var clientNameKey = "pulse-signage-player-client-name";
|
||||||
|
var screenKey = "pulse-signage-player-screen-slug";
|
||||||
|
var deviceId = {{DEVICE_ID_JSON}};
|
||||||
|
var form = document.getElementById("onboarding-form");
|
||||||
|
var message = document.getElementById("onboarding-message");
|
||||||
|
var screenSelect = document.getElementById("onboarding-screen-select");
|
||||||
|
function setMessage(value) { if (message) { message.textContent = value || ""; } }
|
||||||
|
function parseResponseError(response) {
|
||||||
|
return response.text().then(function (text) {
|
||||||
|
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
|
||||||
|
try {
|
||||||
|
var payload = JSON.parse(text);
|
||||||
|
return payload && payload.error ? payload.error : fallbackMessage;
|
||||||
|
} catch (_error) {
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadScreens() {
|
||||||
|
return fetch("/api/onboarding/screens", { cache: "no-store" })
|
||||||
|
.then(function (response) { return response.ok ? response.json() : null; })
|
||||||
|
.then(function (payload) {
|
||||||
|
var screens = payload && Array.isArray(payload.screens) ? payload.screens : [];
|
||||||
|
if (!screenSelect) { return screens; }
|
||||||
|
while (screenSelect.firstChild) { screenSelect.removeChild(screenSelect.firstChild); }
|
||||||
|
var placeholder = document.createElement("option");
|
||||||
|
placeholder.value = "";
|
||||||
|
placeholder.textContent = "Select a screen";
|
||||||
|
screenSelect.appendChild(placeholder);
|
||||||
|
screens.forEach(function (screen) {
|
||||||
|
var option = document.createElement("option");
|
||||||
|
option.value = String(screen && screen.slug ? screen.slug : "");
|
||||||
|
option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen");
|
||||||
|
screenSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
return screens;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!deviceId) { setMessage("Missing device id. Scan the QR code from the player screen again."); return; }
|
||||||
|
try { window.localStorage.setItem(deviceKey, deviceId); } catch (_error) {}
|
||||||
|
loadScreens().then(function () {
|
||||||
|
try {
|
||||||
|
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
|
||||||
|
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
|
||||||
|
var clientNameInput = form.querySelector("input[name=\"clientName\"]");
|
||||||
|
if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; }
|
||||||
|
if (screenSelect && storedScreenSlug) { screenSelect.value = storedScreenSlug; }
|
||||||
|
} catch (_error) {}
|
||||||
|
});
|
||||||
|
form.addEventListener("submit", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
var formData = new FormData(form);
|
||||||
|
var clientName = String(formData.get("clientName") || "").trim();
|
||||||
|
var screenSlug = String(formData.get("screenSlug") || "").trim();
|
||||||
|
if (!clientName) { setMessage("Client name is required."); return; }
|
||||||
|
if (!screenSlug) { setMessage("Screen is required."); return; }
|
||||||
|
setMessage("Saving client...");
|
||||||
|
fetch("/api/onboarding", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
|
||||||
|
})
|
||||||
|
.then(function (response) {
|
||||||
|
if (response.ok) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
return parseResponseError(response).then(function (messageText) {
|
||||||
|
throw new Error(messageText);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (payload) {
|
||||||
|
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
|
||||||
|
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
|
||||||
|
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||||
|
setMessage("Onboarding complete.");
|
||||||
|
if (form) {
|
||||||
|
Array.prototype.slice.call(form.querySelectorAll("input, select, button")).forEach(function (control) {
|
||||||
|
control.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (error) { setMessage(error && error.message ? error.message : "Unable to save onboarding."); });
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var deviceKey = "pulse-signage-player-device-id";
|
||||||
|
var clientNameKey = "pulse-signage-player-client-name";
|
||||||
|
function getClientNameStorageKey(_screenSlug) {
|
||||||
|
return clientNameKey;
|
||||||
|
}
|
||||||
|
var screenKey = "pulse-signage-player-screen-slug";
|
||||||
|
var qr = document.getElementById("onboarding-qr");
|
||||||
|
var status = document.getElementById("onboarding-status");
|
||||||
|
var localForm = document.getElementById("onboarding-local-form");
|
||||||
|
var localMessage = document.getElementById("onboarding-message");
|
||||||
|
var localScreenSelect = document.getElementById("onboarding-screen-select");
|
||||||
|
function parseResponseError(response) {
|
||||||
|
return response.text().then(function (text) {
|
||||||
|
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
|
||||||
|
try {
|
||||||
|
var payload = JSON.parse(text);
|
||||||
|
return payload && payload.error ? payload.error : fallbackMessage;
|
||||||
|
} catch (_error) {
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function getDeviceId() {
|
||||||
|
var stored = "";
|
||||||
|
try { stored = window.localStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
|
||||||
|
if (stored) { return stored; }
|
||||||
|
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "device-" + Date.now() + "-" + Math.random().toString(16).slice(2));
|
||||||
|
try { window.localStorage.setItem(deviceKey, next); } catch (_error2) {}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
function setStatus(message) { if (status) { status.textContent = message; } }
|
||||||
|
function setLocalMessage(message) { if (localMessage) { localMessage.textContent = message || ""; } }
|
||||||
|
function setSelectOptions(select, screens, selectedSlug) {
|
||||||
|
if (!select) { return; }
|
||||||
|
while (select.firstChild) { select.removeChild(select.firstChild); }
|
||||||
|
var placeholder = document.createElement("option");
|
||||||
|
placeholder.value = "";
|
||||||
|
placeholder.textContent = "Select a screen";
|
||||||
|
select.appendChild(placeholder);
|
||||||
|
(Array.isArray(screens) ? screens : []).forEach(function (screen) {
|
||||||
|
var option = document.createElement("option");
|
||||||
|
option.value = String(screen && screen.slug ? screen.slug : "");
|
||||||
|
option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen");
|
||||||
|
if (selectedSlug && String(option.value) === String(selectedSlug)) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadScreens(selectedSlug) {
|
||||||
|
return fetch("/api/onboarding/screens", { cache: "no-store" })
|
||||||
|
.then(function (response) { return response.ok ? response.json() : null; })
|
||||||
|
.then(function (payload) {
|
||||||
|
var screens = payload && Array.isArray(payload.screens) ? payload.screens : [];
|
||||||
|
setSelectOptions(localScreenSelect, screens, selectedSlug);
|
||||||
|
return screens;
|
||||||
|
})
|
||||||
|
.catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; });
|
||||||
|
}
|
||||||
|
function loadQr(deviceId) {
|
||||||
|
if (qr) { qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); }
|
||||||
|
}
|
||||||
|
function submitOnboarding(deviceId, clientName, screenSlug) {
|
||||||
|
return fetch("/api/onboarding", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
|
||||||
|
})
|
||||||
|
.then(function (response) {
|
||||||
|
if (response.ok) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
return parseResponseError(response).then(function (messageText) {
|
||||||
|
throw new Error(messageText);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (payload) {
|
||||||
|
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
|
||||||
|
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
|
||||||
|
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
|
||||||
|
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||||
|
setLocalMessage("Onboarding complete.");
|
||||||
|
if (localForm) {
|
||||||
|
Array.prototype.slice.call(localForm.querySelectorAll("input, select, button")).forEach(function (control) {
|
||||||
|
control.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function redirectIfOnboarded(deviceId) {
|
||||||
|
return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(deviceId), { cache: "no-store" })
|
||||||
|
.then(function (response) { return response.ok ? response.json() : null; })
|
||||||
|
.then(function (payload) {
|
||||||
|
if (payload && payload.onboarded && payload.screenSlug) {
|
||||||
|
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
|
||||||
|
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
|
||||||
|
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
|
||||||
|
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.catch(function () { return false; });
|
||||||
|
}
|
||||||
|
var deviceId = getDeviceId();
|
||||||
|
if (localForm) {
|
||||||
|
localForm.addEventListener("submit", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
var formData = new FormData(localForm);
|
||||||
|
var clientName = String(formData.get("clientName") || "").trim();
|
||||||
|
var screenSlug = String(formData.get("screenSlug") || "").trim();
|
||||||
|
if (!clientName) { setLocalMessage("Client name is required."); return; }
|
||||||
|
if (!screenSlug) { setLocalMessage("Screen is required."); return; }
|
||||||
|
setLocalMessage("Saving client...");
|
||||||
|
submitOnboarding(deviceId, clientName, screenSlug).catch(function (error) {
|
||||||
|
setLocalMessage(error && error.message ? error.message : "Unable to save onboarding.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadScreens().then(function () {
|
||||||
|
try {
|
||||||
|
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
|
||||||
|
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
|
||||||
|
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
|
||||||
|
if (storedClientName && localForm) {
|
||||||
|
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
|
||||||
|
if (clientNameInput && !clientNameInput.value) { clientNameInput.value = storedClientName; }
|
||||||
|
}
|
||||||
|
if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; }
|
||||||
|
} catch (_error) {}
|
||||||
|
});
|
||||||
|
redirectIfOnboarded(deviceId).then(function (redirected) {
|
||||||
|
if (redirected) { return; }
|
||||||
|
loadQr(deviceId);
|
||||||
|
setStatus("Waiting for onboarding to finish.");
|
||||||
|
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function isTransientDbError(error) {
|
||||||
|
const code = String(error && error.code ? error.code : '').trim();
|
||||||
|
return [
|
||||||
|
'ECONNREFUSED',
|
||||||
|
'ECONNRESET',
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'EPIPE',
|
||||||
|
'ENOTFOUND',
|
||||||
|
'PROTOCOL_CONNECTION_LOST',
|
||||||
|
'POOL_CLOSED',
|
||||||
|
'ERR_POOL_CLOSED'
|
||||||
|
].indexOf(code) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOnboardingStore(filePath) {
|
||||||
|
const normalizedFilePath = String(filePath || '').trim();
|
||||||
|
|
||||||
|
async function readEntries() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.promises.readFile(normalizedFilePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeEntries(entries) {
|
||||||
|
await fs.promises.mkdir(path.dirname(normalizedFilePath), { recursive: true });
|
||||||
|
const tempPath = `${normalizedFilePath}.tmp`;
|
||||||
|
await fs.promises.writeFile(tempPath, JSON.stringify(Array.isArray(entries) ? entries : [], null, 2), 'utf8');
|
||||||
|
await fs.promises.rename(tempPath, normalizedFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueBinding(entry) {
|
||||||
|
const normalizedEntry = {
|
||||||
|
deviceId: String(entry && entry.deviceId ? entry.deviceId : '').trim(),
|
||||||
|
clientName: String(entry && entry.clientName ? entry.clientName : '').trim(),
|
||||||
|
screenSlug: String(entry && entry.screenSlug ? entry.screenSlug : '').trim(),
|
||||||
|
queuedAt: String(entry && entry.queuedAt ? entry.queuedAt : new Date().toISOString())
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!normalizedEntry.deviceId || !normalizedEntry.clientName || !normalizedEntry.screenSlug) {
|
||||||
|
return readEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await readEntries();
|
||||||
|
const nextEntries = entries.filter(function (queuedEntry) {
|
||||||
|
return String(queuedEntry && queuedEntry.deviceId ? queuedEntry.deviceId : '').trim() !== normalizedEntry.deviceId;
|
||||||
|
});
|
||||||
|
nextEntries.push(normalizedEntry);
|
||||||
|
await writeEntries(nextEntries);
|
||||||
|
return nextEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushBindings(applyBinding) {
|
||||||
|
const entries = await readEntries();
|
||||||
|
if (!entries.length) {
|
||||||
|
return { flushed: 0, remaining: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = [];
|
||||||
|
let flushed = 0;
|
||||||
|
for (let index = 0; index < entries.length; index += 1) {
|
||||||
|
const entry = entries[index];
|
||||||
|
try {
|
||||||
|
await applyBinding(entry);
|
||||||
|
flushed += 1;
|
||||||
|
} catch (error) {
|
||||||
|
if (isTransientDbError(error)) {
|
||||||
|
remaining.push.apply(remaining, entries.slice(index));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
remaining.push.apply(remaining, entries.slice(index + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeEntries(remaining);
|
||||||
|
return { flushed: flushed, remaining: remaining.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueueBinding: enqueueBinding,
|
||||||
|
flushBindings: flushBindings,
|
||||||
|
readEntries: readEntries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createOnboardingStore: createOnboardingStore,
|
||||||
|
isTransientDbError: isTransientDbError
|
||||||
|
};
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script>
|
||||||
|
let onboardingClientName = null;
|
||||||
|
let onboardingClientNameSyncPromise = null;
|
||||||
|
const onboardingClientNameStorageKey = 'pulse-signage-player-client-name';
|
||||||
|
const onboardingDeviceIdStorageKey = 'pulse-signage-player-device-id';
|
||||||
|
|
||||||
|
function getOnboardingDeviceId() {
|
||||||
|
try {
|
||||||
|
var storedDeviceId = window.localStorage.getItem(onboardingDeviceIdStorageKey) || '';
|
||||||
|
return String(storedDeviceId || '').trim();
|
||||||
|
} catch (_error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the onboarding client name when one was assigned, otherwise a stable client id.
|
||||||
|
function getOnboardingClientName() {
|
||||||
|
if (onboardingClientName) {
|
||||||
|
return onboardingClientName;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var storedClientName = window.localStorage.getItem(onboardingClientNameStorageKey);
|
||||||
|
if (storedClientName) {
|
||||||
|
onboardingClientName = storedClientName;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('pulse-signage-player-client-name', storedClientName);
|
||||||
|
} catch (_mirrorError) {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
return onboardingClientName;
|
||||||
|
}
|
||||||
|
var genericClientName = window.localStorage.getItem('pulse-signage-player-client-name');
|
||||||
|
if (genericClientName) {
|
||||||
|
onboardingClientName = genericClientName;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(onboardingClientNameStorageKey, genericClientName);
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
return onboardingClientName;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// fall through to client id generation
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOnboardingClientName(renamedClientName, socket) {
|
||||||
|
var normalizedName = String(renamedClientName || '').trim();
|
||||||
|
if (!normalizedName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onboardingClientName = normalizedName;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('pulse-signage-player-client-name', normalizedName);
|
||||||
|
window.localStorage.setItem(onboardingClientNameStorageKey, normalizedName);
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
sendCommandHello(socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncOnboardingClientNameFromServer(socket) {
|
||||||
|
var deviceId = getOnboardingDeviceId();
|
||||||
|
if (!deviceId) {
|
||||||
|
return Promise.resolve(getOnboardingClientName());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onboardingClientNameSyncPromise) {
|
||||||
|
return onboardingClientNameSyncPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
onboardingClientNameSyncPromise = fetch('/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId), {
|
||||||
|
cache: 'no-store'
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}).then(function (payload) {
|
||||||
|
var serverName = payload && payload.clientName ? String(payload.clientName).trim() : '';
|
||||||
|
if (serverName) {
|
||||||
|
applyOnboardingClientName(serverName, null);
|
||||||
|
}
|
||||||
|
return onboardingClientName || getOnboardingClientName();
|
||||||
|
}).catch(function () {
|
||||||
|
return onboardingClientName || getOnboardingClientName();
|
||||||
|
}).finally(function () {
|
||||||
|
onboardingClientNameSyncPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return onboardingClientNameSyncPromise;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+86
-1153
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,11 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{{TITLE}}</title>
|
<title>{{TITLE}}</title>
|
||||||
|
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
||||||
<link rel="stylesheet" href="/assets/css/player.css" />
|
<link rel="stylesheet" href="/assets/css/player.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="{{BODY_CLASS}}">
|
||||||
<div id="app"><div class="empty">Loading screen...</div></div>
|
{{{BODY}}}
|
||||||
{{SCRIPT_BLOCK}}
|
{{SCRIPT_BLOCK}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function createPlayerPlaylistService(options) {
|
||||||
|
const pool = options && options.pool ? options.pool : null;
|
||||||
|
const common = options && options.common ? options.common : null;
|
||||||
|
const snapshotDir = options && options.snapshotDir ? options.snapshotDir : null;
|
||||||
|
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('pool is required');
|
||||||
|
}
|
||||||
|
if (!common) {
|
||||||
|
throw new Error('common is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshotFilePath(slug) {
|
||||||
|
if (!snapshotDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedSlug = String(slug || '').trim();
|
||||||
|
if (!normalizedSlug) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.join(snapshotDir, `${normalizedSlug}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSnapshot(slug) {
|
||||||
|
const filePath = getSnapshotFilePath(slug);
|
||||||
|
if (!filePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeSnapshot(slug, payload) {
|
||||||
|
const filePath = getSnapshotFilePath(slug);
|
||||||
|
if (!filePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildScreenPlaylist(slug) {
|
||||||
|
try {
|
||||||
|
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
|
||||||
|
if (!screenRows.length) {
|
||||||
|
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const screen = screenRows[0];
|
||||||
|
if (!screen.playlist_id) {
|
||||||
|
const payloadWithoutPlaylist = {
|
||||||
|
screen: screen,
|
||||||
|
playlist: null,
|
||||||
|
slides: [],
|
||||||
|
revision: getPlaylistRevision(screen, null, [], [], [], [], [])
|
||||||
|
};
|
||||||
|
await writeSnapshot(slug, payloadWithoutPlaylist);
|
||||||
|
return payloadWithoutPlaylist;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
|
||||||
|
const playlist = playlistRows[0] || null;
|
||||||
|
const [slideRows] = await pool.query(`
|
||||||
|
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
|
||||||
|
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
||||||
|
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
|
FROM playlist_slides ps
|
||||||
|
JOIN slides sl ON sl.id = ps.slide_id
|
||||||
|
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||||
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
|
WHERE ps.playlist_id = ?
|
||||||
|
ORDER BY ps.position ASC, ps.id ASC
|
||||||
|
`, [screen.playlist_id]);
|
||||||
|
|
||||||
|
const templateIds = slideRows
|
||||||
|
.filter(function (slide) { return slide.template_id; })
|
||||||
|
.map(function (slide) { return slide.template_id; });
|
||||||
|
const templatesById = {};
|
||||||
|
let templateRows = [];
|
||||||
|
let regionRows = [];
|
||||||
|
if (templateIds.length) {
|
||||||
|
[templateRows] = await pool.query(`
|
||||||
|
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||||
|
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
|
FROM slide_templates st
|
||||||
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
|
WHERE st.id IN (?)
|
||||||
|
`, [templateIds]);
|
||||||
|
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
|
||||||
|
templateRows.forEach(function (template) {
|
||||||
|
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
|
||||||
|
templatesById[template.id] = template;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoRegionDurationSeconds(contentJson) {
|
||||||
|
if (!contentJson) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = common.parseJsonSafe(contentJson) || {};
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) {
|
||||||
|
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||||
|
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slides = slideRows.map(function (slide) {
|
||||||
|
const storedDuration = Number(slide.duration_seconds || 0);
|
||||||
|
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
|
||||||
|
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
|
||||||
|
const content = common.parseJsonSafe(slide.content_json) || {};
|
||||||
|
Object.keys(content).forEach(function (key) {
|
||||||
|
const region = content[key];
|
||||||
|
if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
|
||||||
|
region.cache_bust = videoCacheBust;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: slide.id,
|
||||||
|
title: slide.title,
|
||||||
|
body: slide.body,
|
||||||
|
modified_at: slide.modified_at,
|
||||||
|
duration_seconds: videoDuration || storedDuration,
|
||||||
|
use_video_duration: Boolean(slide.use_video_duration),
|
||||||
|
schedule_mode: slide.schedule_mode,
|
||||||
|
schedule_start_datetime: slide.schedule_start_datetime,
|
||||||
|
schedule_end_datetime: slide.schedule_end_datetime,
|
||||||
|
schedule_start_time: slide.schedule_start_time,
|
||||||
|
schedule_end_time: slide.schedule_end_time,
|
||||||
|
schedule_days_json: slide.schedule_days_json,
|
||||||
|
media_url: slide.media_path,
|
||||||
|
media_type: slide.media_type,
|
||||||
|
kind: common.mediaKind(slide.media_path),
|
||||||
|
template_id: slide.template_id,
|
||||||
|
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||||
|
content: content
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let rssFeeds = [];
|
||||||
|
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
|
||||||
|
const rssData = await common.fetchRssFeedsData(pool);
|
||||||
|
const feedRows = Array.isArray(rssData && rssData.rssFeeds) ? rssData.rssFeeds : [];
|
||||||
|
rssFeeds = await Promise.all(feedRows.map(async function (feed) {
|
||||||
|
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
|
||||||
|
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||||
|
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||||
|
}) });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiSources = [];
|
||||||
|
if (typeof common.fetchApiSourcesData === 'function') {
|
||||||
|
const apiData = await common.fetchApiSourcesData(pool);
|
||||||
|
const sourceRows = Array.isArray(apiData && apiData.apiSources) ? apiData.apiSources : [];
|
||||||
|
apiSources = sourceRows.map(function (source) {
|
||||||
|
return Object.assign({}, source, {
|
||||||
|
responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources);
|
||||||
|
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, revision: revision };
|
||||||
|
await writeSnapshot(slug, payload);
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
const snapshot = await readSnapshot(slug);
|
||||||
|
if (snapshot) {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlaylistRevisionHash(hash, value) {
|
||||||
|
hash.update(String(value === null || value === undefined ? '' : value));
|
||||||
|
hash.update('\0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources) {
|
||||||
|
const hash = crypto.createHash('sha1');
|
||||||
|
|
||||||
|
updatePlaylistRevisionHash(hash, screen && screen.id);
|
||||||
|
updatePlaylistRevisionHash(hash, screen && screen.playlist_id);
|
||||||
|
updatePlaylistRevisionHash(hash, screen && screen.modified_at);
|
||||||
|
|
||||||
|
updatePlaylistRevisionHash(hash, playlist && playlist.id);
|
||||||
|
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
|
||||||
|
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
|
||||||
|
updatePlaylistRevisionHash(hash, playlist && playlist.skip_unavailable_rtmp);
|
||||||
|
|
||||||
|
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
|
||||||
|
updatePlaylistRevisionHash(hash, slide.id);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.title);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.body);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.template_id);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.content_json);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.media_path);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.media_type);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.modified_at);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.position);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.use_video_duration);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.schedule_end_time);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.schedule_days_json);
|
||||||
|
});
|
||||||
|
|
||||||
|
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
|
||||||
|
updatePlaylistRevisionHash(hash, template.id);
|
||||||
|
updatePlaylistRevisionHash(hash, template.name);
|
||||||
|
updatePlaylistRevisionHash(hash, template.canvas_size_id);
|
||||||
|
updatePlaylistRevisionHash(hash, template.canvas_size_width);
|
||||||
|
updatePlaylistRevisionHash(hash, template.canvas_size_height);
|
||||||
|
updatePlaylistRevisionHash(hash, template.background_image_path);
|
||||||
|
updatePlaylistRevisionHash(hash, template.background_color);
|
||||||
|
updatePlaylistRevisionHash(hash, template.modified_at);
|
||||||
|
});
|
||||||
|
|
||||||
|
(Array.isArray(regionRows) ? regionRows : []).forEach(function (region) {
|
||||||
|
updatePlaylistRevisionHash(hash, region.id);
|
||||||
|
updatePlaylistRevisionHash(hash, region.template_id);
|
||||||
|
updatePlaylistRevisionHash(hash, region.region_key);
|
||||||
|
updatePlaylistRevisionHash(hash, region.region_type);
|
||||||
|
updatePlaylistRevisionHash(hash, region.label);
|
||||||
|
updatePlaylistRevisionHash(hash, region.font_family);
|
||||||
|
updatePlaylistRevisionHash(hash, region.x);
|
||||||
|
updatePlaylistRevisionHash(hash, region.y);
|
||||||
|
updatePlaylistRevisionHash(hash, region.width);
|
||||||
|
updatePlaylistRevisionHash(hash, region.height);
|
||||||
|
updatePlaylistRevisionHash(hash, region.z_index);
|
||||||
|
updatePlaylistRevisionHash(hash, region.modified_at);
|
||||||
|
});
|
||||||
|
|
||||||
|
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
|
||||||
|
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
|
||||||
|
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildScreenPlaylist: buildScreenPlaylist
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPlayerPlaylistService: createPlayerPlaylistService
|
||||||
|
};
|
||||||
@@ -4,21 +4,194 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #111;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.onboarding-page {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(82, 144, 255, 0.28), transparent 32%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(34, 197, 94, 0.18), transparent 26%),
|
||||||
|
linear-gradient(160deg, #09111f 0%, #0b1323 52%, #111827 100%);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.onboarding-page #app {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #000;
|
background: #111;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(16px, 3vw, 40px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-card {
|
||||||
|
width: min(100%, 1040px);
|
||||||
|
padding: clamp(20px, 3vw, 40px);
|
||||||
|
border-radius: 30px;
|
||||||
|
background: rgba(10, 17, 30, 0.82);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-card h1 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.2rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-kicker {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: #8ab4ff;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-copy {
|
||||||
|
margin: 0 0 28px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 1.03rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr);
|
||||||
|
gap: clamp(20px, 3vw, 32px);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-qr-pane {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-qr-frame {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-qr-frame img {
|
||||||
|
width: min(100%, 320px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: block;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-form--local {
|
||||||
|
align-content: start;
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-form input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-form select {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-form input[type="text"]::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-form button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #60a5fa, #22c55e);
|
||||||
|
color: #08111f;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-status {
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 1.4em;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-card--landing .onboarding-status {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px), (orientation: portrait) {
|
||||||
|
.onboarding-shell {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-qr-frame img {
|
||||||
|
width: min(100%, 280px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.slide-shell {
|
.slide-shell {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -86,18 +259,6 @@ body.screen-blackout #app {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
padding: 14px 18px;
|
|
||||||
background: rgba(0, 0, 0, 0.35);
|
|
||||||
font-size: 14px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 5%;
|
left: 5%;
|
||||||
@@ -133,7 +294,6 @@ body.screen-blackout #app {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #111;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-stage .template-background {
|
.template-stage .template-background {
|
||||||
@@ -162,6 +322,28 @@ body.screen-blackout #app {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-region.text .template-region-text-scale {
|
||||||
|
display: block;
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-region.text .template-region-text-scale > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-region.text .template-region-text-scale > * + * {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-region.text .template-region-text-scale ul,
|
||||||
|
.template-region.text .template-region-text-scale ol {
|
||||||
|
padding-left: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-region.text .template-region-text-scale code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.template-region.text > * {
|
.template-region.text > * {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -182,6 +364,19 @@ body.screen-blackout #app {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-region.video {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-region.video video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.template-region.webpage iframe {
|
.template-region.webpage iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -200,6 +395,24 @@ body.screen-blackout #app {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-region.rtmp {
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-region.rtmp video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-region-rtmp-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.template-region-placeholder {
|
.template-region-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
@@ -0,0 +1,400 @@
|
|||||||
|
// Capture the current viewport dimensions.
|
||||||
|
function getCurrentViewport() {
|
||||||
|
return {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command websocket and player-state helpers.
|
||||||
|
// Send the current playback state to the command websocket.
|
||||||
|
function sendCommandState(currentSlide) {
|
||||||
|
if (!commandSocket || commandSocket.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commandSocket.send(JSON.stringify({
|
||||||
|
type: 'state',
|
||||||
|
clientId: getCommandClientId(),
|
||||||
|
clientName: getOnboardingClientName() || null,
|
||||||
|
deviceId: getOnboardingDeviceId() || null,
|
||||||
|
userAgent: window.navigator.userAgent || '',
|
||||||
|
page: window.location.href,
|
||||||
|
viewport: getCurrentViewport(),
|
||||||
|
paused: isPaused,
|
||||||
|
blackout: isBlackout,
|
||||||
|
currentSlide: currentSlide ? {
|
||||||
|
id: currentSlide.id || null,
|
||||||
|
title: currentSlide.title || '',
|
||||||
|
kind: currentSlide.kind || '',
|
||||||
|
playlistSignature: currentPlaylistSignature || ''
|
||||||
|
} : null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce command-state updates during rapid changes.
|
||||||
|
function scheduleCommandStateUpdate() {
|
||||||
|
if (commandStateTimer) {
|
||||||
|
window.clearTimeout(commandStateTimer);
|
||||||
|
}
|
||||||
|
commandStateTimer = window.setTimeout(function () {
|
||||||
|
commandStateTimer = null;
|
||||||
|
sendCommandState(lastRenderedSlide);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce rerenders after viewport changes.
|
||||||
|
function scheduleViewportRenderUpdate() {
|
||||||
|
if (viewportRenderTimer) {
|
||||||
|
window.clearTimeout(viewportRenderTimer);
|
||||||
|
}
|
||||||
|
viewportRenderTimer = window.setTimeout(function () {
|
||||||
|
viewportRenderTimer = null;
|
||||||
|
if (slides.length) {
|
||||||
|
var activeSlides = getCurrentActiveSlides();
|
||||||
|
if (getCurrentRenderKey(activeSlides) === lastRenderedViewKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showCurrent();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel the current slide-advance timer.
|
||||||
|
function clearSlideTimer() {
|
||||||
|
if (timer) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule the next slide transition.
|
||||||
|
function scheduleSlideAdvance(delayMs) {
|
||||||
|
clearSlideTimer();
|
||||||
|
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||||
|
slideExpiresAt = Date.now() + holdDelayMs;
|
||||||
|
timer = window.setTimeout(function () {
|
||||||
|
timer = null;
|
||||||
|
slideExpiresAt = null;
|
||||||
|
pausedRemainingMs = null;
|
||||||
|
const activeSlides = getCurrentActiveSlides();
|
||||||
|
if (activeSlides.length < 2) {
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (index >= activeSlides.length) {
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
index = (index + 1) % activeSlides.length;
|
||||||
|
showCurrent();
|
||||||
|
}, holdDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the fade time to a slide's hold duration unless the slide duration already accounts for it.
|
||||||
|
function getSlideHoldDelay(delayMs, skipFadePadding) {
|
||||||
|
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||||
|
return holdDelayMs + (currentPlaylistFadeBetweenSlides && !skipFadePadding ? slideFadeDurationMs : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any pending fade-transition cleanup.
|
||||||
|
function clearSlideTransitionTimer() {
|
||||||
|
if (slideTransitionTimer) {
|
||||||
|
window.clearTimeout(slideTransitionTimer);
|
||||||
|
slideTransitionTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap slide markup with optional fade animation.
|
||||||
|
function renderSlideMarkup(markup, shouldFade) {
|
||||||
|
clearSlideTransitionTimer();
|
||||||
|
if (typeof destroyRtmpRegions === 'function') {
|
||||||
|
destroyRtmpRegions(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeRenderedVideoPlayback(root) {
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var videos = root.querySelectorAll('.template-region.video video');
|
||||||
|
Array.prototype.forEach.call(videos, function (video) {
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.autoplay = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
|
||||||
|
function startPlayback() {
|
||||||
|
var playPromise = video.play && video.play();
|
||||||
|
if (playPromise && typeof playPromise.catch === 'function') {
|
||||||
|
playPromise.catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.readyState >= 2) {
|
||||||
|
startPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.addEventListener('canplay', startPlayback, { once: true });
|
||||||
|
video.addEventListener('loadedmetadata', startPlayback, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldFade) {
|
||||||
|
app.innerHTML = markup;
|
||||||
|
if (typeof syncRtmpRegions === 'function') {
|
||||||
|
syncRtmpRegions(app);
|
||||||
|
}
|
||||||
|
initializeRenderedVideoPlayback(app);
|
||||||
|
return app.firstElementChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
var topLevelChildren = Array.prototype.slice.call(app.children || []);
|
||||||
|
var existingShells = topLevelChildren.filter(function (child) {
|
||||||
|
return child && child.classList && child.classList.contains('slide-shell');
|
||||||
|
});
|
||||||
|
var previousShell = existingShells.length ? existingShells[existingShells.length - 1] : app.firstElementChild;
|
||||||
|
|
||||||
|
if (existingShells.length > 1) {
|
||||||
|
existingShells.slice(0, -1).forEach(function (shell) {
|
||||||
|
if (shell && shell.parentNode) {
|
||||||
|
shell.parentNode.removeChild(shell);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextShell = document.createElement('div');
|
||||||
|
nextShell.className = 'slide-shell';
|
||||||
|
nextShell.style.opacity = '0';
|
||||||
|
nextShell.innerHTML = markup;
|
||||||
|
|
||||||
|
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
|
||||||
|
app.innerHTML = '';
|
||||||
|
nextShell.style.opacity = '1';
|
||||||
|
app.appendChild(nextShell);
|
||||||
|
if (typeof syncRtmpRegions === 'function') {
|
||||||
|
syncRtmpRegions(nextShell);
|
||||||
|
}
|
||||||
|
initializeRenderedVideoPlayback(nextShell);
|
||||||
|
return nextShell;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previousShell.classList.contains('slide-shell')) {
|
||||||
|
previousShell.classList.add('slide-shell');
|
||||||
|
}
|
||||||
|
previousShell.style.opacity = '1';
|
||||||
|
|
||||||
|
app.appendChild(nextShell);
|
||||||
|
void nextShell.offsetHeight;
|
||||||
|
window.requestAnimationFrame(function () {
|
||||||
|
nextShell.style.opacity = '1';
|
||||||
|
previousShell.style.opacity = '0';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof syncRtmpRegions === 'function') {
|
||||||
|
syncRtmpRegions(nextShell);
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeRenderedVideoPlayback(nextShell);
|
||||||
|
|
||||||
|
slideTransitionTimer = window.setTimeout(function () {
|
||||||
|
if (previousShell && previousShell.parentNode) {
|
||||||
|
previousShell.parentNode.removeChild(previousShell);
|
||||||
|
}
|
||||||
|
if (nextShell) {
|
||||||
|
nextShell.style.opacity = '1';
|
||||||
|
}
|
||||||
|
slideTransitionTimer = null;
|
||||||
|
}, slideFadeDurationMs);
|
||||||
|
|
||||||
|
return nextShell;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror blackout state onto the document body.
|
||||||
|
function syncBlackoutState() {
|
||||||
|
document.body.classList.toggle('screen-blackout', isBlackout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pause state and preserve remaining slide time.
|
||||||
|
function setPaused(nextPaused) {
|
||||||
|
var normalized = Boolean(nextPaused);
|
||||||
|
if (isPaused === normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (normalized) {
|
||||||
|
pausedRemainingMs = slideExpiresAt ? Math.max(0, slideExpiresAt - Date.now()) : null;
|
||||||
|
isPaused = true;
|
||||||
|
clearSlideTimer();
|
||||||
|
sendCommandState(lastRenderedSlide);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPaused = false;
|
||||||
|
sendCommandState(lastRenderedSlide);
|
||||||
|
if (!slides.length || !lastRenderedSlide) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pausedRemainingMs !== null) {
|
||||||
|
scheduleSlideAdvance(pausedRemainingMs);
|
||||||
|
pausedRemainingMs = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply blackout state and notify the server.
|
||||||
|
function setBlackout(nextBlackout) {
|
||||||
|
var normalized = Boolean(nextBlackout);
|
||||||
|
if (isBlackout === normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isBlackout = normalized;
|
||||||
|
syncBlackoutState();
|
||||||
|
sendCommandState(lastRenderedSlide);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerce command payload values into booleans or null.
|
||||||
|
function normalizeBoolean(value) {
|
||||||
|
if (value === true || value === false) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var normalized = String(value).trim().toLowerCase();
|
||||||
|
if (['1', 'true', 'yes', 'on'].indexOf(normalized) !== -1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (['0', 'false', 'no', 'off', ''].indexOf(normalized) !== -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to the previous or next active slide.
|
||||||
|
function navigateSlides(offset) {
|
||||||
|
const manualSlides = getCurrentActiveSlides();
|
||||||
|
if (!manualSlides.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let currentIndex = manualSlides.findIndex(function (slide) {
|
||||||
|
return slide && lastRenderedSlide && slide.id === lastRenderedSlide.id;
|
||||||
|
});
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
currentIndex = Math.min(Math.max(index, 0), manualSlides.length - 1);
|
||||||
|
}
|
||||||
|
const nextIndex = (currentIndex + offset + manualSlides.length) % manualSlides.length;
|
||||||
|
clearSlideTimer();
|
||||||
|
applyPendingPlaylistUpdate();
|
||||||
|
renderSlideAtIndex(manualSlides, nextIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route incoming websocket command messages.
|
||||||
|
function handleCommandMessage(rawMessage) {
|
||||||
|
var payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(String(rawMessage || ''));
|
||||||
|
} catch (_error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload || payload.type !== 'command') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (payload.command) {
|
||||||
|
case 'refresh':
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
case 'setclientname':
|
||||||
|
if (payload.clientName) {
|
||||||
|
applyOnboardingClientName(payload.clientName, commandSocket);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'redirect':
|
||||||
|
if (payload.url) {
|
||||||
|
window.location.replace(String(payload.url));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'pause':
|
||||||
|
setPaused(!isPaused);
|
||||||
|
return;
|
||||||
|
case 'blackout':
|
||||||
|
var desiredBlackout = normalizeBoolean(payload.blackout);
|
||||||
|
if (desiredBlackout !== null) {
|
||||||
|
setBlackout(desiredBlackout);
|
||||||
|
} else {
|
||||||
|
setBlackout(!isBlackout);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'previous':
|
||||||
|
case 'left':
|
||||||
|
navigateSlides(-1);
|
||||||
|
return;
|
||||||
|
case 'next':
|
||||||
|
case 'right':
|
||||||
|
navigateSlides(1);
|
||||||
|
return;
|
||||||
|
case 'reload':
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry the command websocket after a disconnect.
|
||||||
|
function scheduleCommandReconnect() {
|
||||||
|
if (commandReconnectTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commandReconnectTimer = window.setTimeout(function () {
|
||||||
|
commandReconnectTimer = null;
|
||||||
|
connectCommandSocket();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open and wire the command websocket connection.
|
||||||
|
function connectCommandSocket() {
|
||||||
|
if (!window.WebSocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
var socketUrl = new URL(commandSocketPath, window.location.origin);
|
||||||
|
if (window.__pulsePageAuthToken) {
|
||||||
|
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
|
||||||
|
}
|
||||||
|
var socket = new WebSocket(socketUrl.toString());
|
||||||
|
commandSocket = socket;
|
||||||
|
|
||||||
|
socket.onopen = function () {
|
||||||
|
if (typeof syncOnboardingClientNameFromServer === 'function') {
|
||||||
|
syncOnboardingClientNameFromServer(socket).then(function () {
|
||||||
|
sendCommandHello(socket);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendCommandHello(socket);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = function (event) {
|
||||||
|
handleCommandMessage(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = function () {
|
||||||
|
commandSocket = null;
|
||||||
|
scheduleCommandReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = function () {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore socket close errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// Show or hide the offline status banner.
|
||||||
|
function setOfflineBannerVisible(visible, message) {
|
||||||
|
if (window.__pulseThumbnailPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var normalizedVisible = Boolean(visible);
|
||||||
|
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
|
||||||
|
if (normalizedVisible) {
|
||||||
|
if (!offlineBanner) {
|
||||||
|
offlineBanner = document.createElement('div');
|
||||||
|
offlineBanner.className = 'player-offline-banner';
|
||||||
|
offlineBanner.style.position = 'fixed';
|
||||||
|
offlineBanner.style.right = '0';
|
||||||
|
offlineBanner.style.bottom = '0';
|
||||||
|
offlineBanner.style.left = 'auto';
|
||||||
|
offlineBanner.style.top = 'auto';
|
||||||
|
offlineBanner.style.width = '1.25rem';
|
||||||
|
offlineBanner.style.height = '1.25rem';
|
||||||
|
offlineBanner.style.zIndex = '9999';
|
||||||
|
offlineBanner.style.background = 'linear-gradient(135deg, #ff4d4f 0%, #b00020 100%)';
|
||||||
|
offlineBanner.style.clipPath = 'circle(100% at 100% 100%)';
|
||||||
|
offlineBanner.style.webkitClipPath = 'circle(100% at 100% 100%)';
|
||||||
|
offlineBanner.style.boxShadow = '0 0 0 1px rgba(0, 0, 0, 0.16), 0 4px 12px rgba(0, 0, 0, 0.18)';
|
||||||
|
offlineBanner.style.pointerEvents = 'none';
|
||||||
|
document.body.appendChild(offlineBanner);
|
||||||
|
}
|
||||||
|
offlineBanner.textContent = '';
|
||||||
|
offlineBanner.setAttribute('aria-label', bannerMessage);
|
||||||
|
offlineBanner.setAttribute('role', 'img');
|
||||||
|
offlineBanner.title = bannerMessage;
|
||||||
|
offlineBannerVisible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
offlineBannerVisible = false;
|
||||||
|
if (offlineBanner && offlineBanner.parentNode) {
|
||||||
|
offlineBanner.parentNode.removeChild(offlineBanner);
|
||||||
|
}
|
||||||
|
offlineBanner = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the offline banner based on connectivity or playlist availability.
|
||||||
|
function syncOfflineBanner() {
|
||||||
|
if (window.__pulseThumbnailPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.navigator.onLine) {
|
||||||
|
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (offlineBannerVisible) {
|
||||||
|
setOfflineBannerVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any pending playlist refresh retry.
|
||||||
|
function clearRefreshRetry() {
|
||||||
|
if (refreshRetryTimer) {
|
||||||
|
window.clearTimeout(refreshRetryTimer);
|
||||||
|
refreshRetryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry playlist refresh with a short backoff while the player is offline.
|
||||||
|
function scheduleRefreshRetry() {
|
||||||
|
if (window.__pulseThumbnailPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (refreshRetryTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.navigator.onLine === false) {
|
||||||
|
refreshRetryDelayMs = refreshRetryDelayMs ? Math.min(refreshRetryDelayMs * 2, 15000) : 3000;
|
||||||
|
} else {
|
||||||
|
refreshRetryDelayMs = refreshRetryDelayMs ? Math.min(refreshRetryDelayMs * 2, 8000) : 3000;
|
||||||
|
}
|
||||||
|
refreshRetryTimer = window.setTimeout(function () {
|
||||||
|
refreshRetryTimer = null;
|
||||||
|
refresh();
|
||||||
|
}, refreshRetryDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScreenWakeLockRetry() {
|
||||||
|
if (screenWakeLockRetryTimer) {
|
||||||
|
window.clearTimeout(screenWakeLockRetryTimer);
|
||||||
|
screenWakeLockRetryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsScreenWakeLock() {
|
||||||
|
return Boolean(window.navigator && window.navigator.wakeLock && typeof window.navigator.wakeLock.request === 'function');
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleScreenWakeLockRetry() {
|
||||||
|
if (screenWakeLockRetryTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!supportsScreenWakeLock() || document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
screenWakeLockRetryTimer = window.setTimeout(function () {
|
||||||
|
screenWakeLockRetryTimer = null;
|
||||||
|
acquireScreenWakeLock();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseScreenWakeLock() {
|
||||||
|
if (screenWakeLock && typeof screenWakeLock.release === 'function') {
|
||||||
|
try {
|
||||||
|
screenWakeLock.release();
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore wake lock release errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
screenWakeLock = null;
|
||||||
|
screenWakeLockRequestPromise = null;
|
||||||
|
clearScreenWakeLockRetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireScreenWakeLock() {
|
||||||
|
if (!supportsScreenWakeLock() || document.visibilityState !== 'visible') {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
if (screenWakeLockRequestPromise) {
|
||||||
|
return screenWakeLockRequestPromise;
|
||||||
|
}
|
||||||
|
if (screenWakeLock && screenWakeLock.released === false) {
|
||||||
|
return Promise.resolve(screenWakeLock);
|
||||||
|
}
|
||||||
|
|
||||||
|
screenWakeLockRequestPromise = window.navigator.wakeLock.request('screen').then(function (sentinel) {
|
||||||
|
screenWakeLock = sentinel;
|
||||||
|
screenWakeLock.addEventListener('release', function () {
|
||||||
|
screenWakeLock = null;
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
scheduleScreenWakeLockRetry();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
clearScreenWakeLockRetry();
|
||||||
|
return screenWakeLock;
|
||||||
|
}).catch(function (error) {
|
||||||
|
screenWakeLock = null;
|
||||||
|
if (error && error.name !== 'NotAllowedError') {
|
||||||
|
scheduleScreenWakeLockRetry();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).finally(function () {
|
||||||
|
screenWakeLockRequestPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return screenWakeLockRequestPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncScreenWakeLock() {
|
||||||
|
if (!supportsScreenWakeLock()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
acquireScreenWakeLock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
releaseScreenWakeLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the retry cadence after a successful refresh.
|
||||||
|
function markRefreshHealthy() {
|
||||||
|
refreshRetryDelayMs = 0;
|
||||||
|
clearRefreshRetry();
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Render the slide at the requested index within the active set.
|
||||||
|
async function renderSlideAtIndex(sourceSlides, targetIndex) {
|
||||||
|
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||||
|
if (!availableSlides.length) {
|
||||||
|
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||||
|
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedIndex = Number(targetIndex || 0);
|
||||||
|
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||||
|
}
|
||||||
|
|
||||||
|
var slideCount = availableSlides.length;
|
||||||
|
var currentIndex = normalizedIndex;
|
||||||
|
var slide = null;
|
||||||
|
var attempts = 0;
|
||||||
|
var renderRequestId = ++slideRenderRequestId;
|
||||||
|
|
||||||
|
while (attempts < slideCount) {
|
||||||
|
slide = availableSlides[currentIndex];
|
||||||
|
if (!slide) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (currentPlaylistSkipUnavailableRtmp && typeof probeRtmpSlideAvailability === 'function') {
|
||||||
|
var isAvailable = await probeRtmpSlideAvailability(slide);
|
||||||
|
if (!isAvailable) {
|
||||||
|
if (renderRequestId !== slideRenderRequestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
currentIndex = (currentIndex + 1) % slideCount;
|
||||||
|
attempts += 1;
|
||||||
|
slide = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (renderRequestId !== slideRenderRequestId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slide) {
|
||||||
|
if (currentPlaylistSkipUnavailableRtmp) {
|
||||||
|
renderEmpty(slides.length ? 'No RTMP slides are currently available.' : 'No slides assigned to this screen yet.');
|
||||||
|
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
||||||
|
scheduleRefreshRetry();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
index = currentIndex;
|
||||||
|
var markup = buildSlideMarkup(slide);
|
||||||
|
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
|
||||||
|
sendCommandState(slide);
|
||||||
|
if (!isPaused) {
|
||||||
|
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
|
||||||
|
}
|
||||||
|
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote a deferred playlist update at the next safe point.
|
||||||
|
function applyPendingPlaylistUpdate() {
|
||||||
|
if (!pendingPlaylistUpdate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
slides = pendingPlaylistUpdate.slides;
|
||||||
|
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
||||||
|
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
||||||
|
currentPlaylistSkipUnavailableRtmp = Boolean(pendingPlaylistUpdate.skipUnavailableRtmp);
|
||||||
|
pendingPlaylistUpdate = null;
|
||||||
|
clearActiveSlidesCache();
|
||||||
|
slideMarkupCache = Object.create(null);
|
||||||
|
templateLayoutCache = Object.create(null);
|
||||||
|
templateRenderPlanCache = Object.create(null);
|
||||||
|
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||||
|
index = 0;
|
||||||
|
logDebug('Applied updated playlist on slide transition.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the current active slide or the empty state.
|
||||||
|
function showCurrent() {
|
||||||
|
clearSlideTimer();
|
||||||
|
applyPendingPlaylistUpdate();
|
||||||
|
const activeSlides = getCurrentActiveSlides();
|
||||||
|
syncWebpagePreloads(activeSlides, index);
|
||||||
|
if (typeof syncRtmpWarmups === 'function') {
|
||||||
|
syncRtmpWarmups(activeSlides, index);
|
||||||
|
}
|
||||||
|
if (!activeSlides.length) {
|
||||||
|
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||||
|
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void renderSlideAtIndex(activeSlides, index);
|
||||||
|
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it.
|
||||||
|
function handleRtmpPlaybackFailure(message, options) {
|
||||||
|
if (!currentPlaylistSkipUnavailableRtmp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var silent = Boolean(options && options.silent);
|
||||||
|
const activeSlides = getCurrentActiveSlides();
|
||||||
|
if (!activeSlides.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSlideTimer();
|
||||||
|
if (!silent) {
|
||||||
|
logDebug('Skipping RTMP slide because the stream is unavailable.', String(message || ''), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = (Number(index || 0) + 1) % activeSlides.length;
|
||||||
|
window.setTimeout(function () {
|
||||||
|
if (!getCurrentActiveSlides().length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void renderSlideAtIndex(getCurrentActiveSlides(), nextIndex);
|
||||||
|
}, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the latest playlist and queue any updates.
|
||||||
|
function refresh() {
|
||||||
|
var request = new XMLHttpRequest();
|
||||||
|
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
|
||||||
|
request.open('GET', url, true);
|
||||||
|
request.timeout = 2500;
|
||||||
|
if (window.__pulsePageAuthToken) {
|
||||||
|
request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken);
|
||||||
|
}
|
||||||
|
if (currentPlaylistEtag) {
|
||||||
|
request.setRequestHeader('If-None-Match', currentPlaylistEtag);
|
||||||
|
}
|
||||||
|
request.onreadystatechange = function () {
|
||||||
|
if (request.readyState !== 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.status === 304) {
|
||||||
|
markRefreshHealthy();
|
||||||
|
setOfflineBannerVisible(false);
|
||||||
|
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||||
|
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.status < 200 || request.status >= 300) {
|
||||||
|
setOfflineBannerVisible(true);
|
||||||
|
scheduleRefreshRetry();
|
||||||
|
logDebug(
|
||||||
|
'Screen not found or playlist unavailable.',
|
||||||
|
['URL: ' + url, 'Status: ' + request.status + ' ' + request.statusText, 'Response: ' + String(request.responseText || '').slice(0, 1000)].join(' | '),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const responseEtag = String(request.getResponseHeader('ETag') || '').trim();
|
||||||
|
const data = JSON.parse(request.responseText || '{}');
|
||||||
|
const nextSignature = getPlaylistRevision(data);
|
||||||
|
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
|
||||||
|
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||||
|
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
||||||
|
savePlaylistSnapshot({
|
||||||
|
slides: nextSlides,
|
||||||
|
signature: nextSignature,
|
||||||
|
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||||
|
skipUnavailableRtmp: nextSkipUnavailableRtmp,
|
||||||
|
etag: responseEtag
|
||||||
|
});
|
||||||
|
markRefreshHealthy();
|
||||||
|
setOfflineBannerVisible(false);
|
||||||
|
const currentActiveSlides = getCurrentActiveSlides();
|
||||||
|
if (responseEtag) {
|
||||||
|
currentPlaylistEtag = responseEtag;
|
||||||
|
}
|
||||||
|
if (!currentPlaylistSignature) {
|
||||||
|
slides = nextSlides;
|
||||||
|
currentPlaylistSignature = nextSignature;
|
||||||
|
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
||||||
|
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
|
||||||
|
index = 0;
|
||||||
|
showCurrent();
|
||||||
|
sendCommandState(lastRenderedSlide);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
|
||||||
|
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
|
||||||
|
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
|
||||||
|
if (typeof syncRtmpWarmups === 'function') {
|
||||||
|
syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index);
|
||||||
|
}
|
||||||
|
if (currentActiveSlides.length < 2) {
|
||||||
|
slides = nextSlides;
|
||||||
|
currentPlaylistSignature = nextSignature;
|
||||||
|
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
||||||
|
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
|
||||||
|
pendingPlaylistUpdate = null;
|
||||||
|
index = 0;
|
||||||
|
showCurrent();
|
||||||
|
sendCommandState(lastRenderedSlide);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingPlaylistUpdate = {
|
||||||
|
slides: nextSlides,
|
||||||
|
signature: nextSignature,
|
||||||
|
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||||
|
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||||
|
};
|
||||||
|
logDebug('Playlist update detected; applying on next slide transition.');
|
||||||
|
} catch (_error) {
|
||||||
|
logDebug(
|
||||||
|
'Unable to load screen playlist.',
|
||||||
|
['URL: ' + url, 'Response: ' + String(request.responseText || '').slice(0, 1000)].join(' | '),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
setOfflineBannerVisible(true);
|
||||||
|
scheduleRefreshRetry();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = function () {
|
||||||
|
logDebug(
|
||||||
|
'Unable to load screen playlist.',
|
||||||
|
['URL: ' + url, 'Network error during request.'].join(' | '),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
setOfflineBannerVisible(true);
|
||||||
|
scheduleRefreshRetry();
|
||||||
|
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||||
|
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.ontimeout = function () {
|
||||||
|
logDebug(
|
||||||
|
'Playlist refresh timed out.',
|
||||||
|
['URL: ' + url, 'Timeout after ' + request.timeout + 'ms'].join(' | '),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
setOfflineBannerVisible(true);
|
||||||
|
scheduleRefreshRetry();
|
||||||
|
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||||
|
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.send();
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// Collect unique webpage URLs from the slide list.
|
||||||
|
function getWebpageUrls(sourceSlides) {
|
||||||
|
const urls = [];
|
||||||
|
(Array.isArray(sourceSlides) ? sourceSlides : []).forEach(function (slide) {
|
||||||
|
const content = slide && slide.content ? slide.content : {};
|
||||||
|
const regions = slide && slide.template && Array.isArray(slide.template.regions) ? slide.template.regions : [];
|
||||||
|
regions.forEach(function (region) {
|
||||||
|
if (region.region_type !== 'webpage') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const regionContent = content[region.region_key] || {};
|
||||||
|
const url = String(regionContent.value || '').trim();
|
||||||
|
if (url && urls.indexOf(url) === -1) {
|
||||||
|
urls.push(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter the slides down to those that are active right now.
|
||||||
|
function getActiveSlidesFrom(sourceSlides) {
|
||||||
|
const now = new Date();
|
||||||
|
return (Array.isArray(sourceSlides) ? sourceSlides : []).filter(function (slide) {
|
||||||
|
return isSlideActive(slide, now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a cache key for the active slide set.
|
||||||
|
function getActiveSlidesCacheKey() {
|
||||||
|
const now = new Date();
|
||||||
|
return [
|
||||||
|
currentPlaylistSignature || '',
|
||||||
|
now.getFullYear(),
|
||||||
|
now.getMonth(),
|
||||||
|
now.getDate(),
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds()
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the cached active slide set for the current playlist and second.
|
||||||
|
function getCurrentActiveSlides() {
|
||||||
|
const cacheKey = getActiveSlidesCacheKey();
|
||||||
|
if (cacheKey !== activeSlidesCacheKey) {
|
||||||
|
activeSlidesCacheValue = getActiveSlidesFrom(slides);
|
||||||
|
activeSlidesCacheKey = cacheKey;
|
||||||
|
}
|
||||||
|
return activeSlidesCacheValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the cached active slide set.
|
||||||
|
function clearActiveSlidesCache() {
|
||||||
|
activeSlidesCacheKey = '';
|
||||||
|
activeSlidesCacheValue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset render caches when the viewport changes.
|
||||||
|
function syncRenderCacheViewport() {
|
||||||
|
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||||
|
if (renderCacheViewportKey === viewportKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCacheViewportKey = viewportKey;
|
||||||
|
slideMarkupCache = Object.create(null);
|
||||||
|
templateLayoutCache = Object.create(null);
|
||||||
|
templateRenderPlanCache = Object.create(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a signature for the currently rendered view.
|
||||||
|
function getCurrentRenderKey(activeSlides) {
|
||||||
|
const viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||||
|
const availableSlides = Array.isArray(activeSlides) ? activeSlides : [];
|
||||||
|
if (!availableSlides.length) {
|
||||||
|
return [currentPlaylistSignature || '', viewportKey, 'empty', slides.length ? 'scheduled' : 'assigned'].join('|');
|
||||||
|
}
|
||||||
|
let normalizedIndex = Number(index || 0);
|
||||||
|
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||||
|
normalizedIndex = 0;
|
||||||
|
}
|
||||||
|
const slide = availableSlides[normalizedIndex];
|
||||||
|
return [currentPlaylistSignature || '', viewportKey, 'slide', slide && slide.id ? slide.id : ''].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the current slide and the next slide for webpage preloading.
|
||||||
|
function getWebpagePreloadSlides(sourceSlides, targetIndex) {
|
||||||
|
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||||
|
if (!availableSlides.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedIndex = Number(targetIndex || 0);
|
||||||
|
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||||
|
normalizedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preloadSlides = [];
|
||||||
|
const currentSlide = availableSlides[normalizedIndex];
|
||||||
|
const nextSlide = availableSlides[normalizedIndex + 1];
|
||||||
|
|
||||||
|
if (currentSlide) {
|
||||||
|
preloadSlides.push(currentSlide);
|
||||||
|
}
|
||||||
|
if (nextSlide && nextSlide !== currentSlide) {
|
||||||
|
preloadSlides.push(nextSlide);
|
||||||
|
}
|
||||||
|
|
||||||
|
return preloadSlides;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount hidden iframe preloads for the chosen webpage URLs.
|
||||||
|
function syncWebpagePreloads(sourceSlides, targetIndex) {
|
||||||
|
const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex));
|
||||||
|
const signature = urls.join('\n');
|
||||||
|
if (signature === preloadSignature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!urls.length) {
|
||||||
|
preloadSignature = '';
|
||||||
|
if (preloadContainer) {
|
||||||
|
preloadContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!preloadContainer) {
|
||||||
|
preloadContainer = document.createElement('div');
|
||||||
|
preloadContainer.className = 'webpage-preloads';
|
||||||
|
preloadContainer.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.appendChild(preloadContainer);
|
||||||
|
}
|
||||||
|
preloadContainer.innerHTML = urls.map(function (url) {
|
||||||
|
return '<iframe class="webpage-preload-frame" src="' + escapeHtml(url) + '" title="Webpage preload" tabindex="-1" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
|
||||||
|
}).join('');
|
||||||
|
preloadSignature = signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a stable client id for this browser session.
|
||||||
|
function getCommandClientId() {
|
||||||
|
if (commandClientId) {
|
||||||
|
return commandClientId;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var storedClientId = window.localStorage.getItem(commandClientStorageKey);
|
||||||
|
if (storedClientId) {
|
||||||
|
commandClientId = storedClientId;
|
||||||
|
return commandClientId;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// fall through to ephemeral ID generation
|
||||||
|
}
|
||||||
|
commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2));
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(commandClientStorageKey, commandClientId);
|
||||||
|
} catch (_error2) {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
return commandClientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the most recent playlist snapshot from browser storage.
|
||||||
|
function loadPlaylistSnapshot() {
|
||||||
|
try {
|
||||||
|
var raw = window.localStorage.getItem(playlistSnapshotStorageKey);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || !Array.isArray(parsed.slides)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
slides: parsed.slides.map(normalizeSlide),
|
||||||
|
signature: String(parsed.signature || ''),
|
||||||
|
fadeBetweenSlides: Boolean(parsed.fadeBetweenSlides),
|
||||||
|
etag: String(parsed.etag || '')
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the latest playlist snapshot for offline recovery.
|
||||||
|
function savePlaylistSnapshot(data) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(playlistSnapshotStorageKey, JSON.stringify({
|
||||||
|
slides: Array.isArray(data && data.slides) ? data.slides : [],
|
||||||
|
signature: String(data && data.signature || ''),
|
||||||
|
fadeBetweenSlides: Boolean(data && data.fadeBetweenSlides),
|
||||||
|
etag: String(data && data.etag || ''),
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a playlist snapshot to the current in-memory state.
|
||||||
|
function applyPlaylistSnapshot(data) {
|
||||||
|
if (!data || !Array.isArray(data.slides)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
slides = data.slides.map(normalizeSlide);
|
||||||
|
currentPlaylistSignature = String(data.signature || '');
|
||||||
|
currentPlaylistFadeBetweenSlides = Boolean(data.fadeBetweenSlides);
|
||||||
|
currentPlaylistEtag = String(data.etag || '');
|
||||||
|
pendingPlaylistUpdate = null;
|
||||||
|
clearActiveSlidesCache();
|
||||||
|
slideMarkupCache = Object.create(null);
|
||||||
|
templateLayoutCache = Object.create(null);
|
||||||
|
templateRenderPlanCache = Object.create(null);
|
||||||
|
renderCacheViewportKey = '';
|
||||||
|
index = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
// General sanitization and sizing helpers.
|
||||||
|
// Strip unsupported characters from a font family string.
|
||||||
|
function sanitizeFontFamily(value) {
|
||||||
|
return String(value || '').replace(/[^a-zA-Z0-9 ,\"-]/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp font size to the supported range.
|
||||||
|
function sanitizeFontSize(value) {
|
||||||
|
return Math.max(8, Number(value || 0) || 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a text color and fall back when needed.
|
||||||
|
function sanitizeTextColor(value, fallback) {
|
||||||
|
var raw = String(value || '').trim();
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return fallback || '#000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the template's canvas dimensions with safe defaults.
|
||||||
|
function getTemplateCanvasSize(template) {
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Number(template.canvas_size_width || 1920)),
|
||||||
|
height: Math.max(1, Number(template.canvas_size_height || 1080))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the server-supplied playlist revision, or fall back to the ETag.
|
||||||
|
function getPlaylistRevision(data) {
|
||||||
|
if (data && data.revision) {
|
||||||
|
return String(data.revision);
|
||||||
|
}
|
||||||
|
if (data && data.playlist && data.playlist.revision) {
|
||||||
|
return String(data.playlist.revision);
|
||||||
|
}
|
||||||
|
if (currentPlaylistEtag) {
|
||||||
|
return String(currentPlaylistEtag).replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
return String(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale a canvas to fit within the viewport.
|
||||||
|
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||||
|
var width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
||||||
|
var height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
||||||
|
var viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
||||||
|
var viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
||||||
|
var scale = Math.min(viewportWidth / width, viewportHeight / height);
|
||||||
|
return {
|
||||||
|
width: Math.round(width * scale),
|
||||||
|
height: Math.round(height * scale)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||||
|
|
||||||
|
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||||
|
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'],
|
||||||
|
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 + '="' + escapeHtml(value) + '"');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
return attrs.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove unsafe markup while preserving richer CKEditor formatting.
|
||||||
|
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 attrText = String(match[3] || '');
|
||||||
|
if (ALLOWED_RICH_TEXT_TAGS.indexOf(name) === -1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (closing) {
|
||||||
|
return '</' + name + '>';
|
||||||
|
}
|
||||||
|
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a single Editor.js block to HTML.
|
||||||
|
function renderEditorJsBlock(block) {
|
||||||
|
if (!block || !block.type || !block.data) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'header') {
|
||||||
|
var level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||||
|
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'list') {
|
||||||
|
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||||
|
var items = Array.isArray(block.data.items) ? block.data.items : [];
|
||||||
|
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderEditorJsListItem(item, tag); }).join('') + '</' + tag + '>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'delimiter') {
|
||||||
|
return '<hr />';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'code') {
|
||||||
|
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'table') {
|
||||||
|
return renderEditorJsTable(block.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'paragraph') {
|
||||||
|
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a list item and any nested sub-items.
|
||||||
|
function renderEditorJsListItem(item, tag) {
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||||
|
var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||||
|
var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderEditorJsListItem(child, tag); }).join('') + '</' + tag + '>' : '';
|
||||||
|
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
||||||
|
}
|
||||||
|
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render an Editor.js table block.
|
||||||
|
function renderEditorJsTable(data) {
|
||||||
|
var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||||
|
if (!rows.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var hasHeadings = Boolean(data.withHeadings);
|
||||||
|
var tableRows = rows.map(function (row, rowIndex) {
|
||||||
|
var cells = Array.isArray(row) ? row : [];
|
||||||
|
var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||||
|
var cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||||
|
return '<tr>' + cells.map(function (cell) {
|
||||||
|
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
||||||
|
}).join('') + '</tr>';
|
||||||
|
}).join('');
|
||||||
|
return '<table class="ck-content-table">' + tableRows + '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Editor.js JSON or plain content safely.
|
||||||
|
function renderEditorJsContent(value) {
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
if (Array.isArray(value.blocks)) {
|
||||||
|
return value.blocks.map(renderEditorJsBlock).join('');
|
||||||
|
}
|
||||||
|
if (value.value !== undefined) {
|
||||||
|
return renderEditorJsContent(value.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var raw = String(value || '');
|
||||||
|
try {
|
||||||
|
var parsed = JSON.parse(raw);
|
||||||
|
if (parsed && Array.isArray(parsed.blocks)) {
|
||||||
|
return parsed.blocks.map(renderEditorJsBlock).join('');
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// fall through to legacy HTML rendering
|
||||||
|
}
|
||||||
|
return sanitizeRichText(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse string values that look like JSON.
|
||||||
|
function parseMaybeJson(value) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
var raw = value.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (raw.charAt(0) !== '{' && raw.charAt(0) !== '[') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (_error) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize a slide region's stored content value.
|
||||||
|
function normalizeContentValue(value) {
|
||||||
|
var normalized;
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
value: parseMaybeJson(value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
normalized = {};
|
||||||
|
Object.keys(value).forEach(function (key) {
|
||||||
|
normalized[key] = value[key];
|
||||||
|
});
|
||||||
|
if (normalized.value !== undefined) {
|
||||||
|
normalized.value = parseMaybeJson(normalized.value);
|
||||||
|
}
|
||||||
|
if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
||||||
|
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
||||||
|
}
|
||||||
|
if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
||||||
|
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
||||||
|
}
|
||||||
|
if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
||||||
|
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
||||||
|
}
|
||||||
|
if (!normalized.type) {
|
||||||
|
normalized.type = 'text';
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize a slide and its nested region content.
|
||||||
|
function normalizeSlide(slide) {
|
||||||
|
var normalized = {};
|
||||||
|
var content;
|
||||||
|
Object.keys(slide || {}).forEach(function (key) {
|
||||||
|
normalized[key] = slide[key];
|
||||||
|
});
|
||||||
|
normalized.content = {};
|
||||||
|
content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
||||||
|
Object.keys(content).forEach(function (regionKey) {
|
||||||
|
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the stored schedule-day list into numbers.
|
||||||
|
function parseScheduleDays(value) {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(function (item) { return Number(item); }).filter(function (item) { return !Number.isNaN(item); });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed.map(function (item) { return Number(item); }).filter(function (item) { return !Number.isNaN(item); }) : [];
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a HH:MM time string to minutes since midnight.
|
||||||
|
function parseTimeToMinutes(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = raw.match(/^(\d{2}):(\d{2})/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Number(match[1]) * 60 + Number(match[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine whether a slide should be shown at the current time.
|
||||||
|
function isSlideActive(slide, now) {
|
||||||
|
const mode = String(slide.schedule_mode || 'always');
|
||||||
|
if (mode === 'always') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mode === 'dates') {
|
||||||
|
const start = slide.schedule_start_datetime ? new Date(slide.schedule_start_datetime) : null;
|
||||||
|
const end = slide.schedule_end_datetime ? new Date(slide.schedule_end_datetime) : null;
|
||||||
|
if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return now >= start && now <= end;
|
||||||
|
}
|
||||||
|
if (mode === 'times') {
|
||||||
|
const days = parseScheduleDays(slide.schedule_days_json);
|
||||||
|
if (!days.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const day = now.getDay();
|
||||||
|
if (days.indexOf(day) === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const startMinutes = parseTimeToMinutes(slide.schedule_start_time);
|
||||||
|
const endMinutes = parseTimeToMinutes(slide.schedule_end_time);
|
||||||
|
if (startMinutes === null || endMinutes === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
if (startMinutes <= endMinutes) {
|
||||||
|
return nowMinutes >= startMinutes && nowMinutes <= endMinutes;
|
||||||
|
}
|
||||||
|
return nowMinutes >= startMinutes || nowMinutes <= endMinutes;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Build the cache key for a template layout.
|
||||||
|
function getTemplateLayoutCacheKey(template) {
|
||||||
|
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||||
|
return [currentPlaylistSignature || '', template && template.id ? template.id : '', viewportKey].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build or reuse layout metadata for a template.
|
||||||
|
function getTemplateLayout(template) {
|
||||||
|
if (!template || !template.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncRenderCacheViewport();
|
||||||
|
|
||||||
|
var cacheKey = getTemplateLayoutCacheKey(template);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(templateLayoutCache, cacheKey)) {
|
||||||
|
return templateLayoutCache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
var templateCanvas = getTemplateCanvasSize(template);
|
||||||
|
var canvasSize = fitCanvasSize(templateCanvas.width, templateCanvas.height, window.innerWidth, window.innerHeight);
|
||||||
|
var canvasScale = canvasSize.width / templateCanvas.width;
|
||||||
|
var regions = (template.regions || []).map(function (region) {
|
||||||
|
var left = (Number(region.x) / templateCanvas.width) * 100;
|
||||||
|
var top = (Number(region.y) / templateCanvas.height) * 100;
|
||||||
|
var width = (Number(region.width) / templateCanvas.width) * 100;
|
||||||
|
var height = (Number(region.height) / templateCanvas.height) * 100;
|
||||||
|
var baseStyle = 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';';
|
||||||
|
var pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1));
|
||||||
|
var pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1));
|
||||||
|
|
||||||
|
return {
|
||||||
|
regionKey: region.region_key,
|
||||||
|
regionType: region.region_type,
|
||||||
|
label: region.label,
|
||||||
|
baseStyle: baseStyle,
|
||||||
|
pixelWidth: pixelWidth,
|
||||||
|
pixelHeight: pixelHeight,
|
||||||
|
fontFamily: region.font_family || null,
|
||||||
|
fontSize: region.font_size || null,
|
||||||
|
fontColor: region.font_color || null,
|
||||||
|
canvasScale: canvasScale
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
var layout = {
|
||||||
|
canvasWidth: canvasSize.width,
|
||||||
|
canvasHeight: canvasSize.height,
|
||||||
|
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(template.background_image_path) + '" alt="" />' : '',
|
||||||
|
backgroundColor: template.background_color || '#111111',
|
||||||
|
regions: regions
|
||||||
|
};
|
||||||
|
|
||||||
|
templateLayoutCache[cacheKey] = layout;
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a dark backdrop style for template and media canvases.
|
||||||
|
function buildBackdropStyle(backgroundColor, backgroundImagePath) {
|
||||||
|
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
||||||
|
var style = 'background-color:' + escapeHtml(color) + ';';
|
||||||
|
var gradient = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))';
|
||||||
|
|
||||||
|
if (backgroundImagePath) {
|
||||||
|
style += 'background-image:' + gradient + ',url("' + escapeHtml(backgroundImagePath) + '");';
|
||||||
|
style += 'background-position:center,center;background-size:100% 100%,cover;background-repeat:no-repeat,no-repeat;';
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
style += 'background-image:' + gradient + ';background-position:center;background-size:100% 100%;background-repeat:no-repeat;';
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the cache key for a template render plan.
|
||||||
|
function getTemplateRenderPlanCacheKey(template) {
|
||||||
|
return getTemplateLayoutCacheKey(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build or reuse the render plan for a template.
|
||||||
|
function getTemplateRenderPlan(template) {
|
||||||
|
if (!template || !template.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncRenderCacheViewport();
|
||||||
|
|
||||||
|
var cacheKey = getTemplateRenderPlanCacheKey(template);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(templateRenderPlanCache, cacheKey)) {
|
||||||
|
return templateRenderPlanCache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
var layout = getTemplateLayout(template);
|
||||||
|
var plan = {
|
||||||
|
layout: layout,
|
||||||
|
renderRegion: function (region, regionContent) {
|
||||||
|
if (region.regionType === 'image') {
|
||||||
|
return renderImageRegion(region, regionContent);
|
||||||
|
}
|
||||||
|
if (region.regionType === 'video') {
|
||||||
|
return renderVideoRegion(region, regionContent);
|
||||||
|
}
|
||||||
|
if (region.regionType === 'webpage') {
|
||||||
|
return renderWebpageRegion(region, regionContent);
|
||||||
|
}
|
||||||
|
if (region.regionType === 'rtmp') {
|
||||||
|
return renderRtmpRegion(region, regionContent);
|
||||||
|
}
|
||||||
|
if (region.regionType === 'rss') {
|
||||||
|
return renderRssRegion(region, regionContent);
|
||||||
|
}
|
||||||
|
if (region.regionType === 'api') {
|
||||||
|
return renderApiRegion(region, regionContent);
|
||||||
|
}
|
||||||
|
if (region.regionType === 'html') {
|
||||||
|
return renderHtmlRegion(region, regionContent);
|
||||||
|
}
|
||||||
|
return renderTextRegion(region, regionContent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
templateRenderPlanCache[cacheKey] = plan;
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a template-based slide using the cached layout.
|
||||||
|
function renderTemplateSlideMarkup(slide) {
|
||||||
|
const template = slide.template;
|
||||||
|
const content = slide.content || {};
|
||||||
|
const plan = getTemplateRenderPlan(template);
|
||||||
|
const layout = plan ? plan.layout : null;
|
||||||
|
const regions = layout ? layout.regions.map(function (region) {
|
||||||
|
const regionContent = content[region.regionKey] || {};
|
||||||
|
return plan.renderRegion(region, regionContent);
|
||||||
|
}).join('') : '';
|
||||||
|
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
|
||||||
|
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media rendering helpers.
|
||||||
|
// Build the direct media element for a slide.
|
||||||
|
function renderMediaSlideContent(slide) {
|
||||||
|
if (slide.kind === 'image') {
|
||||||
|
return '<img src="' + escapeHtml(slide.media_url) + '" alt="slide" />';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a slide that contains direct media content.
|
||||||
|
function renderMediaSlideMarkup(slide) {
|
||||||
|
const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight);
|
||||||
|
const media = renderMediaSlideContent(slide);
|
||||||
|
const canvasStyle = slide.kind === 'image' ? buildBackdropStyle('#111111', slide.media_url) : '';
|
||||||
|
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media, canvasStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the shared slide shell around slide-specific inner content.
|
||||||
|
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml, canvasStyle) {
|
||||||
|
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
|
||||||
|
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
|
||||||
|
const style = 'width:' + canvasWidth + ';height:' + canvasHeight + ';' + (canvasStyle || '');
|
||||||
|
return '<div class="slide"><div class="' + className + '" style="' + style + '">' + innerHtml + body + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the cache key for rendered slide markup.
|
||||||
|
function getSlideMarkupCacheKey(slide) {
|
||||||
|
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||||
|
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyVideoRegionSourceReady() {
|
||||||
|
if (typeof videoRegionRenderVersion === 'number') {
|
||||||
|
videoRegionRenderVersion += 1;
|
||||||
|
}
|
||||||
|
slideMarkupCache = Object.create(null);
|
||||||
|
if (typeof showCurrent === 'function' && slides && slides.length) {
|
||||||
|
showCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.notifyVideoRegionSourceReady = notifyVideoRegionSourceReady;
|
||||||
|
|
||||||
|
// Look up a previously rendered slide in the cache.
|
||||||
|
function getCachedSlideMarkup(slide) {
|
||||||
|
var cacheKey = getSlideMarkupCacheKey(slide);
|
||||||
|
return Object.prototype.hasOwnProperty.call(slideMarkupCache, cacheKey) ? slideMarkupCache[cacheKey] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store rendered slide markup in the cache.
|
||||||
|
function setCachedSlideMarkup(slide, markup) {
|
||||||
|
syncRenderCacheViewport();
|
||||||
|
slideMarkupCache[getSlideMarkupCacheKey(slide)] = markup;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide rendering and markup cache helpers.
|
||||||
|
// Choose the right slide renderer and cache the result.
|
||||||
|
function buildSlideMarkup(slide) {
|
||||||
|
lastRenderedSlide = slide || null;
|
||||||
|
syncBlackoutState();
|
||||||
|
var cachedMarkup = getCachedSlideMarkup(slide);
|
||||||
|
if (cachedMarkup) {
|
||||||
|
return cachedMarkup;
|
||||||
|
}
|
||||||
|
|
||||||
|
var markup = '';
|
||||||
|
if (slide.template_id && slide.template) {
|
||||||
|
markup = renderTemplateSlideMarkup(slide);
|
||||||
|
setCachedSlideMarkup(slide, markup);
|
||||||
|
return markup;
|
||||||
|
}
|
||||||
|
|
||||||
|
markup = renderMediaSlideMarkup(slide);
|
||||||
|
setCachedSlideMarkup(slide, markup);
|
||||||
|
return markup;
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
const CACHE_VERSION = 'v2';
|
||||||
|
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
|
||||||
|
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
|
||||||
|
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
|
||||||
|
const PLAYLIST_CACHE = `pulse-signage-player-playlists-${CACHE_VERSION}`;
|
||||||
|
|
||||||
|
function normalizeRequest(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return new Request(`${url.origin}${url.pathname}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: request.headers,
|
||||||
|
mode: 'same-origin',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheResponse(cacheName, request, response, cacheKeyRequest) {
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
await cache.put(cacheKeyRequest || request, response.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirst(request, cacheName, cacheKeyRequest) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response && response.ok) {
|
||||||
|
await cacheResponse(cacheName, request, response, cacheKeyRequest);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
if (response && response.status === 304) {
|
||||||
|
const cached = await caches.match(cacheKeyRequest || request);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cached = await caches.match(cacheKeyRequest || request);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (_error) {
|
||||||
|
const cached = await caches.match(cacheKeyRequest || request);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
throw _error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheFirst(request, cacheName) {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response && response.ok) {
|
||||||
|
await cacheResponse(cacheName, request, response);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function staleWhileRevalidate(request, cacheName) {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
const networkPromise = fetch(request).then(async function (response) {
|
||||||
|
if (response && response.ok) {
|
||||||
|
await cacheResponse(cacheName, request, response);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}).catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
networkPromise.catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const networkResponse = await networkPromise;
|
||||||
|
if (networkResponse) {
|
||||||
|
return networkResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('', { status: 504, statusText: 'Offline' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkOnly(request) {
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('install', function (event) {
|
||||||
|
self.skipWaiting();
|
||||||
|
event.waitUntil(Promise.resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', function (event) {
|
||||||
|
event.waitUntil((async function () {
|
||||||
|
const expected = [PAGE_CACHE, ASSET_CACHE, MEDIA_CACHE, PLAYLIST_CACHE];
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.filter(function (key) {
|
||||||
|
return expected.indexOf(key) === -1;
|
||||||
|
}).map(function (key) {
|
||||||
|
return caches.delete(key);
|
||||||
|
}));
|
||||||
|
await self.clients.claim();
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', function (event) {
|
||||||
|
const request = event.request;
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/sw.js') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/assets/')) {
|
||||||
|
event.respondWith(cacheFirst(request, ASSET_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/media/')) {
|
||||||
|
event.respondWith(networkOnly(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.mode === 'navigate' || url.pathname === '/' || url.pathname === '/onboard' || /^\/screen\/[^/]+$/.test(url.pathname)) {
|
||||||
|
event.respondWith(networkFirst(request, PAGE_CACHE, normalizeRequest(request)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/api/screens/') && url.pathname.endsWith('/playlist')) {
|
||||||
|
event.respondWith(networkFirst(request, PLAYLIST_CACHE, normalizeRequest(request)));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
function getApiSourceById(sourceId) {
|
||||||
|
var sources = Array.isArray(initialData && initialData.apiSources) ? initialData.apiSources : [];
|
||||||
|
var normalizedId = Number(sourceId || 0);
|
||||||
|
return sources.find(function (source) {
|
||||||
|
return Number(source.id) === normalizedId;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiSourceItems(sourceId) {
|
||||||
|
var source = getApiSourceById(sourceId);
|
||||||
|
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
|
||||||
|
if (Array.isArray(responseJson)) {
|
||||||
|
return responseJson;
|
||||||
|
}
|
||||||
|
if (responseJson && Array.isArray(responseJson.items)) {
|
||||||
|
return responseJson.items;
|
||||||
|
}
|
||||||
|
if (responseJson && Array.isArray(responseJson.results)) {
|
||||||
|
return responseJson.results;
|
||||||
|
}
|
||||||
|
if (responseJson && Array.isArray(responseJson.data)) {
|
||||||
|
return responseJson.data;
|
||||||
|
}
|
||||||
|
return responseJson ? [responseJson] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiItem(sourceId, itemNumber) {
|
||||||
|
var items = getApiSourceItems(sourceId);
|
||||||
|
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
|
||||||
|
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||||
|
return items[index] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiPath(value, path) {
|
||||||
|
var current = value;
|
||||||
|
if (!path) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
String(path).split('.').forEach(function (segment) {
|
||||||
|
if (current === undefined || current === null) {
|
||||||
|
current = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
});
|
||||||
|
|
||||||
|
return current === undefined || current === null ? '' : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function substituteApiVariables(html, item) {
|
||||||
|
var source = String(html || '');
|
||||||
|
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
|
||||||
|
return escapeHtml(resolveApiPath(item, key || ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiPreviewFallback(item) {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return '<div class="template-region-placeholder">API item</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = String(item.title || item.name || '').trim();
|
||||||
|
var description = String(item.description || item.summary || item.text || '').trim();
|
||||||
|
var summary = [];
|
||||||
|
if (title) {
|
||||||
|
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
||||||
|
}
|
||||||
|
if (description) {
|
||||||
|
summary.push('<div>' + sanitizeRichText(description) + '</div>');
|
||||||
|
}
|
||||||
|
if (!summary.length) {
|
||||||
|
return '<div class="template-region-placeholder">API item</div>';
|
||||||
|
}
|
||||||
|
return summary.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApiRegion(region, regionContent) {
|
||||||
|
var content = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||||
|
var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : null;
|
||||||
|
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
|
||||||
|
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
|
||||||
|
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
||||||
|
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
||||||
|
var item = getApiItem(sourceId, itemNumber);
|
||||||
|
var body = item ? substituteApiVariables(content, item) : '';
|
||||||
|
if (!body && item) {
|
||||||
|
body = getApiPreviewFallback(item);
|
||||||
|
}
|
||||||
|
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||||
|
var renderedBody = body ? renderEditorJsContent(body) : '<div class="template-region-placeholder">API item</div>';
|
||||||
|
return '<div class="template-region api" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>';
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
function renderHtmlRegionContent(value) {
|
||||||
|
var html = String(value || '').trim();
|
||||||
|
if (!html) {
|
||||||
|
return '<div class="template-region-placeholder">HTML</div>';
|
||||||
|
}
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHtmlRegion(region, regionContent) {
|
||||||
|
return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent.value || '') + '</div>';
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
function renderImageRegion(region, regionContent) {
|
||||||
|
var src = regionContent.value || '';
|
||||||
|
return '<div class="template-region image" style="' + region.baseStyle + '"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label) + '" /></div>';
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
function getRssFeedById(feedId) {
|
||||||
|
var feeds = Array.isArray(initialData && initialData.rssFeeds) ? initialData.rssFeeds : [];
|
||||||
|
var normalizedId = Number(feedId || 0);
|
||||||
|
return feeds.find(function (feed) {
|
||||||
|
return Number(feed.id) === normalizedId;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRssFeedItem(feedId, itemNumber) {
|
||||||
|
var feed = getRssFeedById(feedId);
|
||||||
|
var items = feed && Array.isArray(feed.items) ? feed.items : [];
|
||||||
|
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
|
||||||
|
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||||
|
return items[index] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRssPath(value, path) {
|
||||||
|
var current = value;
|
||||||
|
if (!path) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
String(path).split('.').forEach(function (segment) {
|
||||||
|
if (current === undefined || current === null) {
|
||||||
|
current = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
});
|
||||||
|
|
||||||
|
return current === undefined || current === null ? '' : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function substituteRssVariables(html, item) {
|
||||||
|
var source = String(html || '');
|
||||||
|
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
|
||||||
|
return escapeHtml(resolveRssPath(item, key || ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRssRegion(region, regionContent) {
|
||||||
|
var content = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||||
|
var feedId = regionContent && regionContent.feed_id !== undefined ? regionContent.feed_id : null;
|
||||||
|
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
|
||||||
|
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
|
||||||
|
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
||||||
|
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
||||||
|
var item = getRssFeedItem(feedId, itemNumber);
|
||||||
|
var body = item ? substituteRssVariables(content, item) : '';
|
||||||
|
if (!body && item) {
|
||||||
|
var summaryParts = [];
|
||||||
|
if (item.title) {
|
||||||
|
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
|
||||||
|
}
|
||||||
|
if (item.description) {
|
||||||
|
summaryParts.push('<div>' + sanitizeRichText(item.description) + '</div>');
|
||||||
|
}
|
||||||
|
body = summaryParts.join('');
|
||||||
|
}
|
||||||
|
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||||
|
var renderedBody = body ? renderEditorJsContent(body) : '<div class="template-region-placeholder">RSS item</div>';
|
||||||
|
return '<div class="template-region rss" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>';
|
||||||
|
}
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
function renderRtmpRegion(region, regionContent) {
|
||||||
|
var url = String(regionContent.value || '').trim();
|
||||||
|
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||||
|
var skipUnavailable = Boolean(currentPlaylistSkipUnavailableRtmp);
|
||||||
|
if (!url) {
|
||||||
|
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><div class="template-region-placeholder">RTMP stream</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRtmpSessionUrl(sourceUrl, disableAudio) {
|
||||||
|
return '/api/rtmp/session?source=' + encodeURIComponent(sourceUrl) + '&disableAudio=' + (disableAudio ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
var rtmpAvailabilityCache = Object.create(null);
|
||||||
|
var rtmpAvailabilityRetryMs = 5000;
|
||||||
|
var rtmpWarmupTimers = Object.create(null);
|
||||||
|
var rtmpBrowserReadyCache = Object.create(null);
|
||||||
|
var rtmpBrowserWarmupTimers = Object.create(null);
|
||||||
|
var rtmpBrowserWarmupContainer = null;
|
||||||
|
var rtmpBrowserWarmupStates = Object.create(null);
|
||||||
|
|
||||||
|
function getRtmpAvailabilityKey(sourceUrl, disableAudio) {
|
||||||
|
return String(sourceUrl || '').trim() + '\n' + (disableAudio ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRtmpAvailability(sourceUrl, disableAudio, available) {
|
||||||
|
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||||
|
rtmpAvailabilityCache[key] = {
|
||||||
|
available: available === null ? null : Boolean(available),
|
||||||
|
pending: available === null,
|
||||||
|
checkedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRtmpAvailability(sourceUrl, disableAudio) {
|
||||||
|
return rtmpAvailabilityCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRtmpAvailabilityStale(status) {
|
||||||
|
if (!status || !status.checkedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRtmpSlideUnavailable(slide) {
|
||||||
|
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = slide.content || {};
|
||||||
|
return slide.template.regions.some(function (region) {
|
||||||
|
if (region.region_type !== 'rtmp') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionContent = content[region.region_key] || {};
|
||||||
|
var sourceUrl = String(regionContent.value || '').trim();
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||||
|
var serverStatus = getRtmpAvailability(sourceUrl, disableAudio);
|
||||||
|
return Boolean(!serverStatus || isRtmpAvailabilityStale(serverStatus) || serverStatus.available !== true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRtmpAvailabilityForSlide(slide) {
|
||||||
|
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = slide.content || {};
|
||||||
|
slide.template.regions.forEach(function (region) {
|
||||||
|
if (region.region_type !== 'rtmp') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionContent = content[region.region_key] || {};
|
||||||
|
var sourceUrl = String(regionContent.value || '').trim();
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRtmpBrowserReady(sourceUrl, disableAudio, ready) {
|
||||||
|
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||||
|
rtmpBrowserReadyCache[key] = {
|
||||||
|
ready: ready === null ? null : Boolean(ready),
|
||||||
|
checkedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRtmpBrowserReady(sourceUrl, disableAudio) {
|
||||||
|
return rtmpBrowserReadyCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRtmpBrowserReadyStale(status) {
|
||||||
|
if (!status || !status.checkedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRtmpBrowserWarmupContainer() {
|
||||||
|
if (rtmpBrowserWarmupContainer) {
|
||||||
|
return rtmpBrowserWarmupContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
rtmpBrowserWarmupContainer = document.createElement('div');
|
||||||
|
rtmpBrowserWarmupContainer.className = 'rtmp-browser-warmups';
|
||||||
|
rtmpBrowserWarmupContainer.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.appendChild(rtmpBrowserWarmupContainer);
|
||||||
|
return rtmpBrowserWarmupContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRtmpBrowserWarmupState(sourceUrl, disableAudio) {
|
||||||
|
return rtmpBrowserWarmupStates[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRtmpBrowserWarmupState(sourceUrl, disableAudio) {
|
||||||
|
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||||
|
var state = rtmpBrowserWarmupStates[key];
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.video && state.video.__rtmpBrowserWarmupTimer) {
|
||||||
|
window.clearTimeout(state.video.__rtmpBrowserWarmupTimer);
|
||||||
|
state.video.__rtmpBrowserWarmupTimer = null;
|
||||||
|
}
|
||||||
|
if (state.video && state.video.__rtmpHls) {
|
||||||
|
try {
|
||||||
|
state.video.__rtmpHls.destroy();
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
state.video.__rtmpHls = null;
|
||||||
|
}
|
||||||
|
if (state.wrapper && state.wrapper.parentNode) {
|
||||||
|
state.wrapper.parentNode.removeChild(state.wrapper);
|
||||||
|
}
|
||||||
|
delete rtmpBrowserWarmupStates[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function warmupRtmpBrowser(sourceUrl, disableAudio) {
|
||||||
|
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||||
|
var existing = rtmpBrowserWarmupStates[key];
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'rtmp-browser-warmup-entry';
|
||||||
|
wrapper.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
var video = document.createElement('video');
|
||||||
|
video.className = 'template-region-rtmp-video';
|
||||||
|
video.dataset.rtmpSource = sourceUrl;
|
||||||
|
video.dataset.rtmpDisableAudio = disableAudio ? '1' : '0';
|
||||||
|
video.autoplay = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.preload = 'auto';
|
||||||
|
video.tabIndex = -1;
|
||||||
|
video.disablePictureInPicture = true;
|
||||||
|
video.muted = disableAudio;
|
||||||
|
|
||||||
|
wrapper.appendChild(video);
|
||||||
|
ensureRtmpBrowserWarmupContainer().appendChild(wrapper);
|
||||||
|
|
||||||
|
var state = {
|
||||||
|
wrapper: wrapper,
|
||||||
|
video: video,
|
||||||
|
sourceUrl: sourceUrl,
|
||||||
|
disableAudio: disableAudio
|
||||||
|
};
|
||||||
|
rtmpBrowserWarmupStates[key] = state;
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, null);
|
||||||
|
|
||||||
|
var markReady = function () {
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, true);
|
||||||
|
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
|
||||||
|
};
|
||||||
|
|
||||||
|
var markNotReady = function () {
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('canplay', markReady, { once: true });
|
||||||
|
video.addEventListener('playing', markReady, { once: true });
|
||||||
|
video.addEventListener('error', markNotReady, { once: true });
|
||||||
|
|
||||||
|
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||||
|
var hls = new window.Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
liveSyncDurationCount: 4,
|
||||||
|
liveMaxLatencyDurationCount: 8,
|
||||||
|
maxBufferLength: 10,
|
||||||
|
maxLiveSyncPlaybackRate: 1,
|
||||||
|
backBufferLength: 10
|
||||||
|
});
|
||||||
|
video.__rtmpHls = hls;
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
|
||||||
|
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
|
||||||
|
});
|
||||||
|
hls.on(window.Hls.Events.ERROR, function (_event, data) {
|
||||||
|
if (data && data.fatal) {
|
||||||
|
markNotReady();
|
||||||
|
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
markNotReady();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure) {
|
||||||
|
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return null;
|
||||||
|
}).then(function (payload) {
|
||||||
|
return {
|
||||||
|
response: response,
|
||||||
|
payload: payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}).then(function (result) {
|
||||||
|
if (!result || !result.response) {
|
||||||
|
throw new Error('Unable to initialize RTMP stream.');
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = result.response;
|
||||||
|
var payload = result.payload || null;
|
||||||
|
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 503 && payload && payload.probing) {
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, null);
|
||||||
|
if (video && !video.__rtmpBrowserWarmupTimer) {
|
||||||
|
video.__rtmpBrowserWarmupTimer = window.setTimeout(function () {
|
||||||
|
video.__rtmpBrowserWarmupTimer = null;
|
||||||
|
requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('Unable to initialize RTMP stream.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playlistUrl) {
|
||||||
|
throw new Error('RTMP playlist URL was not returned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||||
|
|
||||||
|
if (video.__rtmpHls && window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||||
|
video.__rtmpHls.loadSource(playlistUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video) {
|
||||||
|
video.src = playlistUrl;
|
||||||
|
video.play().catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||||
|
if (typeof onFailure === 'function') {
|
||||||
|
onFailure();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRtmpWarmupSlides(sourceSlides, targetIndex) {
|
||||||
|
var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||||
|
if (!availableSlides.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedIndex = Number(targetIndex || 0);
|
||||||
|
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||||
|
normalizedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var warmupSlides = [];
|
||||||
|
var currentSlide = availableSlides[normalizedIndex];
|
||||||
|
var nextSlide = availableSlides[normalizedIndex + 1];
|
||||||
|
|
||||||
|
if (currentSlide) {
|
||||||
|
warmupSlides.push(currentSlide);
|
||||||
|
}
|
||||||
|
if (nextSlide && nextSlide !== currentSlide) {
|
||||||
|
warmupSlides.push(nextSlide);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warmupSlides;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRtmpWarmupEntries(sourceSlides) {
|
||||||
|
var entries = [];
|
||||||
|
var seen = Object.create(null);
|
||||||
|
|
||||||
|
(Array.isArray(sourceSlides) ? sourceSlides : []).forEach(function (slide) {
|
||||||
|
var content = slide && slide.content ? slide.content : {};
|
||||||
|
var regions = slide && slide.template && Array.isArray(slide.template.regions) ? slide.template.regions : [];
|
||||||
|
regions.forEach(function (region) {
|
||||||
|
if (region.region_type !== 'rtmp') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionContent = content[region.region_key] || {};
|
||||||
|
var url = String(regionContent.value || '').trim();
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||||
|
var key = getRtmpAvailabilityKey(url, disableAudio);
|
||||||
|
if (seen[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[key] = true;
|
||||||
|
entries.push({
|
||||||
|
url: url,
|
||||||
|
disableAudio: disableAudio,
|
||||||
|
key: key
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRtmpWarmups(sourceSlides, targetIndex) {
|
||||||
|
var entries = getRtmpWarmupEntries(getRtmpWarmupSlides(sourceSlides, targetIndex));
|
||||||
|
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
var status = getRtmpAvailability(entry.url, entry.disableAudio);
|
||||||
|
if (status && !isRtmpAvailabilityStale(status) && status.available === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rtmpWarmupTimers[entry.key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWarmup() {
|
||||||
|
fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return null;
|
||||||
|
}).then(function (payload) {
|
||||||
|
return {
|
||||||
|
response: response,
|
||||||
|
payload: payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}).then(function (result) {
|
||||||
|
rtmpWarmupTimers[entry.key] = null;
|
||||||
|
if (!result || !result.response) {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.response.ok) {
|
||||||
|
if (result.response.status === 503 && result.payload && result.payload.probing) {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||||
|
rtmpWarmupTimers[entry.key] = window.setTimeout(function () {
|
||||||
|
rtmpWarmupTimers[entry.key] = null;
|
||||||
|
requestWarmup();
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, true);
|
||||||
|
}).catch(function () {
|
||||||
|
rtmpWarmupTimers[entry.key] = null;
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||||
|
requestWarmup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeRtmpSlideAvailability(slide) {
|
||||||
|
var entries = getRtmpWarmupEntries([slide]);
|
||||||
|
if (!entries.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = await Promise.all(entries.map(function (entry) {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||||
|
return fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return null;
|
||||||
|
}).then(function (payload) {
|
||||||
|
return {
|
||||||
|
response: response,
|
||||||
|
payload: payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}).then(function (result) {
|
||||||
|
if (!result || !result.response) {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.response.ok) {
|
||||||
|
if (result.response.status === 503 && result.payload && result.payload.probing) {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, true);
|
||||||
|
return true;
|
||||||
|
}).catch(function () {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
return results.every(function (value) {
|
||||||
|
return Boolean(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRtmpBrowserWarmups(sourceSlides, targetIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRtmpBrowserUnavailable(slide) {
|
||||||
|
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = slide.content || {};
|
||||||
|
return slide.template.regions.some(function (region) {
|
||||||
|
if (region.region_type !== 'rtmp') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionContent = content[region.region_key] || {};
|
||||||
|
var sourceUrl = String(regionContent.value || '').trim();
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||||
|
var status = getRtmpBrowserReady(sourceUrl, disableAudio);
|
||||||
|
return Boolean(!status || isRtmpBrowserReadyStale(status) || status.ready !== true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRtmpVideoPlaceholder(video, placeholder) {
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var markReady = function () {
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('canplay', markReady, { once: true });
|
||||||
|
video.addEventListener('playing', markReady, { once: true });
|
||||||
|
if (video.readyState >= 2) {
|
||||||
|
markReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, isPreload, onFailure) {
|
||||||
|
var startupTimeoutMs = skipUnavailable ? 6000 : 0;
|
||||||
|
var startupTimer = null;
|
||||||
|
var probeRetryTimer = null;
|
||||||
|
var webReceiveTimer = null;
|
||||||
|
var webReceiveRetryUsed = false;
|
||||||
|
|
||||||
|
function failPlayback(message, silent) {
|
||||||
|
if (video.dataset.rtmpFailureHandled === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
video.dataset.rtmpFailureHandled = '1';
|
||||||
|
if (typeof onFailure === 'function') {
|
||||||
|
onFailure(message, silent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isPreload && skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
placeholder.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStartupTimer() {
|
||||||
|
if (startupTimer) {
|
||||||
|
window.clearTimeout(startupTimer);
|
||||||
|
startupTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpStartupTimer) {
|
||||||
|
video.__rtmpStartupTimer = null;
|
||||||
|
}
|
||||||
|
if (probeRetryTimer) {
|
||||||
|
window.clearTimeout(probeRetryTimer);
|
||||||
|
probeRetryTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpProbeRetryTimer) {
|
||||||
|
video.__rtmpProbeRetryTimer = null;
|
||||||
|
}
|
||||||
|
if (webReceiveTimer) {
|
||||||
|
window.clearTimeout(webReceiveTimer);
|
||||||
|
webReceiveTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpWebReceiveTimer) {
|
||||||
|
video.__rtmpWebReceiveTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryPlay() {
|
||||||
|
if (video && typeof video.play === 'function') {
|
||||||
|
video.play().catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markReceiving() {
|
||||||
|
if (webReceiveTimer) {
|
||||||
|
window.clearTimeout(webReceiveTimer);
|
||||||
|
webReceiveTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpWebReceiveTimer) {
|
||||||
|
video.__rtmpWebReceiveTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReceiveRetry() {
|
||||||
|
if (isPreload || webReceiveRetryUsed || video.dataset.rtmpFailureHandled === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (webReceiveTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webReceiveTimer = window.setTimeout(function () {
|
||||||
|
webReceiveTimer = null;
|
||||||
|
video.__rtmpWebReceiveTimer = null;
|
||||||
|
if (video.dataset.rtmpFailureHandled === '1' || webReceiveRetryUsed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (video.readyState >= 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webReceiveRetryUsed = true;
|
||||||
|
clearStartupTimer();
|
||||||
|
try {
|
||||||
|
if (video.__rtmpHls) {
|
||||||
|
video.__rtmpHls.destroy();
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
video.__rtmpHls = null;
|
||||||
|
requestSession();
|
||||||
|
}, 1200);
|
||||||
|
video.__rtmpWebReceiveTimer = webReceiveTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryWebReceiveOrFail(message, silent) {
|
||||||
|
if (isPreload || video.dataset.rtmpFailureHandled === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!webReceiveRetryUsed) {
|
||||||
|
webReceiveRetryUsed = true;
|
||||||
|
clearStartupTimer();
|
||||||
|
if (webReceiveTimer) {
|
||||||
|
window.clearTimeout(webReceiveTimer);
|
||||||
|
webReceiveTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpWebReceiveTimer) {
|
||||||
|
video.__rtmpWebReceiveTimer = null;
|
||||||
|
}
|
||||||
|
window.setTimeout(function () {
|
||||||
|
requestSession();
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failPlayback(message, silent);
|
||||||
|
}
|
||||||
|
|
||||||
|
video.dataset.rtmpInitialized = '1';
|
||||||
|
video.muted = disableAudio;
|
||||||
|
video.controls = false;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.autoplay = true;
|
||||||
|
|
||||||
|
bindRtmpVideoPlaceholder(video, placeholder);
|
||||||
|
video.addEventListener('canplay', markReceiving, { once: true });
|
||||||
|
video.addEventListener('playing', markReceiving, { once: true });
|
||||||
|
|
||||||
|
if (startupTimeoutMs > 0) {
|
||||||
|
startupTimer = window.setTimeout(function () {
|
||||||
|
startupTimer = null;
|
||||||
|
video.__rtmpStartupTimer = null;
|
||||||
|
failPlayback('Unable to load RTMP stream.', true);
|
||||||
|
}, startupTimeoutMs);
|
||||||
|
video.__rtmpStartupTimer = startupTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryWhileProbing() {
|
||||||
|
if (probeRetryTimer || video.dataset.rtmpFailureHandled === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probeRetryTimer = window.setTimeout(function () {
|
||||||
|
probeRetryTimer = null;
|
||||||
|
video.__rtmpProbeRetryTimer = null;
|
||||||
|
requestSession();
|
||||||
|
}, 500);
|
||||||
|
video.__rtmpProbeRetryTimer = probeRetryTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSession() {
|
||||||
|
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return null;
|
||||||
|
}).then(function (payload) {
|
||||||
|
return {
|
||||||
|
response: response,
|
||||||
|
payload: payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}).then(function (result) {
|
||||||
|
if (!result || !result.response) {
|
||||||
|
throw new Error('Unable to initialize RTMP stream.');
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = result.response;
|
||||||
|
var payload = result.payload || null;
|
||||||
|
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 503 && payload && payload.probing) {
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, null);
|
||||||
|
retryWhileProbing();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||||
|
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, true);
|
||||||
|
if (!playlistUrl) {
|
||||||
|
throw new Error('RTMP playlist URL was not returned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||||
|
var hls = new window.Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
liveSyncDurationCount: 4,
|
||||||
|
liveMaxLatencyDurationCount: 8,
|
||||||
|
maxBufferLength: 20,
|
||||||
|
maxLiveSyncPlaybackRate: 1,
|
||||||
|
backBufferLength: 30
|
||||||
|
});
|
||||||
|
video.__rtmpHls = hls;
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
|
||||||
|
hls.loadSource(playlistUrl);
|
||||||
|
scheduleReceiveRetry();
|
||||||
|
});
|
||||||
|
hls.on(window.Hls.Events.MANIFEST_PARSED, function () {
|
||||||
|
tryPlay();
|
||||||
|
});
|
||||||
|
hls.on(window.Hls.Events.ERROR, function (_event, data) {
|
||||||
|
if (data && data.fatal) {
|
||||||
|
clearStartupTimer();
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||||
|
try {
|
||||||
|
hls.destroy();
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
video.__rtmpHls = null;
|
||||||
|
retryWebReceiveOrFail('RTMP playback failed.', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
video.src = playlistUrl;
|
||||||
|
tryPlay();
|
||||||
|
scheduleReceiveRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
failPlayback('RTMP playback is not supported in this browser.', true);
|
||||||
|
return null;
|
||||||
|
}).catch(function () {
|
||||||
|
clearStartupTimer();
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||||
|
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
requestSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRtmpRegions(root) {
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var videos = root.querySelectorAll('video[data-rtmp-source]');
|
||||||
|
Array.prototype.forEach.call(videos, function (video) {
|
||||||
|
if (!video || video.dataset.rtmpInitialized === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceUrl = String(video.dataset.rtmpSource || '').trim();
|
||||||
|
var disableAudio = String(video.dataset.rtmpDisableAudio || '1') !== '0';
|
||||||
|
var region = video.parentNode;
|
||||||
|
var placeholder = region ? region.querySelector('.template-region-rtmp-placeholder') : null;
|
||||||
|
|
||||||
|
if (!sourceUrl) {
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.textContent = 'RTMP stream';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var skipUnavailable = String(video.dataset.rtmpSkipUnavailable || '0') === '1';
|
||||||
|
var startupTimer = null;
|
||||||
|
|
||||||
|
if (skipUnavailable) {
|
||||||
|
var preflightStatus = getRtmpAvailability(sourceUrl, disableAudio);
|
||||||
|
if (preflightStatus && !isRtmpAvailabilityStale(preflightStatus) && preflightStatus.available === false) {
|
||||||
|
failPlayback('Unable to load RTMP stream.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function failPlayback(message, silent) {
|
||||||
|
if (video.dataset.rtmpFailureHandled === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
video.dataset.rtmpFailureHandled = '1';
|
||||||
|
if (skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
placeholder.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyRtmpRegions(root) {
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var videos = root.querySelectorAll('video[data-rtmp-source]');
|
||||||
|
Array.prototype.forEach.call(videos, function (video) {
|
||||||
|
if (video.__rtmpStartupTimer) {
|
||||||
|
window.clearTimeout(video.__rtmpStartupTimer);
|
||||||
|
video.__rtmpStartupTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpProbeRetryTimer) {
|
||||||
|
window.clearTimeout(video.__rtmpProbeRetryTimer);
|
||||||
|
video.__rtmpProbeRetryTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpWebReceiveTimer) {
|
||||||
|
window.clearTimeout(video.__rtmpWebReceiveTimer);
|
||||||
|
video.__rtmpWebReceiveTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpHls) {
|
||||||
|
try {
|
||||||
|
video.__rtmpHls.destroy();
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
video.__rtmpHls = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
function renderTextRegion(region, regionContent) {
|
||||||
|
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
|
||||||
|
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
||||||
|
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
||||||
|
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||||
|
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(regionContent.value || '') + '</div></div>';
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
var videoRegionLastGoodSrcCache = Object.create(null);
|
||||||
|
var videoRegionProbeStateCache = Object.create(null);
|
||||||
|
var videoRegionProbeTimerCache = Object.create(null);
|
||||||
|
var VIDEO_REGION_RETRY_DELAY_MS = 5000;
|
||||||
|
|
||||||
|
function getVideoRegionCacheKey(region) {
|
||||||
|
return String(region && (region.regionKey || region.label) || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectlyRenderableSource(src) {
|
||||||
|
return /^(?:https?:)?\/\//i.test(src) || /^data:/i.test(src) || /^blob:/i.test(src) || /^\/media\//i.test(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCacheBust(src, cacheBust) {
|
||||||
|
var key = String(cacheBust || '').trim();
|
||||||
|
var raw = String(src || '').trim();
|
||||||
|
if (!raw || !key) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return raw + (raw.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoSourceAvailability(src) {
|
||||||
|
return videoRegionProbeStateCache[String(src || '').trim()] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVideoSourceAvailability(src, available) {
|
||||||
|
var key = String(src || '').trim();
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
videoRegionProbeStateCache[key] = {
|
||||||
|
available: available === null ? null : Boolean(available),
|
||||||
|
checkedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logVideoRegionStatus(message, details, level) {
|
||||||
|
if (typeof logDebug === 'function') {
|
||||||
|
logDebug(message, details || '', level || 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerVideoRegionSourceRefresh() {
|
||||||
|
if (typeof window.notifyVideoRegionSourceReady === 'function') {
|
||||||
|
window.notifyVideoRegionSourceReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof videoRegionRenderVersion === 'number') {
|
||||||
|
videoRegionRenderVersion += 1;
|
||||||
|
}
|
||||||
|
slideMarkupCache = Object.create(null);
|
||||||
|
if (typeof showCurrent === 'function' && slides && slides.length) {
|
||||||
|
showCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleVideoSourceProbe(regionKey, src, isRetry) {
|
||||||
|
var key = String(src || '').trim();
|
||||||
|
if (!regionKey || !key || isDirectlyRenderableSource(key)) {
|
||||||
|
if (key) {
|
||||||
|
setVideoSourceAvailability(key, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoRegionProbeTimerCache[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRetry) {
|
||||||
|
logVideoRegionStatus('Video source changed; probing mirrored file before swapping.', 'region=' + regionKey + ' src=' + key);
|
||||||
|
} else {
|
||||||
|
logVideoRegionStatus('Video source still unavailable; retrying mirrored file probe.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
videoRegionProbeTimerCache[key] = window.setTimeout(function () {
|
||||||
|
delete videoRegionProbeTimerCache[key];
|
||||||
|
fetch(key, {
|
||||||
|
method: 'HEAD',
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (response) {
|
||||||
|
if (response && response.ok) {
|
||||||
|
setVideoSourceAvailability(key, true);
|
||||||
|
if (videoRegionLastGoodSrcCache[regionKey] !== key) {
|
||||||
|
videoRegionLastGoodSrcCache[regionKey] = key;
|
||||||
|
logVideoRegionStatus('Mirrored video is ready; switching to the new source.', 'region=' + regionKey + ' src=' + key);
|
||||||
|
triggerVideoRegionSourceRefresh();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoSourceAvailability(key, false);
|
||||||
|
logVideoRegionStatus('Mirrored video probe returned unavailable; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||||
|
scheduleVideoSourceProbe(regionKey, key, true);
|
||||||
|
}).catch(function () {
|
||||||
|
setVideoSourceAvailability(key, false);
|
||||||
|
logVideoRegionStatus('Mirrored video probe failed; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||||
|
scheduleVideoSourceProbe(regionKey, key, true);
|
||||||
|
});
|
||||||
|
}, isRetry ? VIDEO_REGION_RETRY_DELAY_MS : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVideoRegion(region, regionContent) {
|
||||||
|
var requestedSrc = String(regionContent && regionContent.value || '').trim();
|
||||||
|
var requestedSrcVersioned = appendCacheBust(requestedSrc, regionContent && regionContent.cache_bust);
|
||||||
|
var regionKey = getVideoRegionCacheKey(region);
|
||||||
|
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
|
||||||
|
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
|
||||||
|
|
||||||
|
if (!requestedSrc) {
|
||||||
|
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectlyRenderableSource(requestedSrc)) {
|
||||||
|
if (regionKey) {
|
||||||
|
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||||
|
}
|
||||||
|
setVideoSourceAvailability(requestedSrc, true);
|
||||||
|
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestedState = getVideoSourceAvailability(requestedSrc);
|
||||||
|
var requestedReady = Boolean(requestedState && requestedState.available === true);
|
||||||
|
|
||||||
|
if (requestedReady) {
|
||||||
|
if (regionKey) {
|
||||||
|
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||||
|
}
|
||||||
|
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
|
||||||
|
|
||||||
|
if (cachedSrc) {
|
||||||
|
if (cachedSrc !== requestedSrc) {
|
||||||
|
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
|
||||||
|
}
|
||||||
|
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
function renderWebpageRegion(region, regionContent) {
|
||||||
|
var url = String(regionContent.value || '').trim();
|
||||||
|
var iframe = url ? '<iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>' : '<div class="template-region-placeholder">Webpage</div>';
|
||||||
|
return '<div class="template-region webpage" style="' + region.baseStyle + '">' + iframe + '</div>';
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function mediaKind(mediaPath) {
|
||||||
|
const ext = path.extname(mediaPath || '').toLowerCase();
|
||||||
|
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].includes(ext)) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
if (['.mp4', '.webm', '.ogg'].includes(ext)) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
if (ext === '.pdf') {
|
||||||
|
return 'pdf';
|
||||||
|
}
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFontFamily(value) {
|
||||||
|
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFontSize(value) {
|
||||||
|
return Math.max(8, Number(value || 0) || 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTextColor(value, fallback) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
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'];
|
||||||
|
|
||||||
|
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'],
|
||||||
|
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 + '="' + escapeHtml(value) + '"');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
return attrs.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonForScript(value) {
|
||||||
|
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMaybeJson(value) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const raw = value.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (raw[0] !== '{' && raw[0] !== '[') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (_error) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContentValue(value) {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
value: parseMaybeJson(value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const normalized = {};
|
||||||
|
Object.keys(value).forEach(function (key) {
|
||||||
|
normalized[key] = value[key];
|
||||||
|
});
|
||||||
|
if (normalized.value !== undefined) {
|
||||||
|
normalized.value = parseMaybeJson(normalized.value);
|
||||||
|
} else if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
||||||
|
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
||||||
|
} else if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
||||||
|
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
||||||
|
} else if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
||||||
|
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
||||||
|
} else if (!normalized.type) {
|
||||||
|
normalized.type = 'text';
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSlide(slide) {
|
||||||
|
const normalized = {};
|
||||||
|
Object.keys(slide || {}).forEach(function (key) {
|
||||||
|
normalized[key] = slide[key];
|
||||||
|
});
|
||||||
|
normalized.content = {};
|
||||||
|
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
||||||
|
Object.keys(content).forEach(function (regionKey) {
|
||||||
|
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRichText(html) {
|
||||||
|
let output = String(html || '');
|
||||||
|
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||||
|
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||||
|
return output.replace(/<[^>]+>/g, (tag) => {
|
||||||
|
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||||
|
if (!match) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const closing = Boolean(match[1]);
|
||||||
|
const name = String(match[2] || '').toLowerCase();
|
||||||
|
const attrText = String(match[3] || '');
|
||||||
|
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (closing) {
|
||||||
|
return `</${name}>`;
|
||||||
|
}
|
||||||
|
return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditorJsBlock(block) {
|
||||||
|
if (!block || !block.type || !block.data) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'header') {
|
||||||
|
const level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||||
|
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
||||||
|
} else if (block.type === 'list') {
|
||||||
|
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||||
|
const items = Array.isArray(block.data.items) ? block.data.items : [];
|
||||||
|
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map((item) => renderEditorJsListItem(item, tag)).join('') + '</' + tag + '>';
|
||||||
|
} else if (block.type === 'delimiter') {
|
||||||
|
return '<hr />';
|
||||||
|
} else if (block.type === 'code') {
|
||||||
|
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
||||||
|
} else if (block.type === 'table') {
|
||||||
|
return renderEditorJsTable(block.data);
|
||||||
|
} else if (block.type === 'paragraph') {
|
||||||
|
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditorJsListItem(item, tag) {
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||||
|
const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||||
|
const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map((child) => renderEditorJsListItem(child, tag)).join('') + '</' + tag + '>' : '';
|
||||||
|
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
||||||
|
}
|
||||||
|
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditorJsTable(data) {
|
||||||
|
const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||||
|
if (!rows.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const hasHeadings = Boolean(data.withHeadings);
|
||||||
|
const tableRows = rows.map(function (row, rowIndex) {
|
||||||
|
const cells = Array.isArray(row) ? row : [];
|
||||||
|
const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||||
|
const cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||||
|
return '<tr>' + cells.map(function (cell) {
|
||||||
|
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
||||||
|
}).join('') + '</tr>';
|
||||||
|
}).join('');
|
||||||
|
return '<table class="ck-content-table">' + tableRows + '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditorJsContent(value) {
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
if (Array.isArray(value.blocks)) {
|
||||||
|
return value.blocks.map(renderEditorJsBlock).join('');
|
||||||
|
}
|
||||||
|
if (value.value !== undefined) {
|
||||||
|
return renderEditorJsContent(value.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const raw = String(value || '');
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && Array.isArray(parsed.blocks)) {
|
||||||
|
return parsed.blocks.map(renderEditorJsBlock).join('');
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// fall through to legacy HTML rendering
|
||||||
|
}
|
||||||
|
return sanitizeRichText(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHtmlRegionContent(value) {
|
||||||
|
const html = String(value || '').trim();
|
||||||
|
if (!html) {
|
||||||
|
return '<div class="template-region-placeholder">HTML</div>';
|
||||||
|
}
|
||||||
|
return '<iframe class="template-region-html-frame" sandbox="" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager"></iframe>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||||
|
const width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
||||||
|
const height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
||||||
|
const viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
||||||
|
const viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
||||||
|
const scale = Math.min(viewportWidth / width, viewportHeight / height);
|
||||||
|
return {
|
||||||
|
width: Math.round(width * scale),
|
||||||
|
height: Math.round(height * scale)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
|
||||||
|
const playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html');
|
||||||
|
const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js');
|
||||||
|
const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playlist.js');
|
||||||
|
const playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js');
|
||||||
|
const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js');
|
||||||
|
const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js');
|
||||||
|
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
||||||
|
const playerRegionScriptPaths = [
|
||||||
|
path.join(__dirname, 'regions', 'image.js'),
|
||||||
|
path.join(__dirname, 'regions', 'video.js'),
|
||||||
|
path.join(__dirname, 'regions', 'webpage.js'),
|
||||||
|
path.join(__dirname, 'regions', 'html.js'),
|
||||||
|
path.join(__dirname, 'regions', 'rtmp.js'),
|
||||||
|
path.join(__dirname, 'regions', 'rss.js'),
|
||||||
|
path.join(__dirname, 'regions', 'api.js'),
|
||||||
|
path.join(__dirname, 'regions', 'text.js')
|
||||||
|
];
|
||||||
|
const playerOnboardingLandingScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-landing.script.html');
|
||||||
|
const playerOnboardingFormScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-form.script.html');
|
||||||
|
let playerPageTemplateCache = null;
|
||||||
|
let playerClientNameScriptCache = null;
|
||||||
|
let playerPageOfflineScriptCache = null;
|
||||||
|
let playerPagePlaylistScriptCache = null;
|
||||||
|
let playerPageCommandsScriptCache = null;
|
||||||
|
let playerPageRenderingScriptCache = null;
|
||||||
|
let playerPagePlaybackScriptCache = null;
|
||||||
|
let playerPageScriptCache = null;
|
||||||
|
let playerRegionScriptsCache = null;
|
||||||
|
let playerOnboardingLandingScriptCache = null;
|
||||||
|
let playerOnboardingFormScriptCache = null;
|
||||||
|
|
||||||
|
function loadTemplate(filePath, cache) {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (cache.value && cache.mtimeMs === stat.mtimeMs) {
|
||||||
|
return cache.value;
|
||||||
|
}
|
||||||
|
const compiled = require('handlebars').compile(fs.readFileSync(filePath, 'utf8'));
|
||||||
|
cache.value = compiled;
|
||||||
|
cache.mtimeMs = stat.mtimeMs;
|
||||||
|
return compiled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerPageTemplate() {
|
||||||
|
return loadTemplate(playerPageTemplatePath, playerPageTemplateCache || (playerPageTemplateCache = {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerClientNameScript() {
|
||||||
|
return loadTemplate(playerClientNameScriptPath, playerClientNameScriptCache || (playerClientNameScriptCache = {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerPageOfflineScript() {
|
||||||
|
return loadTemplate(playerPageOfflineScriptPath, playerPageOfflineScriptCache || (playerPageOfflineScriptCache = {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerPagePlaylistScript() {
|
||||||
|
return loadTemplate(playerPagePlaylistScriptPath, playerPagePlaylistScriptCache || (playerPagePlaylistScriptCache = {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerPageCommandsScript() {
|
||||||
|
return loadTemplate(playerPageCommandsScriptPath, playerPageCommandsScriptCache || (playerPageCommandsScriptCache = {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerPageRenderingScript() {
|
||||||
|
return loadTemplate(playerPageRenderingScriptPath, playerPageRenderingScriptCache || (playerPageRenderingScriptCache = {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerPagePlaybackScript() {
|
||||||
|
return loadTemplate(playerPagePlaybackScriptPath, playerPagePlaybackScriptCache || (playerPagePlaybackScriptCache = {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerPageScript() {
|
||||||
|
return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerRegionScripts() {
|
||||||
|
const statSignature = playerRegionScriptPaths.map(function (filePath) {
|
||||||
|
return fs.statSync(filePath).mtimeMs;
|
||||||
|
}).join('|');
|
||||||
|
if (playerRegionScriptsCache && playerRegionScriptsCache.signature === statSignature) {
|
||||||
|
return playerRegionScriptsCache.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = playerRegionScriptPaths.map(function (filePath) {
|
||||||
|
return fs.readFileSync(filePath, 'utf8').trim();
|
||||||
|
}).join('\n\n');
|
||||||
|
playerRegionScriptsCache = {
|
||||||
|
signature: statSignature,
|
||||||
|
value: value
|
||||||
|
};
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerOnboardingLandingScript() {
|
||||||
|
return loadTemplate(playerOnboardingLandingScriptPath, playerOnboardingLandingScriptCache || (playerOnboardingLandingScriptCache = {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerOnboardingFormScript() {
|
||||||
|
return loadTemplate(playerOnboardingFormScriptPath, playerOnboardingFormScriptCache || (playerOnboardingFormScriptCache = {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mediaKind: mediaKind,
|
||||||
|
escapeHtml: escapeHtml,
|
||||||
|
sanitizeFontFamily: sanitizeFontFamily,
|
||||||
|
sanitizeFontSize: sanitizeFontSize,
|
||||||
|
sanitizeTextColor: sanitizeTextColor,
|
||||||
|
sanitizeRichTextAttributes: sanitizeRichTextAttributes,
|
||||||
|
safeJsonForScript: safeJsonForScript,
|
||||||
|
parseMaybeJson: parseMaybeJson,
|
||||||
|
normalizeContentValue: normalizeContentValue,
|
||||||
|
normalizeSlide: normalizeSlide,
|
||||||
|
sanitizeRichText: sanitizeRichText,
|
||||||
|
renderEditorJsBlock: renderEditorJsBlock,
|
||||||
|
renderEditorJsListItem: renderEditorJsListItem,
|
||||||
|
renderEditorJsTable: renderEditorJsTable,
|
||||||
|
renderEditorJsContent: renderEditorJsContent,
|
||||||
|
renderHtmlRegionContent: renderHtmlRegionContent,
|
||||||
|
fitCanvasSize: fitCanvasSize,
|
||||||
|
loadTemplate: loadTemplate,
|
||||||
|
getPlayerPageTemplate: getPlayerPageTemplate,
|
||||||
|
getPlayerClientNameScript: getPlayerClientNameScript,
|
||||||
|
getPlayerPageOfflineScript: getPlayerPageOfflineScript,
|
||||||
|
getPlayerPagePlaylistScript: getPlayerPagePlaylistScript,
|
||||||
|
getPlayerPageCommandsScript: getPlayerPageCommandsScript,
|
||||||
|
getPlayerPageRenderingScript: getPlayerPageRenderingScript,
|
||||||
|
getPlayerPagePlaybackScript: getPlayerPagePlaybackScript,
|
||||||
|
getPlayerPageScript: getPlayerPageScript,
|
||||||
|
getPlayerRegionScripts: getPlayerRegionScripts,
|
||||||
|
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
|
||||||
|
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript
|
||||||
|
};
|
||||||
+123
-233
@@ -1,260 +1,150 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const Handlebars = require('handlebars');
|
const Handlebars = require('handlebars');
|
||||||
|
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
||||||
|
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
|
||||||
|
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
|
||||||
|
|
||||||
function mediaKind(mediaPath) {
|
function renderPage(template, options) {
|
||||||
const ext = path.extname(mediaPath || '').toLowerCase();
|
return template({
|
||||||
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].includes(ext)) {
|
TITLE: options.title,
|
||||||
return 'image';
|
BODY_CLASS: options.bodyClass || '',
|
||||||
}
|
BODY: new Handlebars.SafeString(options.body || ''),
|
||||||
if (['.mp4', '.webm', '.ogg'].includes(ext)) {
|
SCRIPT_BLOCK: new Handlebars.SafeString(options.script || '')
|
||||||
return 'video';
|
|
||||||
}
|
|
||||||
if (ext === '.pdf') {
|
|
||||||
return 'pdf';
|
|
||||||
}
|
|
||||||
return 'file';
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return String(value ?? '')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFontFamily(value) {
|
|
||||||
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFontSize(value) {
|
|
||||||
return Math.max(8, Number(value || 0) || 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeTextColor(value, fallback) {
|
|
||||||
const raw = String(value || '').trim();
|
|
||||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
return fallback || '#000000';
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
|
||||||
|
|
||||||
function safeJsonForScript(value) {
|
|
||||||
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMaybeJson(value) {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
const raw = value.trim();
|
|
||||||
if (!raw) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (raw[0] !== '{' && raw[0] !== '[') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch (_error) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeContentValue(value) {
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
return {
|
|
||||||
type: 'text',
|
|
||||||
value: parseMaybeJson(value)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const normalized = {};
|
|
||||||
Object.keys(value).forEach(function (key) {
|
|
||||||
normalized[key] = value[key];
|
|
||||||
});
|
|
||||||
if (normalized.value !== undefined) {
|
|
||||||
normalized.value = parseMaybeJson(normalized.value);
|
|
||||||
} else if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
|
||||||
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
|
||||||
} else if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
|
||||||
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
|
||||||
} else if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
|
||||||
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
|
||||||
} else if (!normalized.type) {
|
|
||||||
normalized.type = 'text';
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSlide(slide) {
|
|
||||||
const normalized = {};
|
|
||||||
Object.keys(slide || {}).forEach(function (key) {
|
|
||||||
normalized[key] = slide[key];
|
|
||||||
});
|
|
||||||
normalized.content = {};
|
|
||||||
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
|
||||||
Object.keys(content).forEach(function (regionKey) {
|
|
||||||
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
|
||||||
});
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeRichText(html) {
|
|
||||||
let output = String(html || '');
|
|
||||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
||||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
||||||
return output.replace(/<[^>]+>/g, (tag) => {
|
|
||||||
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
|
|
||||||
if (!match) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const closing = Boolean(match[1]);
|
|
||||||
const name = String(match[2] || '').toLowerCase();
|
|
||||||
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (name === 'br') {
|
|
||||||
return '<br>';
|
|
||||||
}
|
|
||||||
return closing ? `</${name}>` : `<${name}>`;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditorJsBlock(block) {
|
function getPlayerServiceWorkerRegistrationScript() {
|
||||||
if (!block || !block.type || !block.data) {
|
return [
|
||||||
return '';
|
'<script>',
|
||||||
}
|
' if ("serviceWorker" in navigator) {',
|
||||||
|
' window.addEventListener("load", function () {',
|
||||||
if (block.type === 'header') {
|
' navigator.serviceWorker.register("/sw.js").catch(function () {',
|
||||||
const level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
' return null;',
|
||||||
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
' });',
|
||||||
} else if (block.type === 'list') {
|
' });',
|
||||||
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
' }',
|
||||||
const items = Array.isArray(block.data.items) ? block.data.items : [];
|
'</script>'
|
||||||
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map((item) => renderEditorJsListItem(item, tag)).join('') + '</' + tag + '>';
|
].join('');
|
||||||
} else if (block.type === 'delimiter') {
|
|
||||||
return '<hr />';
|
|
||||||
} else if (block.type === 'code') {
|
|
||||||
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
|
||||||
} else if (block.type === 'table') {
|
|
||||||
return renderEditorJsTable(block.data);
|
|
||||||
} else if (block.type === 'paragraph') {
|
|
||||||
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditorJsListItem(item, tag) {
|
function renderOnboardingLandingBody() {
|
||||||
if (item && typeof item === 'object') {
|
return [
|
||||||
const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
'<main class="onboarding-shell">',
|
||||||
const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
' <section class="onboarding-card onboarding-card--landing">',
|
||||||
const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map((child) => renderEditorJsListItem(child, tag)).join('') + '</' + tag + '>' : '';
|
' <p class="onboarding-kicker">Pulse Signage</p>',
|
||||||
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
' <h1>Onboard this player</h1>',
|
||||||
}
|
' <p class="onboarding-copy">Choose an existing screen, name the client, and either scan the QR code or finish right here with a keyboard and mouse.</p>',
|
||||||
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
' <div class="onboarding-layout">',
|
||||||
|
' <div class="onboarding-qr-pane">',
|
||||||
|
' <div class="onboarding-qr-frame">',
|
||||||
|
' <img id="onboarding-qr" alt="Onboarding QR code" />',
|
||||||
|
' </div>',
|
||||||
|
' <div id="onboarding-status" class="onboarding-status">Preparing onboarding link...</div>',
|
||||||
|
' </div>',
|
||||||
|
' <form id="onboarding-local-form" class="onboarding-form onboarding-form--local">',
|
||||||
|
' <label>',
|
||||||
|
' <span>Client name</span>',
|
||||||
|
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" autocomplete="off" />',
|
||||||
|
' </label>',
|
||||||
|
' <label>',
|
||||||
|
' <span>Screen</span>',
|
||||||
|
' <select name="screenSlug" id="onboarding-screen-select" required>',
|
||||||
|
' <option value="">Loading screens...</option>',
|
||||||
|
' </select>',
|
||||||
|
' </label>',
|
||||||
|
' <button type="submit">Save client</button>',
|
||||||
|
' <div id="onboarding-message" class="onboarding-status"></div>',
|
||||||
|
' </form>',
|
||||||
|
' </div>',
|
||||||
|
' </section>',
|
||||||
|
'</main>'
|
||||||
|
].join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditorJsTable(data) {
|
function renderOnboardingFormBody(deviceId) {
|
||||||
const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
return [
|
||||||
if (!rows.length) {
|
'<main class="onboarding-shell">',
|
||||||
return '';
|
' <section class="onboarding-card onboarding-card--form">',
|
||||||
}
|
' <p class="onboarding-kicker">Pulse Signage</p>',
|
||||||
const hasHeadings = Boolean(data.withHeadings);
|
' <h1>Name this client</h1>',
|
||||||
const tableRows = rows.map(function (row, rowIndex) {
|
' <p class="onboarding-copy">Pick an existing screen and give this player a friendly name that will persist after refreshes.</p>',
|
||||||
const cells = Array.isArray(row) ? row : [];
|
' <form id="onboarding-form" class="onboarding-form">',
|
||||||
const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
' <label>',
|
||||||
const cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
' <span>Client name</span>',
|
||||||
return '<tr>' + cells.map(function (cell) {
|
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" />',
|
||||||
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
' </label>',
|
||||||
}).join('') + '</tr>';
|
' <label>',
|
||||||
}).join('');
|
' <span>Screen</span>',
|
||||||
return '<table class="editorjs-table">' + tableRows + '</table>';
|
' <select name="screenSlug" id="onboarding-screen-select" required>',
|
||||||
|
' <option value="">Loading screens...</option>',
|
||||||
|
' </select>',
|
||||||
|
' </label>',
|
||||||
|
' <input type="hidden" name="deviceId" value="' + Handlebars.escapeExpression(deviceId || '') + '" />',
|
||||||
|
' <button type="submit">Save client</button>',
|
||||||
|
' <div id="onboarding-message" class="onboarding-status"></div>',
|
||||||
|
' </form>',
|
||||||
|
' </section>',
|
||||||
|
'</main>'
|
||||||
|
].join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditorJsContent(value) {
|
|
||||||
if (value && typeof value === 'object') {
|
function renderOnboardingLandingScript() {
|
||||||
if (Array.isArray(value.blocks)) {
|
return getPlayerOnboardingLandingScript()();
|
||||||
return value.blocks.map(renderEditorJsBlock).join('');
|
|
||||||
}
|
|
||||||
if (value.value !== undefined) {
|
|
||||||
return renderEditorJsContent(value.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const raw = String(value || '');
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (parsed && Array.isArray(parsed.blocks)) {
|
|
||||||
return parsed.blocks.map(renderEditorJsBlock).join('');
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
// fall through to legacy HTML rendering
|
|
||||||
}
|
|
||||||
return sanitizeRichText(raw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHtmlRegionContent(value) {
|
function renderOnboardingFormScript(deviceId) {
|
||||||
const html = String(value || '').trim();
|
return getPlayerOnboardingFormScript()({
|
||||||
if (!html) {
|
DEVICE_ID_JSON: new Handlebars.SafeString(JSON.stringify(deviceId || ''))
|
||||||
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>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
|
||||||
const width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
|
||||||
const height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
|
||||||
const viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
|
||||||
const viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
|
||||||
const scale = Math.min(viewportWidth / width, viewportHeight / height);
|
|
||||||
return {
|
|
||||||
width: Math.round(width * scale),
|
|
||||||
height: Math.round(height * scale)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
|
|
||||||
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
|
||||||
let playerPageTemplateCache = null;
|
|
||||||
let playerPageScriptCache = null;
|
|
||||||
|
|
||||||
function loadTemplate(filePath, cache) {
|
|
||||||
const stat = fs.statSync(filePath);
|
|
||||||
if (cache.value && cache.mtimeMs === stat.mtimeMs) {
|
|
||||||
return cache.value;
|
|
||||||
}
|
|
||||||
const compiled = Handlebars.compile(fs.readFileSync(filePath, 'utf8'));
|
|
||||||
cache.value = compiled;
|
|
||||||
cache.mtimeMs = stat.mtimeMs;
|
|
||||||
return compiled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPlayerPageTemplate() {
|
|
||||||
return loadTemplate(playerPageTemplatePath, playerPageTemplateCache || (playerPageTemplateCache = {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPlayerPageScript() {
|
|
||||||
return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPlayerPage(slug, initialData) {
|
function renderPlayerPage(slug, initialData) {
|
||||||
|
const onboardingScript = getPlayerClientNameScript()();
|
||||||
|
const offlineScript = getPlayerPageOfflineScript()();
|
||||||
|
const playlistScript = getPlayerPagePlaylistScript()();
|
||||||
|
const commandScript = getPlayerPageCommandsScript()();
|
||||||
|
const renderingScript = getPlayerPageRenderingScript()();
|
||||||
|
const playbackScript = getPlayerPagePlaybackScript()();
|
||||||
|
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript();
|
||||||
const template = getPlayerPageTemplate();
|
const template = getPlayerPageTemplate();
|
||||||
|
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
|
||||||
|
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
|
||||||
const script = getPlayerPageScript()({
|
const script = getPlayerPageScript()({
|
||||||
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
||||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null))
|
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
|
||||||
|
REGION_SCRIPTS: new Handlebars.SafeString(getPlayerRegionScripts())
|
||||||
});
|
});
|
||||||
|
|
||||||
return template({
|
return renderPage(template, {
|
||||||
TITLE: 'Screen ' + slug,
|
title: 'Screen ' + slug,
|
||||||
SCRIPT_BLOCK: new Handlebars.SafeString(script)
|
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||||
|
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + onboardingScript + script
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlayerOnboardingLandingPage() {
|
||||||
|
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding' });
|
||||||
|
return renderPage(getPlayerPageTemplate(), {
|
||||||
|
title: 'Onboard player',
|
||||||
|
bodyClass: 'onboarding-page',
|
||||||
|
body: renderOnboardingLandingBody(),
|
||||||
|
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlayerOnboardingFormPage(deviceId) {
|
||||||
|
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding', deviceId: String(deviceId || '').trim() });
|
||||||
|
return renderPage(getPlayerPageTemplate(), {
|
||||||
|
title: 'Onboard screen',
|
||||||
|
bodyClass: 'onboarding-page',
|
||||||
|
body: renderOnboardingFormBody(deviceId),
|
||||||
|
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingFormScript(deviceId)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mediaKind,
|
mediaKind,
|
||||||
renderPlayerPage
|
renderPlayerPage,
|
||||||
|
renderPlayerOnboardingLandingPage,
|
||||||
|
renderPlayerOnboardingFormPage
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
||||||
|
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
||||||
|
|
||||||
|
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
||||||
|
|
||||||
|
function isTransientDbError(error) {
|
||||||
|
return Boolean(error && TRANSIENT_DB_ERROR_CODES.indexOf(String(error.code || '').trim()) !== -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerPlayerRoutes(app, options) {
|
||||||
|
const pool = options && options.pool ? options.pool : null;
|
||||||
|
const common = options && options.common ? options.common : null;
|
||||||
|
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
|
||||||
|
const assetDir = options && options.assetDir ? options.assetDir : null;
|
||||||
|
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||||
|
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
|
||||||
|
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
|
||||||
|
|
||||||
|
if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) {
|
||||||
|
throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and rtmpStreamService.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedSecret = getSharedSecret();
|
||||||
|
|
||||||
|
function requirePageAuth(allowedScopes) {
|
||||||
|
return function (req, res, next) {
|
||||||
|
if (!sharedSecret) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
|
||||||
|
const payload = verifyPageAuthToken(token);
|
||||||
|
if (!payload) {
|
||||||
|
return res.status(401).json({ error: 'Page authentication required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes = Array.isArray(allowedScopes) ? allowedScopes : [];
|
||||||
|
if (scopes.length && scopes.indexOf(String(payload.scope || '').trim()) === -1) {
|
||||||
|
return res.status(403).json({ error: 'Page authentication scope is not allowed for this route.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.playerPageAuth = payload;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRequestAuth(req, res, next) {
|
||||||
|
if (!sharedSecret) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyRequestAuth(req)) {
|
||||||
|
return res.status(401).json({ error: 'Request authentication required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMediaFilePath(fileName) {
|
||||||
|
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
|
||||||
|
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedMediaDir = path.resolve(mediaDir);
|
||||||
|
const resolvedFilePath = path.resolve(mediaDir, relativePath);
|
||||||
|
if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use('/assets', express.static(assetDir));
|
||||||
|
app.use('/media', express.static(mediaDir));
|
||||||
|
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
|
||||||
|
|
||||||
|
app.get('/sw.js', function (_req, res) {
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.type('application/javascript');
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'sw.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/page', function (req, res, next) {
|
||||||
|
try {
|
||||||
|
if (!sharedSecret) {
|
||||||
|
return res.status(404).json({ error: 'Page authentication is disabled.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
|
||||||
|
const payload = verifyPageAuthToken(token);
|
||||||
|
if (!payload || ['player', 'onboarding'].indexOf(String(payload.scope || '').trim()) === -1) {
|
||||||
|
return res.status(401).json({ error: 'Page authentication required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenBundle = createPageAuthBundle({
|
||||||
|
scope: payload.scope,
|
||||||
|
slug: payload.slug || null,
|
||||||
|
deviceId: payload.deviceId || null
|
||||||
|
});
|
||||||
|
res.json(tokenBundle);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
|
||||||
|
res.json({
|
||||||
|
mediaDir: mediaDir,
|
||||||
|
uploadDir: path.join(mediaDir, 'uploads')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const filePath = resolveMediaFilePath(req.params.filename);
|
||||||
|
if (!filePath) {
|
||||||
|
return res.status(400).json({ error: 'Filename is required' });
|
||||||
|
}
|
||||||
|
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
||||||
|
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fs.promises.writeFile(filePath, body);
|
||||||
|
res.json({ ok: true, filename: req.params.filename });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const filePath = resolveMediaFilePath(req.params.filename);
|
||||||
|
if (!filePath) {
|
||||||
|
return res.status(400).json({ error: 'Filename is required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!error || error.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ ok: true, filename: req.params.filename });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/rtmp/session', requirePageAuth(['player']), async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const source = String(req.query.source || '').trim();
|
||||||
|
const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase();
|
||||||
|
const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on';
|
||||||
|
const status = await rtmpStreamService.getSessionStatus(source, useMutedOutput);
|
||||||
|
const session = status.session;
|
||||||
|
const ready = Boolean(status.ready);
|
||||||
|
const live = Boolean(status.live);
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
if (!ready || !live) {
|
||||||
|
return res.status(503).json({
|
||||||
|
ready: false,
|
||||||
|
live: false,
|
||||||
|
timedOut: Boolean(status.timedOut),
|
||||||
|
stderr: String(status.stderr || '')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
key: session.key,
|
||||||
|
playlistUrl: session.playlistUrl,
|
||||||
|
disableAudio: session.disableAudio,
|
||||||
|
ready: true,
|
||||||
|
live: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/rtmp/streams/:key/index.m3u8', async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
const manifestPath = await rtmpStreamService.getManifestFilePath(req.params.key);
|
||||||
|
if (!manifestPath) {
|
||||||
|
return res.status(404).send('Stream not found');
|
||||||
|
}
|
||||||
|
res.sendFile(manifestPath);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/rtmp/streams/:key/:fileName', async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const segmentPath = await rtmpStreamService.getSegmentFilePath(req.params.key, req.params.fileName);
|
||||||
|
if (!segmentPath) {
|
||||||
|
return res.status(404).send('Stream not found');
|
||||||
|
}
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.sendFile(segmentPath);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/screen/:slug', function (req, res) {
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.set('Pragma', 'no-cache');
|
||||||
|
playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) {
|
||||||
|
res.send(common.renderPlayerPage(req.params.slug, data));
|
||||||
|
}).catch(function (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.set('X-Player-Offline', '1');
|
||||||
|
res.send(common.renderPlayerPage(req.params.slug, null));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||||
|
if (!slide) {
|
||||||
|
return res.status(404).send('Slide not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = buildThumbnailPreviewData(slide);
|
||||||
|
|
||||||
|
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
|
||||||
|
const rssData = await common.fetchRssFeedsData(pool);
|
||||||
|
data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||||
|
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
|
||||||
|
return Object.assign({}, feed, {
|
||||||
|
items: items.map(function (item) {
|
||||||
|
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof common.fetchApiSourcesData === 'function') {
|
||||||
|
const apiData = await common.fetchApiSourcesData(pool);
|
||||||
|
data.apiSources = (apiData.apiSources || []).map(function (source) {
|
||||||
|
return Object.assign({}, source, {
|
||||||
|
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
|
||||||
|
if (!data.screen) {
|
||||||
|
return res.status(404).json({ error: 'Screen not found' });
|
||||||
|
}
|
||||||
|
const etag = '"' + String(data.revision || '') + '"';
|
||||||
|
res.set('ETag', etag);
|
||||||
|
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}).includes(etag)) {
|
||||||
|
return res.status(304).end();
|
||||||
|
}
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], requireRequestAuth, async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const connections = playerRuntime.snapshotConnections(req.params.slug);
|
||||||
|
let screen = null;
|
||||||
|
let screenLookupFailed = false;
|
||||||
|
try {
|
||||||
|
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||||
|
screen = screenRows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
screenLookupFailed = isTransientDbError(error);
|
||||||
|
if (!screenLookupFailed) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
screen: screen,
|
||||||
|
screenSlug: req.params.slug,
|
||||||
|
count: connections.length,
|
||||||
|
connections: connections,
|
||||||
|
degraded: screenLookupFailed
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/screens/:slug/commands', requireRequestAuth, async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||||
|
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
|
||||||
|
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||||
|
? req.body.blackout
|
||||||
|
: req.query.blackout;
|
||||||
|
if (!command) {
|
||||||
|
return res.status(400).json({ error: 'Command is required' });
|
||||||
|
}
|
||||||
|
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right', 'setclientname'].indexOf(command) === -1) {
|
||||||
|
return res.status(400).json({ error: 'Unsupported command' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveConnections = playerRuntime.snapshotConnections(req.params.slug);
|
||||||
|
const isRedirectCommand = command === 'redirect';
|
||||||
|
let screen = null;
|
||||||
|
let screenLookupFailed = false;
|
||||||
|
if (!isRedirectCommand) {
|
||||||
|
try {
|
||||||
|
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||||
|
screen = screenRows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
screenLookupFailed = isTransientDbError(error);
|
||||||
|
if (!screenLookupFailed) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!screen && liveConnections.length) {
|
||||||
|
screen = {
|
||||||
|
name: req.params.slug,
|
||||||
|
slug: req.params.slug
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||||
|
? Object.assign({}, req.body, { command: command })
|
||||||
|
: command;
|
||||||
|
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
||||||
|
commandPayload.blackout = blackoutValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = connectionId
|
||||||
|
? await playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
||||||
|
: await playerRuntime.broadcastCommand(req.params.slug, commandPayload);
|
||||||
|
|
||||||
|
if (!screen && !screenLookupFailed && !liveConnections.length) {
|
||||||
|
return res.status(404).json({ error: 'Screen not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!screen && screenLookupFailed && !liveConnections.length) {
|
||||||
|
return res.status(503).json({ error: 'Screen metadata unavailable while the database is down.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
screen: screen,
|
||||||
|
screenSlug: req.params.slug,
|
||||||
|
command: command,
|
||||||
|
connectionId: connectionId || null,
|
||||||
|
sent: sent,
|
||||||
|
degraded: screenLookupFailed
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerPlayerRoutes: registerPlayerRoutes
|
||||||
|
};
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const { WebSocketServer, WebSocket } = require('ws');
|
||||||
|
const { isClientNameAvailable } = require('../data/client-name-check');
|
||||||
|
const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
||||||
|
|
||||||
|
function createPlayerRuntime(options) {
|
||||||
|
const pool = options && options.pool ? options.pool : null;
|
||||||
|
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
|
||||||
|
? options.normalizeDeviceId
|
||||||
|
: function (value) {
|
||||||
|
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
||||||
|
};
|
||||||
|
const connectionsBySlug = new Map();
|
||||||
|
const dashboardListenersBySlug = new Map();
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
function normalizeClientIp(value) {
|
||||||
|
const ip = String(value || '').trim();
|
||||||
|
if (!ip) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ip.toLowerCase().startsWith('::ffff:')) {
|
||||||
|
return ip.slice(7).trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnectionBucket(slug) {
|
||||||
|
const key = String(slug || '').trim();
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!connectionsBySlug.has(key)) {
|
||||||
|
connectionsBySlug.set(key, new Map());
|
||||||
|
}
|
||||||
|
return connectionsBySlug.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeConnection(slug, connectionId) {
|
||||||
|
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||||
|
if (!bucket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bucket.delete(connectionId);
|
||||||
|
if (!bucket.size) {
|
||||||
|
connectionsBySlug.delete(String(slug || '').trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDashboardListenerBucket(slug) {
|
||||||
|
const key = String(slug || '').trim();
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!dashboardListenersBySlug.has(key)) {
|
||||||
|
dashboardListenersBySlug.set(key, new Set());
|
||||||
|
}
|
||||||
|
return dashboardListenersBySlug.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDashboardListener(slug, socket) {
|
||||||
|
const key = String(slug || '').trim();
|
||||||
|
const bucket = dashboardListenersBySlug.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bucket.delete(socket);
|
||||||
|
if (!bucket.size) {
|
||||||
|
dashboardListenersBySlug.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClientLabel(connection) {
|
||||||
|
const clientName = String(connection.clientName || '').trim();
|
||||||
|
const clientId = String(connection.clientId || '').trim();
|
||||||
|
const userAgent = String(connection.userAgent || '').trim();
|
||||||
|
const clientIp = String(connection.clientIp || '').trim();
|
||||||
|
const viewport = connection.viewport && typeof connection.viewport === 'object'
|
||||||
|
? connection.viewport
|
||||||
|
: null;
|
||||||
|
const labelParts = [];
|
||||||
|
|
||||||
|
if (userAgent) {
|
||||||
|
labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientName) {
|
||||||
|
labelParts.push(clientName);
|
||||||
|
} else if (clientId) {
|
||||||
|
labelParts.push(`id ${clientId.slice(-6)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientIp) {
|
||||||
|
labelParts.push(clientIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
|
||||||
|
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!labelParts.length) {
|
||||||
|
return connection.remoteAddress || 'connected client';
|
||||||
|
}
|
||||||
|
|
||||||
|
return labelParts.join(' • ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotConnections(slug) {
|
||||||
|
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||||
|
if (!bucket) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(bucket.values()).map(function (connection) {
|
||||||
|
return {
|
||||||
|
id: connection.id,
|
||||||
|
clientId: connection.clientId || null,
|
||||||
|
clientName: connection.clientName || null,
|
||||||
|
deviceId: connection.deviceId || null,
|
||||||
|
label: connection.label,
|
||||||
|
userAgent: connection.userAgent || null,
|
||||||
|
viewport: connection.viewport || null,
|
||||||
|
page: connection.page || null,
|
||||||
|
currentSlide: connection.currentSlide || null,
|
||||||
|
paused: Boolean(connection.paused),
|
||||||
|
blackout: Boolean(connection.blackout),
|
||||||
|
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
|
||||||
|
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null,
|
||||||
|
clientIp: connection.clientIp || null,
|
||||||
|
remoteAddress: connection.remoteAddress || null,
|
||||||
|
connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
|
||||||
|
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotAllConnections() {
|
||||||
|
const allConnections = [];
|
||||||
|
for (const bucket of connectionsBySlug.values()) {
|
||||||
|
if (!bucket || typeof bucket.values !== 'function') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const connection of bucket.values()) {
|
||||||
|
allConnections.push({
|
||||||
|
clientId: connection.clientId || null,
|
||||||
|
clientName: connection.clientName || null,
|
||||||
|
deviceId: connection.deviceId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allConnections;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
|
||||||
|
return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections());
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastConnectionSnapshot(slug) {
|
||||||
|
const key = String(slug || '').trim();
|
||||||
|
const bucket = dashboardListenersBySlug.get(key);
|
||||||
|
if (!bucket || !bucket.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
type: 'snapshot',
|
||||||
|
slug: key,
|
||||||
|
connections: snapshotConnections(slug),
|
||||||
|
sentAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
bucket.forEach(function (socket) {
|
||||||
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCommandToConnection(slug, connectionId, commandOrPayload) {
|
||||||
|
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||||
|
if (!bucket || !bucket.size) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = bucket.get(String(connectionId || '').trim());
|
||||||
|
if (!target || target.socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||||
|
? Object.assign({}, commandOrPayload)
|
||||||
|
: { command: commandOrPayload };
|
||||||
|
payload.type = 'command';
|
||||||
|
payload.targetConnectionId = target.id;
|
||||||
|
payload.sentAt = new Date().toISOString();
|
||||||
|
|
||||||
|
target.socket.send(JSON.stringify(payload));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function broadcastCommand(slug, commandOrPayload) {
|
||||||
|
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||||
|
if (!bucket || !bucket.size) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
bucket.forEach(function (connection) {
|
||||||
|
if (connection.socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||||
|
? Object.assign({}, commandOrPayload)
|
||||||
|
: { command: commandOrPayload };
|
||||||
|
payload.type = 'command';
|
||||||
|
payload.sentAt = new Date().toISOString();
|
||||||
|
|
||||||
|
connection.socket.send(JSON.stringify(payload));
|
||||||
|
sent += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpgrade(request, socket, head) {
|
||||||
|
let pathname = '';
|
||||||
|
try {
|
||||||
|
pathname = new URL(request.url, 'http://localhost').pathname;
|
||||||
|
} catch (_error) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
|
||||||
|
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
|
||||||
|
if (!dashboardMatch && !playerMatch) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dashboardMatch) {
|
||||||
|
if (!verifyRequestAuth(request)) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerMatch) {
|
||||||
|
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
||||||
|
const payload = verifyPageAuthToken(authToken);
|
||||||
|
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]);
|
||||||
|
wss.handleUpgrade(request, socket, head, function (ws) {
|
||||||
|
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.on('connection', function (socket, request, slug, role) {
|
||||||
|
if (role === 'dashboard') {
|
||||||
|
const listenerBucket = getDashboardListenerBucket(slug);
|
||||||
|
if (!listenerBucket) {
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listenerBucket.add(socket);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'snapshot',
|
||||||
|
slug: String(slug || '').trim(),
|
||||||
|
connections: snapshotConnections(slug),
|
||||||
|
sentAt: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
socket.on('close', function () {
|
||||||
|
removeDashboardListener(slug, socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', function () {
|
||||||
|
removeDashboardListener(slug, socket);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
|
||||||
|
const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]);
|
||||||
|
const normalizedRemoteAddress = normalizeClientIp(remoteAddress);
|
||||||
|
const connectionId = crypto.randomUUID();
|
||||||
|
const connection = {
|
||||||
|
id: connectionId,
|
||||||
|
slug: slug,
|
||||||
|
socket: socket,
|
||||||
|
clientId: null,
|
||||||
|
clientName: null,
|
||||||
|
deviceId: null,
|
||||||
|
userAgent: null,
|
||||||
|
viewport: null,
|
||||||
|
page: null,
|
||||||
|
paused: false,
|
||||||
|
blackout: false,
|
||||||
|
clientIp: forwardedFor || normalizedRemoteAddress,
|
||||||
|
remoteAddress: normalizedRemoteAddress,
|
||||||
|
label: forwardedFor || normalizedRemoteAddress || 'connected client',
|
||||||
|
connectedAt: new Date(),
|
||||||
|
lastSeenAt: new Date()
|
||||||
|
};
|
||||||
|
const bucket = getConnectionBucket(slug);
|
||||||
|
|
||||||
|
if (!bucket) {
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.set(connectionId, connection);
|
||||||
|
|
||||||
|
socket.on('message', function (rawMessage) {
|
||||||
|
connection.lastSeenAt = new Date();
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(String(rawMessage || ''));
|
||||||
|
} catch (_error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
|
||||||
|
connection.clientName = payload.clientName ? String(payload.clientName).trim() : connection.clientName;
|
||||||
|
connection.deviceId = payload.deviceId ? normalizeDeviceId(payload.deviceId) || connection.deviceId : connection.deviceId;
|
||||||
|
if (!connection.clientName && connection.clientId) {
|
||||||
|
connection.clientName = connection.clientId;
|
||||||
|
}
|
||||||
|
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
|
||||||
|
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
|
||||||
|
connection.page = payload.page ? String(payload.page).trim() : connection.page;
|
||||||
|
connection.paused = Boolean(payload.paused);
|
||||||
|
connection.blackout = Boolean(payload.blackout);
|
||||||
|
connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp;
|
||||||
|
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
|
||||||
|
id: payload.currentSlide.id || null,
|
||||||
|
title: payload.currentSlide.title || '',
|
||||||
|
kind: payload.currentSlide.kind || '',
|
||||||
|
playlistSignature: payload.currentSlide.playlistSignature || ''
|
||||||
|
} : connection.currentSlide;
|
||||||
|
connection.label = buildClientLabel(connection);
|
||||||
|
connection.lastSeenAt = new Date();
|
||||||
|
broadcastConnectionSnapshot(slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', function () {
|
||||||
|
removeConnection(slug, connectionId);
|
||||||
|
broadcastConnectionSnapshot(slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', function () {
|
||||||
|
removeConnection(slug, connectionId);
|
||||||
|
broadcastConnectionSnapshot(slug);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function installWebsocket(server) {
|
||||||
|
server.on('upgrade', handleUpgrade);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installWebsocket: installWebsocket,
|
||||||
|
snapshotConnections: snapshotConnections,
|
||||||
|
snapshotAllConnections: snapshotAllConnections,
|
||||||
|
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
|
||||||
|
sendCommandToConnection: sendCommandToConnection,
|
||||||
|
broadcastCommand: broadcastCommand
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPlayerRuntime: createPlayerRuntime
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
function buildThumbnailPreviewData(slide) {
|
||||||
|
return {
|
||||||
|
thumbnailPreview: true,
|
||||||
|
screen: {
|
||||||
|
id: slide.id,
|
||||||
|
name: slide.title,
|
||||||
|
slug: 'slide-thumbnail-preview-' + slide.id,
|
||||||
|
playlist_id: null
|
||||||
|
},
|
||||||
|
playlist: {
|
||||||
|
fade_between_slides: false
|
||||||
|
},
|
||||||
|
slides: [slide],
|
||||||
|
rssFeeds: [],
|
||||||
|
apiSources: [],
|
||||||
|
revision: String(slide.modified_at || slide.id || Date.now())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThumbnailPreviewBootstrapScript(initialData) {
|
||||||
|
return initialData && initialData.thumbnailPreview ? '<script>window.__pulseThumbnailPreview = true;</script>' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildThumbnailPreviewData: buildThumbnailPreviewData,
|
||||||
|
createThumbnailPreviewBootstrapScript: createThumbnailPreviewBootstrapScript
|
||||||
|
};
|
||||||
+269
@@ -0,0 +1,269 @@
|
|||||||
|
const PERMISSION_SECTIONS = [
|
||||||
|
{
|
||||||
|
key: 'dashboard',
|
||||||
|
order: 10,
|
||||||
|
name: 'Dashboard',
|
||||||
|
sectionName: 'Main navigation',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' },
|
||||||
|
{ key: 'allow', name: 'Allow', description: 'Send global player commands.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'clients',
|
||||||
|
order: 20,
|
||||||
|
name: 'Connected clients',
|
||||||
|
sectionName: 'Main navigation',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View connected player clients and live status.' },
|
||||||
|
{ key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'screens',
|
||||||
|
order: 30,
|
||||||
|
name: 'Screens',
|
||||||
|
sectionName: 'Content',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View the screen list and open screen details.' },
|
||||||
|
{ key: 'create', name: 'Create', description: 'Create new screens.' },
|
||||||
|
{ key: 'update', name: 'Update', description: 'Update screens.' },
|
||||||
|
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'playlists',
|
||||||
|
order: 40,
|
||||||
|
name: 'Playlists',
|
||||||
|
sectionName: 'Content',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View playlists and playlist contents.' },
|
||||||
|
{ key: 'create', name: 'Create', description: 'Create new playlists.' },
|
||||||
|
{ key: 'update', name: 'Update', description: 'Update playlists and playlist slides.' },
|
||||||
|
{ key: 'delete', name: 'Delete', description: 'Delete playlists.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'slides',
|
||||||
|
order: 50,
|
||||||
|
name: 'Slides',
|
||||||
|
sectionName: 'Content',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View slides.' },
|
||||||
|
{ key: 'create', name: 'Create', description: 'Create new slides.' },
|
||||||
|
{ key: 'update', name: 'Update', description: 'Update slide content.' },
|
||||||
|
{ key: 'delete', name: 'Delete', description: 'Delete slides.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'templates',
|
||||||
|
order: 60,
|
||||||
|
name: 'Slide templates',
|
||||||
|
sectionName: 'Content',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View slide templates.' },
|
||||||
|
{ key: 'create', name: 'Create', description: 'Create new slide templates.' },
|
||||||
|
{ key: 'update', name: 'Update', description: 'Update slide templates.' },
|
||||||
|
{ key: 'delete', name: 'Delete', description: 'Delete slide templates.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'canvas-sizes',
|
||||||
|
order: 70,
|
||||||
|
name: 'Canvas sizes',
|
||||||
|
sectionName: 'Content',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View canvas sizes.' },
|
||||||
|
{ key: 'create', name: 'Create', description: 'Create new canvas sizes.' },
|
||||||
|
{ key: 'update', name: 'Update', description: 'Update canvas sizes.' },
|
||||||
|
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rss-feeds',
|
||||||
|
order: 75,
|
||||||
|
name: 'RSS feeds',
|
||||||
|
sectionName: 'Data Sources',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View configured RSS feeds.' },
|
||||||
|
{ key: 'create', name: 'Create', description: 'Create new RSS feeds.' },
|
||||||
|
{ key: 'update', name: 'Update', description: 'Update RSS feeds.' },
|
||||||
|
{ key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'api-sources',
|
||||||
|
order: 76,
|
||||||
|
name: 'API sources',
|
||||||
|
sectionName: 'Data Sources',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View configured API sources.' },
|
||||||
|
{ key: 'create', name: 'Create', description: 'Create new API sources.' },
|
||||||
|
{ key: 'update', name: 'Update', description: 'Update API sources.' },
|
||||||
|
{ key: 'delete', name: 'Delete', description: 'Delete API sources.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'background-tasks',
|
||||||
|
order: 100,
|
||||||
|
name: 'Background tasks',
|
||||||
|
sectionName: 'Settings',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
|
||||||
|
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scheduled-tasks',
|
||||||
|
order: 101,
|
||||||
|
name: 'Scheduled tasks',
|
||||||
|
sectionName: 'Settings',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
|
||||||
|
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users',
|
||||||
|
order: 80,
|
||||||
|
name: 'Users',
|
||||||
|
sectionName: 'Settings',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View users and role assignments.' },
|
||||||
|
{ key: 'create', name: 'Create', description: 'Create new users.' },
|
||||||
|
{ key: 'update', name: 'Update', description: 'Update users, passwords, and role assignments.' },
|
||||||
|
{ key: 'delete', name: 'Delete', description: 'Delete users.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rbac',
|
||||||
|
order: 90,
|
||||||
|
name: 'Roles and permissions',
|
||||||
|
sectionName: 'Settings',
|
||||||
|
actions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View roles and permissions.' },
|
||||||
|
{ key: 'create', name: 'Create', description: 'Create new roles.' },
|
||||||
|
{ key: 'update', name: 'Update', description: 'Update role details and permissions.' },
|
||||||
|
{ key: 'delete', name: 'Delete', description: 'Delete roles.' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) {
|
||||||
|
return section.actions.map(function (action) {
|
||||||
|
return {
|
||||||
|
key: `${section.key}.${action.key}`,
|
||||||
|
name: section.name,
|
||||||
|
sectionOrder: section.order,
|
||||||
|
actionName: action.name,
|
||||||
|
sectionName: section.sectionName,
|
||||||
|
sectionKey: section.key,
|
||||||
|
actionKey: action.key,
|
||||||
|
description: action.description
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_ROLE = {
|
||||||
|
key: 'administrators',
|
||||||
|
name: 'Administrators',
|
||||||
|
description: 'Full access to the admin interface.'
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePermissionKey(permissionKey) {
|
||||||
|
return String(permissionKey || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePermissionKeys(permissionKeys) {
|
||||||
|
const normalized = [];
|
||||||
|
|
||||||
|
(Array.isArray(permissionKeys) ? permissionKeys : []).forEach(function (permissionKey) {
|
||||||
|
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
|
||||||
|
if (!normalizedPermissionKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = normalizedPermissionKey.split('.');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
normalized.push(normalizedPermissionKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionKey = parts[0];
|
||||||
|
const actionKey = parts[1];
|
||||||
|
normalized.push(`${sectionKey}.${actionKey}`);
|
||||||
|
|
||||||
|
if (actionKey === 'create' || actionKey === 'update' || actionKey === 'delete') {
|
||||||
|
normalized.push(`${sectionKey}.read`);
|
||||||
|
}
|
||||||
|
if (actionKey === 'manage') {
|
||||||
|
normalized.push(`${sectionKey}.read`);
|
||||||
|
normalized.push(`${sectionKey}.create`);
|
||||||
|
normalized.push(`${sectionKey}.update`);
|
||||||
|
normalized.push(`${sectionKey}.delete`);
|
||||||
|
}
|
||||||
|
if (actionKey === 'view') {
|
||||||
|
normalized.push(`${sectionKey}.read`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(new Set(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermission(currentUser, permissionKey) {
|
||||||
|
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
|
||||||
|
if (!normalizedPermissionKey || !currentUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionKeys = Array.isArray(currentUser.permissionKeys)
|
||||||
|
? currentUser.permissionKeys
|
||||||
|
: Array.isArray(currentUser.permissions)
|
||||||
|
? currentUser.permissions
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return normalizePermissionKeys(permissionKeys).includes(normalizedPermissionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyPermission(currentUser, permissionKeys) {
|
||||||
|
const normalizedPermissionKeys = normalizePermissionKeys(permissionKeys);
|
||||||
|
if (!normalizedPermissionKeys.length || !currentUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedPermissionKeys.some(function (permissionKey) {
|
||||||
|
return hasPermission(currentUser, permissionKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePermission(permissionKey) {
|
||||||
|
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
|
||||||
|
if (!normalizedPermissionKey) {
|
||||||
|
throw new Error('requirePermission requires a permission key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return function (req, res, next) {
|
||||||
|
if (!req.currentUser) {
|
||||||
|
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPermission(req.currentUser, normalizedPermissionKey)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error('You do not have permission to access this area.');
|
||||||
|
error.statusCode = 403;
|
||||||
|
error.expose = true;
|
||||||
|
next(error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PERMISSIONS,
|
||||||
|
PERMISSION_SECTIONS,
|
||||||
|
DEFAULT_ROLE,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
requirePermission,
|
||||||
|
normalizePermissionKeys
|
||||||
|
};
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const PAGE_TOKEN_HEADER = 'x-pulse-page-auth';
|
||||||
|
const REQUEST_TIMESTAMP_HEADER = 'x-pulse-request-timestamp';
|
||||||
|
const REQUEST_SIGNATURE_HEADER = 'x-pulse-request-signature';
|
||||||
|
const PAGE_TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
|
const REQUEST_AUTH_MAX_SKEW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function getSharedSecret() {
|
||||||
|
return String(process.env.PULSE_SIGNAGE_SHARED_SECRET || process.env.PLAYER_API_SHARED_SECRET || process.env.PLAYER_SHARED_SECRET || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64Url(value) {
|
||||||
|
return Buffer.from(String(value || ''), 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64Url(value) {
|
||||||
|
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||||
|
return Buffer.from(padded, 'base64').toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalize(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(canonicalize);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(value)) {
|
||||||
|
return value.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = {};
|
||||||
|
Object.keys(value).sort().forEach(function (key) {
|
||||||
|
const normalizedValue = canonicalize(value[key]);
|
||||||
|
if (normalizedValue !== undefined) {
|
||||||
|
canonical[key] = normalizedValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value === undefined ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequestPath(pathValue) {
|
||||||
|
const value = String(pathValue || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch (_error) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableJson(value) {
|
||||||
|
return JSON.stringify(canonicalize(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPayload(value) {
|
||||||
|
const normalized = Buffer.isBuffer(value) ? value : Buffer.from(stableJson(value === undefined ? null : value) || '', 'utf8');
|
||||||
|
return crypto.createHash('sha256').update(normalized).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequestAuthBody(req) {
|
||||||
|
if (!req) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = req.body;
|
||||||
|
if (body === undefined || body === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(body)) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body === 'object' && !Array.isArray(body)) {
|
||||||
|
const hasFields = Object.keys(body).length > 0;
|
||||||
|
if (!hasFields) {
|
||||||
|
const contentLength = Number(String(req.headers && req.headers['content-length'] || '').trim() || 0);
|
||||||
|
const contentType = String(req.headers && req.headers['content-type'] || '').trim();
|
||||||
|
if (!contentLength || !contentType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestPath(req) {
|
||||||
|
const explicitPath = String(req && req.path ? req.path : '').trim();
|
||||||
|
if (explicitPath) {
|
||||||
|
return normalizeRequestPath(explicitPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUrl = String(req && req.url ? req.url : '').trim();
|
||||||
|
if (!rawUrl) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return normalizeRequestPath(new URL(rawUrl, 'http://localhost').pathname);
|
||||||
|
} catch (_error) {
|
||||||
|
return normalizeRequestPath(rawUrl.split('?')[0] || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signText(secret, text) {
|
||||||
|
return crypto.createHmac('sha256', String(secret || '')).update(String(text || ''), 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqualHex(expectedHex, actualHex) {
|
||||||
|
const expected = Buffer.from(String(expectedHex || ''), 'hex');
|
||||||
|
const actual = Buffer.from(String(actualHex || ''), 'hex');
|
||||||
|
return expected.length === actual.length && expected.length > 0 && crypto.timingSafeEqual(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageAuthPayload(payload) {
|
||||||
|
const issuedAt = Date.now();
|
||||||
|
const normalizedPayload = canonicalize(payload || {});
|
||||||
|
return Object.assign({}, normalizedPayload, {
|
||||||
|
issuedAt: issuedAt,
|
||||||
|
expiresAt: issuedAt + PAGE_TOKEN_TTL_MS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPageAuthBundle(payload) {
|
||||||
|
const secret = getSharedSecret();
|
||||||
|
if (!secret) {
|
||||||
|
return {
|
||||||
|
token: '',
|
||||||
|
issuedAt: null,
|
||||||
|
expiresAt: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadWithExpiry = buildPageAuthPayload(payload);
|
||||||
|
const encodedPayload = toBase64Url(stableJson(payloadWithExpiry));
|
||||||
|
const signature = signText(secret, `page\n${encodedPayload}`);
|
||||||
|
return {
|
||||||
|
token: `${encodedPayload}.${signature}`,
|
||||||
|
issuedAt: payloadWithExpiry.issuedAt,
|
||||||
|
expiresAt: payloadWithExpiry.expiresAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPageAuthToken(payload) {
|
||||||
|
return createPageAuthBundle(payload).token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPageAuthToken(token) {
|
||||||
|
const secret = getSharedSecret();
|
||||||
|
if (!secret) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedToken = String(token || '').trim();
|
||||||
|
if (!normalizedToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = normalizedToken.lastIndexOf('.');
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPart = normalizedToken.slice(0, separatorIndex);
|
||||||
|
const signaturePart = normalizedToken.slice(separatorIndex + 1);
|
||||||
|
const expectedSignature = signText(secret, `page\n${payloadPart}`);
|
||||||
|
if (!timingSafeEqualHex(expectedSignature, signaturePart)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(fromBase64Url(payloadPart));
|
||||||
|
const now = Date.now();
|
||||||
|
const issuedAt = Number(payload && payload.issuedAt);
|
||||||
|
const expiresAt = Number(payload && payload.expiresAt);
|
||||||
|
if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (issuedAt > now + REQUEST_AUTH_MAX_SKEW_MS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (expiresAt <= now) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestAuthHeaders(options) {
|
||||||
|
const secret = getSharedSecret();
|
||||||
|
if (!secret) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = String(options && options.method || 'GET').trim().toUpperCase();
|
||||||
|
const pathname = normalizeRequestPath(options && options.pathname || '');
|
||||||
|
const timestamp = String(options && options.timestamp || Date.now()).trim();
|
||||||
|
const bodyDigest = hashPayload(options && Object.prototype.hasOwnProperty.call(options, 'body') ? options.body : null);
|
||||||
|
const signature = signText(secret, `request\n${method}\n${pathname}\n${timestamp}\n${bodyDigest}`);
|
||||||
|
return {
|
||||||
|
[REQUEST_TIMESTAMP_HEADER]: timestamp,
|
||||||
|
[REQUEST_SIGNATURE_HEADER]: signature
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyRequestAuth(req) {
|
||||||
|
const secret = getSharedSecret();
|
||||||
|
if (!secret) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = String(req && req.headers ? req.headers[REQUEST_TIMESTAMP_HEADER] || '' : '').trim();
|
||||||
|
const signature = String(req && req.headers ? req.headers[REQUEST_SIGNATURE_HEADER] || '' : '').trim();
|
||||||
|
if (!timestamp || !signature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedTimestamp = Number(timestamp);
|
||||||
|
if (!Number.isFinite(parsedTimestamp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (Math.abs(now - parsedTimestamp) > REQUEST_AUTH_MAX_SKEW_MS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(normalizeRequestAuthBody(req))}`);
|
||||||
|
return timingSafeEqualHex(expectedSignature, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPageFetchAuthScript(token) {
|
||||||
|
const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim();
|
||||||
|
if (!normalizedToken) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt))
|
||||||
|
? Number(token.expiresAt)
|
||||||
|
: null;
|
||||||
|
const renewSkewMs = REQUEST_AUTH_MAX_SKEW_MS;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<script>',
|
||||||
|
' (function () {',
|
||||||
|
' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';',
|
||||||
|
' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';',
|
||||||
|
' var pageAuthRenewalTimer = null;',
|
||||||
|
' var pageAuthRenewalInFlight = null;',
|
||||||
|
' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';',
|
||||||
|
' var originalFetch = window.fetch && window.fetch.bind(window);',
|
||||||
|
' if (!originalFetch) {',
|
||||||
|
' return;',
|
||||||
|
' }',
|
||||||
|
' function schedulePageAuthRenewal() {',
|
||||||
|
' if (pageAuthRenewalTimer) {',
|
||||||
|
' clearTimeout(pageAuthRenewalTimer);',
|
||||||
|
' pageAuthRenewalTimer = null;',
|
||||||
|
' }',
|
||||||
|
' if (!pageAuthToken || !pageAuthExpiresAt) {',
|
||||||
|
' return;',
|
||||||
|
' }',
|
||||||
|
' var delayMs = pageAuthExpiresAt - Date.now() - pageAuthRenewalSkewMs;',
|
||||||
|
' if (!Number.isFinite(delayMs) || delayMs < 1000) {',
|
||||||
|
' delayMs = 1000;',
|
||||||
|
' }',
|
||||||
|
' pageAuthRenewalTimer = setTimeout(function () {',
|
||||||
|
' renewPageAuthToken().catch(function () {',
|
||||||
|
' return null;',
|
||||||
|
' });',
|
||||||
|
' }, delayMs);',
|
||||||
|
' }',
|
||||||
|
' function setPageAuthToken(nextToken, nextExpiresAt) {',
|
||||||
|
' pageAuthToken = String(nextToken || "").trim();',
|
||||||
|
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
|
||||||
|
' window.__pulsePageAuthToken = pageAuthToken;',
|
||||||
|
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
||||||
|
' schedulePageAuthRenewal();',
|
||||||
|
' }',
|
||||||
|
' async function renewPageAuthToken() {',
|
||||||
|
' if (!pageAuthToken || pageAuthRenewalInFlight) {',
|
||||||
|
' return pageAuthToken;',
|
||||||
|
' }',
|
||||||
|
' pageAuthRenewalInFlight = originalFetch("/api/auth/page", {',
|
||||||
|
' method: "POST",',
|
||||||
|
' headers: new Headers({',
|
||||||
|
' "Accept": "application/json",',
|
||||||
|
' ' + JSON.stringify(PAGE_TOKEN_HEADER) + ': pageAuthToken',
|
||||||
|
' })',
|
||||||
|
' }).then(function (response) {',
|
||||||
|
' if (!response.ok) {',
|
||||||
|
' return null;',
|
||||||
|
' }',
|
||||||
|
' return response.json().catch(function () {',
|
||||||
|
' return null;',
|
||||||
|
' });',
|
||||||
|
' }).then(function (payload) {',
|
||||||
|
' if (!payload || !payload.token) {',
|
||||||
|
' return null;',
|
||||||
|
' }',
|
||||||
|
' setPageAuthToken(payload.token, payload.expiresAt);',
|
||||||
|
' return pageAuthToken;',
|
||||||
|
' }).finally(function () {',
|
||||||
|
' pageAuthRenewalInFlight = null;',
|
||||||
|
' });',
|
||||||
|
' return pageAuthRenewalInFlight;',
|
||||||
|
' }',
|
||||||
|
' window.__pulseSetPageAuthToken = setPageAuthToken;',
|
||||||
|
' window.__pulseRenewPageAuthToken = renewPageAuthToken;',
|
||||||
|
' window.__pulsePageAuthToken = pageAuthToken;',
|
||||||
|
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
||||||
|
' window.addEventListener("focus", function () {',
|
||||||
|
' schedulePageAuthRenewal();',
|
||||||
|
' });',
|
||||||
|
' document.addEventListener("visibilitychange", function () {',
|
||||||
|
' if (document.visibilityState === "visible") {',
|
||||||
|
' schedulePageAuthRenewal();',
|
||||||
|
' }',
|
||||||
|
' });',
|
||||||
|
' window.fetch = function (input, init) {',
|
||||||
|
' try {',
|
||||||
|
' var requestUrl = input instanceof Request ? new URL(input.url, window.location.href) : new URL(String(input), window.location.href);',
|
||||||
|
' if (requestUrl.origin === window.location.origin && requestUrl.pathname.indexOf("/api/") === 0) {',
|
||||||
|
' var isRenewalRequest = requestUrl.pathname === "/api/auth/page";',
|
||||||
|
' var requestInit = init ? Object.assign({}, init) : {};',
|
||||||
|
' var headers = input instanceof Request ? new Headers(input.headers) : new Headers(requestInit.headers || {});',
|
||||||
|
' headers.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', pageAuthToken);',
|
||||||
|
' if (input instanceof Request) {',
|
||||||
|
' requestInit = { headers: headers };',
|
||||||
|
' var firstResponse = originalFetch(new Request(input, requestInit));',
|
||||||
|
' return firstResponse.then(function (response) {',
|
||||||
|
' if (response.status !== 401 || isRenewalRequest || !pageAuthToken) {',
|
||||||
|
' return response;',
|
||||||
|
' }',
|
||||||
|
' return renewPageAuthToken().then(function (nextToken) {',
|
||||||
|
' if (!nextToken) {',
|
||||||
|
' return response;',
|
||||||
|
' }',
|
||||||
|
' var retryHeaders = new Headers(input.headers);',
|
||||||
|
' retryHeaders.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', nextToken);',
|
||||||
|
' return originalFetch(new Request(input, { headers: retryHeaders }));',
|
||||||
|
' }).catch(function () {',
|
||||||
|
' return response;',
|
||||||
|
' });',
|
||||||
|
' });',
|
||||||
|
' }',
|
||||||
|
' requestInit.headers = headers;',
|
||||||
|
' var firstRequest = originalFetch(input, requestInit);',
|
||||||
|
' return firstRequest.then(function (response) {',
|
||||||
|
' if (response.status !== 401 || isRenewalRequest || !pageAuthToken) {',
|
||||||
|
' return response;',
|
||||||
|
' }',
|
||||||
|
' return renewPageAuthToken().then(function (nextToken) {',
|
||||||
|
' if (!nextToken) {',
|
||||||
|
' return response;',
|
||||||
|
' }',
|
||||||
|
' var retryRequestInit = init ? Object.assign({}, init) : {};',
|
||||||
|
' var retryHeaders = new Headers(retryRequestInit.headers || {});',
|
||||||
|
' retryHeaders.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', nextToken);',
|
||||||
|
' retryRequestInit.headers = retryHeaders;',
|
||||||
|
' return originalFetch(input, retryRequestInit);',
|
||||||
|
' }).catch(function () {',
|
||||||
|
' return response;',
|
||||||
|
' });',
|
||||||
|
' });',
|
||||||
|
' }',
|
||||||
|
' } catch (_error) {',
|
||||||
|
' return originalFetch(input, init);',
|
||||||
|
' }',
|
||||||
|
' return originalFetch(input, init);',
|
||||||
|
' };',
|
||||||
|
' schedulePageAuthRenewal();',
|
||||||
|
' }());',
|
||||||
|
'</script>'
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getSharedSecret: getSharedSecret,
|
||||||
|
PAGE_TOKEN_HEADER: PAGE_TOKEN_HEADER,
|
||||||
|
REQUEST_TIMESTAMP_HEADER: REQUEST_TIMESTAMP_HEADER,
|
||||||
|
REQUEST_SIGNATURE_HEADER: REQUEST_SIGNATURE_HEADER,
|
||||||
|
PAGE_TOKEN_TTL_MS: PAGE_TOKEN_TTL_MS,
|
||||||
|
REQUEST_AUTH_MAX_SKEW_MS: REQUEST_AUTH_MAX_SKEW_MS,
|
||||||
|
createPageAuthToken: createPageAuthToken,
|
||||||
|
createPageAuthBundle: createPageAuthBundle,
|
||||||
|
verifyPageAuthToken: verifyPageAuthToken,
|
||||||
|
createRequestAuthHeaders: createRequestAuthHeaders,
|
||||||
|
verifyRequestAuth: verifyRequestAuth,
|
||||||
|
createPageFetchAuthScript: createPageFetchAuthScript
|
||||||
|
};
|
||||||
+518
@@ -0,0 +1,518 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const multer = require('multer');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const common = require('./common');
|
||||||
|
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth');
|
||||||
|
const pages = require('./web/pages');
|
||||||
|
const registerAuthRoutes = require('./web/routes/auth');
|
||||||
|
const registerAdminPagesRoutes = require('./web/routes/admin/pages');
|
||||||
|
const registerAdminAccountRoutes = require('./web/routes/admin/account');
|
||||||
|
const registerAdminUsersRoutes = require('./web/routes/admin/users');
|
||||||
|
const registerAdminManageRoutes = require('./web/routes/admin/manage');
|
||||||
|
const registerAdminScreenCommandRoutes = require('./web/routes/admin/client-commands');
|
||||||
|
const registerAdminContentRoutes = require('./web/routes/admin/content');
|
||||||
|
const registerAdminDataSourceRoutes = require('./web/routes/data-sources');
|
||||||
|
const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks');
|
||||||
|
const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue');
|
||||||
|
const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails');
|
||||||
|
const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh');
|
||||||
|
const { createWebBootstrap } = require('./web/bootstrap');
|
||||||
|
const { requirePermission } = require('./rbac');
|
||||||
|
const rbacData = require('./web/lib/rbac-data');
|
||||||
|
const { createPlayerActionService } = require('./web/lib/player-actions');
|
||||||
|
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check');
|
||||||
|
const { createSessionService } = require('./web/lib/session');
|
||||||
|
const { hasAnyPermission } = require('./rbac');
|
||||||
|
const {
|
||||||
|
formatDashboardDate,
|
||||||
|
readArrayField,
|
||||||
|
parseDateTimeLocal,
|
||||||
|
parseTimeLocal,
|
||||||
|
normalizeScheduleMode,
|
||||||
|
getAuditUserId,
|
||||||
|
getCanvasSignature,
|
||||||
|
fetchPlaylistCanvasSignature,
|
||||||
|
fetchScreensByPlaylistId,
|
||||||
|
fetchScreensBySlideId,
|
||||||
|
fetchScreensByTemplateId,
|
||||||
|
fetchOrderedPlaylistSlides,
|
||||||
|
redirectAfterSave
|
||||||
|
} = require('./web/lib/helpers');
|
||||||
|
const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, '');
|
||||||
|
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||||
|
const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws');
|
||||||
|
const SESSION_COOKIE_NAME = 'digital_signage_session';
|
||||||
|
const SESSION_MAX_AGE_DAYS = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
||||||
|
const SESSION_MAX_AGE_MS = (Number.isFinite(SESSION_MAX_AGE_DAYS) && SESSION_MAX_AGE_DAYS > 0 ? SESSION_MAX_AGE_DAYS : 14) * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const pool = common.createPool();
|
||||||
|
const PORT = Number(process.env.WEB_PORT || 3000);
|
||||||
|
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||||
|
const UPLOADS_DIR = path.join(MEDIA_DIR, 'uploads');
|
||||||
|
const THUMBNAILS_DIR = path.join(MEDIA_DIR, 'thumbnails');
|
||||||
|
const ASSET_DIR = path.join(__dirname, 'web', 'public');
|
||||||
|
const WEB_BASE_URL = (process.env.WEB_PUBLIC_BASE_URL || process.env.WEB_BASE_URL || ('http://127.0.0.1:' + PORT)).replace(/\/$/, '');
|
||||||
|
const DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250));
|
||||||
|
const backgroundTaskQueue = createBackgroundTaskQueue({
|
||||||
|
pool: pool,
|
||||||
|
maxConcurrent: 1
|
||||||
|
});
|
||||||
|
const playerActionService = createPlayerActionService({
|
||||||
|
common: common,
|
||||||
|
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
||||||
|
});
|
||||||
|
const notifyPlayerScreens = function (slugs, commandOrPayload) {
|
||||||
|
const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) {
|
||||||
|
return String(slug || '').trim();
|
||||||
|
}).filter(Boolean)));
|
||||||
|
|
||||||
|
if (!uniqueSlugs.length) {
|
||||||
|
return Promise.resolve(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.allSettled(uniqueSlugs.map(function (slug) {
|
||||||
|
return playerActionService.forwardPlayerCommand(slug, commandOrPayload || 'refresh');
|
||||||
|
})).then(function (results) {
|
||||||
|
return results.filter(function (result) {
|
||||||
|
return result.status === 'fulfilled';
|
||||||
|
}).length;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const webBootstrap = createWebBootstrap({
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||||
|
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||||
|
uploadDir: UPLOADS_DIR,
|
||||||
|
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
|
||||||
|
formatDashboardDate: formatDashboardDate,
|
||||||
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
|
hasAnyPermission: hasAnyPermission,
|
||||||
|
});
|
||||||
|
const upload = webBootstrap.upload;
|
||||||
|
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
|
||||||
|
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
|
||||||
|
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
|
||||||
|
const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles;
|
||||||
|
const collectUploadPathsFromDirectory = webBootstrap.collectUploadPathsFromDirectory;
|
||||||
|
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
|
||||||
|
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
|
||||||
|
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
|
||||||
|
const broadcastDashboardState = webBootstrap.broadcastDashboardState;
|
||||||
|
const sessionService = createSessionService({
|
||||||
|
sessionCookieName: SESSION_COOKIE_NAME,
|
||||||
|
sessionMaxAgeMs: SESSION_MAX_AGE_MS,
|
||||||
|
hashSessionToken: hashSessionToken,
|
||||||
|
createSessionToken: createSessionToken
|
||||||
|
});
|
||||||
|
const parseCookies = sessionService.parseCookies;
|
||||||
|
const clearSessionCookie = sessionService.clearSessionCookie;
|
||||||
|
const setSessionCookie = sessionService.setSessionCookie;
|
||||||
|
const loadCurrentUser = sessionService.loadCurrentUser;
|
||||||
|
const createUserSession = sessionService.createUserSession;
|
||||||
|
const requireAuth = sessionService.requireAuth;
|
||||||
|
|
||||||
|
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
|
||||||
|
return runMediaSyncTask(task && task.payload ? task.payload : task);
|
||||||
|
});
|
||||||
|
|
||||||
|
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
|
||||||
|
const payload = task && task.payload ? task.payload : {};
|
||||||
|
const sourceType = String(payload.sourceType || '').trim();
|
||||||
|
const sourceId = Number(payload.sourceId || 0);
|
||||||
|
|
||||||
|
if (sourceType === 'api-source') {
|
||||||
|
const apiSource = await common.fetchApiSourceById(pool, sourceId);
|
||||||
|
if (!apiSource) {
|
||||||
|
throw new Error('API source not found.');
|
||||||
|
}
|
||||||
|
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceType === 'rss-feed') {
|
||||||
|
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
|
||||||
|
if (!rssFeed) {
|
||||||
|
throw new Error('RSS feed not found.');
|
||||||
|
}
|
||||||
|
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported data source refresh task.');
|
||||||
|
});
|
||||||
|
|
||||||
|
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) {
|
||||||
|
const payload = task && task.payload ? task.payload : {};
|
||||||
|
const slideId = Number(payload.slideId || 0);
|
||||||
|
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||||
|
throw new Error('Slide id is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return captureSlideThumbnail({
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
mediaDir: MEDIA_DIR,
|
||||||
|
baseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||||
|
slideId: slideId,
|
||||||
|
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/assets', express.static(ASSET_DIR));
|
||||||
|
app.use('/assets/vendor/cropperjs', express.static(path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist')));
|
||||||
|
app.use('/media', express.static(MEDIA_DIR));
|
||||||
|
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
app.use(async function (req, _res, next) {
|
||||||
|
try {
|
||||||
|
req.currentUser = await loadCurrentUser(pool, req);
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(function (req, res, next) {
|
||||||
|
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return requireAuth(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAuthRoutes(app, {
|
||||||
|
pool: pool,
|
||||||
|
pages: pages,
|
||||||
|
createUserSession: createUserSession,
|
||||||
|
setSessionCookie: setSessionCookie,
|
||||||
|
clearSessionCookie: clearSessionCookie,
|
||||||
|
parseCookies: parseCookies,
|
||||||
|
hashSessionToken: hashSessionToken,
|
||||||
|
verifyPassword: verifyPassword,
|
||||||
|
sessionCookieName: SESSION_COOKIE_NAME
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAdminPagesRoutes(app, {
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
pages: pages,
|
||||||
|
requirePermission: requirePermission,
|
||||||
|
buildDashboardState: webBootstrap.buildDashboardState
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAdminAccountRoutes(app, {
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
pages: pages,
|
||||||
|
formatDashboardDate: formatDashboardDate,
|
||||||
|
getAuditUserId: getAuditUserId,
|
||||||
|
verifyPassword: verifyPassword,
|
||||||
|
hashPassword: hashPassword,
|
||||||
|
createUserSession: createUserSession,
|
||||||
|
setSessionCookie: setSessionCookie
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAdminUsersRoutes(app, {
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
pages: pages,
|
||||||
|
formatDashboardDate: formatDashboardDate,
|
||||||
|
getAuditUserId: getAuditUserId,
|
||||||
|
hashPassword: hashPassword,
|
||||||
|
readArrayField: readArrayField,
|
||||||
|
rbacData: rbacData,
|
||||||
|
requirePermission: requirePermission
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAdminManageRoutes(app, {
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
pages: pages,
|
||||||
|
fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides,
|
||||||
|
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
|
||||||
|
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||||
|
getCanvasSignature: getCanvasSignature,
|
||||||
|
normalizeScheduleMode: normalizeScheduleMode,
|
||||||
|
parseDateTimeLocal: parseDateTimeLocal,
|
||||||
|
parseTimeLocal: parseTimeLocal,
|
||||||
|
readArrayField: readArrayField,
|
||||||
|
getAuditUserId: getAuditUserId,
|
||||||
|
redirectAfterSave: redirectAfterSave,
|
||||||
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
|
broadcastDashboardState: broadcastDashboardState,
|
||||||
|
getScreenDeleteBlockMessage: playerActionService.getScreenDeleteBlockMessage,
|
||||||
|
getScreenConnections: playerActionService.getScreenConnections,
|
||||||
|
getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage,
|
||||||
|
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
|
||||||
|
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||||
|
requirePermission: requirePermission
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAdminScreenCommandRoutes(app, {
|
||||||
|
pool: pool,
|
||||||
|
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
|
||||||
|
getScreenConnections: playerActionService.getScreenConnections,
|
||||||
|
isClientNameAvailable: isClientNameAvailable,
|
||||||
|
withClientNameReservation: withClientNameReservation,
|
||||||
|
requirePermission: requirePermission
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerAdminRbacRoutes = require('./web/routes/admin/rbac');
|
||||||
|
registerAdminRbacRoutes(app, {
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
pages: pages,
|
||||||
|
getAuditUserId: getAuditUserId,
|
||||||
|
rbacData: rbacData,
|
||||||
|
readArrayField: readArrayField,
|
||||||
|
permissions: require('./rbac').PERMISSIONS,
|
||||||
|
normalizePermissionKeys: require('./rbac').normalizePermissionKeys,
|
||||||
|
requirePermission: requirePermission
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAdminContentRoutes(app, {
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
pages: pages,
|
||||||
|
upload: upload,
|
||||||
|
uploadDir: UPLOADS_DIR,
|
||||||
|
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||||
|
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||||
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||||
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||||
|
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||||
|
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||||
|
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||||
|
getAuditUserId: getAuditUserId,
|
||||||
|
redirectAfterSave: redirectAfterSave,
|
||||||
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
|
broadcastDashboardState: broadcastDashboardState,
|
||||||
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
|
hasAnyPermission: hasAnyPermission,
|
||||||
|
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
|
||||||
|
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
|
||||||
|
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
|
||||||
|
requirePermission: requirePermission
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAdminDataSourceRoutes(app, {
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
pages: pages,
|
||||||
|
formatDashboardDate: formatDashboardDate,
|
||||||
|
getAuditUserId: getAuditUserId,
|
||||||
|
redirectAfterSave: redirectAfterSave,
|
||||||
|
fetchRssFeedItems: common.fetchRssFeedItems,
|
||||||
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
|
requirePermission: requirePermission
|
||||||
|
});
|
||||||
|
|
||||||
|
registerAdminSettingsRoutes(app, {
|
||||||
|
pages: pages,
|
||||||
|
backgroundTaskQueue: backgroundTaskQueue
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
app.use(function (req, res, next) {
|
||||||
|
const pathName = String(req.originalUrl || '');
|
||||||
|
const wantsHtml = !pathName.startsWith('/api/') && (!req.accepts || req.accepts('html'));
|
||||||
|
|
||||||
|
if (!wantsHtml) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(404).send(pages.renderErrorPage({
|
||||||
|
statusCode: 404,
|
||||||
|
title: 'Not found',
|
||||||
|
errorTitle: 'Oops! Page not found.',
|
||||||
|
message: 'We could not find the page you were looking for.',
|
||||||
|
backUrl: req.currentUser ? '/dashboard' : '/login',
|
||||||
|
backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in'
|
||||||
|
}, req.currentUser));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(function (error, req, res, _next) {
|
||||||
|
console.error(error);
|
||||||
|
const statusCode = Number(error && (error.statusCode || error.status)) || 500;
|
||||||
|
const isXhr = String(req.get && req.get('X-Requested-With') || '').toLowerCase() === 'xmlhttprequest';
|
||||||
|
const wantsHtml = !isXhr && !String(req.originalUrl || '').startsWith('/api/') && (!req.accepts || req.accepts('html'));
|
||||||
|
|
||||||
|
if (wantsHtml && pages.renderErrorPage) {
|
||||||
|
const isPermissionError = statusCode === 403;
|
||||||
|
const message = isPermissionError
|
||||||
|
? String(error && error.message ? error.message : 'You do not have permission to access this area.')
|
||||||
|
: String(error && error.message ? error.message : 'An unexpected error occurred.');
|
||||||
|
const title = isPermissionError
|
||||||
|
? 'Access denied'
|
||||||
|
: statusCode === 404
|
||||||
|
? 'Not found'
|
||||||
|
: 'Something went wrong';
|
||||||
|
|
||||||
|
return res.status(statusCode).send(pages.renderErrorPage({
|
||||||
|
statusCode: statusCode,
|
||||||
|
title: title,
|
||||||
|
errorTitle: title,
|
||||||
|
message: message,
|
||||||
|
detail: statusCode >= 500 ? 'The server could not complete the request.' : '',
|
||||||
|
backUrl: req.currentUser ? '/dashboard' : '/login',
|
||||||
|
backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in'
|
||||||
|
}, req.currentUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).send(statusCode >= 500 ? 'Internal server error' : String(error && error.message ? error.message : 'Error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure schema and mirror media before the web service starts handling traffic.
|
||||||
|
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||||
|
await backgroundTaskQueue.initialize();
|
||||||
|
|
||||||
|
async function syncRecurringRefreshes() {
|
||||||
|
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||||
|
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||||
|
backgroundTaskQueue.registerRecurringTask({
|
||||||
|
key: 'api-source-refresh:' + Number(apiSource.id),
|
||||||
|
title: 'API source refresh',
|
||||||
|
category: 'data-source',
|
||||||
|
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
|
||||||
|
metadata: {
|
||||||
|
sourceType: 'api-source',
|
||||||
|
sourceId: Number(apiSource.id),
|
||||||
|
sourceName: apiSource.name
|
||||||
|
},
|
||||||
|
run: function () {
|
||||||
|
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||||
|
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||||
|
backgroundTaskQueue.registerRecurringTask({
|
||||||
|
key: 'rss-feed-refresh:' + Number(rssFeed.id),
|
||||||
|
title: 'RSS feed refresh',
|
||||||
|
category: 'data-source',
|
||||||
|
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
|
||||||
|
metadata: {
|
||||||
|
sourceType: 'rss-feed',
|
||||||
|
sourceId: Number(rssFeed.id),
|
||||||
|
sourceName: rssFeed.name
|
||||||
|
},
|
||||||
|
run: function () {
|
||||||
|
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerRecurringMaintenanceTasks() {
|
||||||
|
async function runUnusedUploadSweep() {
|
||||||
|
const uploadPaths = await collectUploadPathsFromDirectory(UPLOADS_DIR);
|
||||||
|
if (!uploadPaths.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeUnusedUploadFiles(pool, UPLOADS_DIR, uploadPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundTaskQueue.registerRecurringTask({
|
||||||
|
key: 'unused-upload-sweep',
|
||||||
|
title: 'Unused upload sweep',
|
||||||
|
category: 'media-sync',
|
||||||
|
intervalMs: 24 * 60 * 60 * 1000,
|
||||||
|
metadata: {
|
||||||
|
uploadDir: UPLOADS_DIR
|
||||||
|
},
|
||||||
|
run: runUnusedUploadSweep
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scheduleInitialDataSourceRefreshes() {
|
||||||
|
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||||
|
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||||
|
const startupSources = [];
|
||||||
|
|
||||||
|
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||||
|
startupSources.push({
|
||||||
|
type: 'api-source',
|
||||||
|
id: Number(apiSource.id),
|
||||||
|
name: apiSource.name,
|
||||||
|
run: function () {
|
||||||
|
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||||
|
startupSources.push({
|
||||||
|
type: 'rss-feed',
|
||||||
|
id: Number(rssFeed.id),
|
||||||
|
name: rssFeed.name,
|
||||||
|
run: function () {
|
||||||
|
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
startupSources.forEach(function (source, index) {
|
||||||
|
const startupDelayMs = index * DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS;
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
backgroundTaskQueue.enqueueTask({
|
||||||
|
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
|
||||||
|
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||||
|
category: 'data-source',
|
||||||
|
taskType: 'data-source-refresh',
|
||||||
|
metadata: {
|
||||||
|
sourceType: source.type,
|
||||||
|
sourceId: source.id,
|
||||||
|
sourceName: source.name,
|
||||||
|
startupRefresh: true
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
sourceType: source.type,
|
||||||
|
sourceId: source.id,
|
||||||
|
sourceName: source.name,
|
||||||
|
startupRefresh: true
|
||||||
|
}
|
||||||
|
}).catch(function (error) {
|
||||||
|
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
|
||||||
|
});
|
||||||
|
}, startupDelayMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncRecurringRefreshes();
|
||||||
|
await registerRecurringMaintenanceTasks();
|
||||||
|
scheduleInitialDataSourceRefreshes().catch(function (error) {
|
||||||
|
console.warn('Unable to schedule startup data source refreshes:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||||
|
await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) {
|
||||||
|
console.warn('Unable to sync existing media to player:', error);
|
||||||
|
});
|
||||||
|
webBootstrap.installDashboardWebsocket(server, loadCurrentUser);
|
||||||
|
|
||||||
|
server.listen(PORT, function () {
|
||||||
|
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
start().catch(function (error) {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+225
@@ -0,0 +1,225 @@
|
|||||||
|
const { WebSocketServer, WebSocket } = require('ws');
|
||||||
|
const { createDashboardStateService } = require('./lib/dashboard-state');
|
||||||
|
const { createUploadSyncService } = require('./lib/upload-sync');
|
||||||
|
const { createRequestAuthHeaders } = require('../request-auth');
|
||||||
|
|
||||||
|
function createWebBootstrap(options) {
|
||||||
|
const pool = options && options.pool;
|
||||||
|
const common = options && options.common;
|
||||||
|
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||||
|
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
|
||||||
|
const uploadDir = String(options && options.uploadDir || '').trim();
|
||||||
|
const dashboardRefreshIntervalMs = Number(options && options.dashboardRefreshIntervalMs || 2000);
|
||||||
|
const formatDashboardDate = options && options.formatDashboardDate;
|
||||||
|
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||||
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||||
|
|
||||||
|
if (!pool || !common || !uploadDir || typeof formatDashboardDate !== 'function' || typeof notifyPlayerScreens !== 'function') {
|
||||||
|
throw new Error('createWebBootstrap requires the web bootstrap dependencies.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardWs = new WebSocketServer({ noServer: true });
|
||||||
|
const dashboardClients = new Set();
|
||||||
|
const playerSnapshotCache = new Map();
|
||||||
|
const playerSnapshotSockets = new Map();
|
||||||
|
let dashboardRefreshInFlight = null;
|
||||||
|
let broadcastDashboardState = null;
|
||||||
|
|
||||||
|
function getPlayerSnapshotSocketUrl(slug) {
|
||||||
|
const url = new URL(playerInternalBaseUrl.replace(/^http/, 'ws'));
|
||||||
|
url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
|
||||||
|
url.search = '';
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function storePlayerSnapshot(slug, connections) {
|
||||||
|
const normalizedSlug = String(slug || '').trim();
|
||||||
|
const normalizedConnections = Array.isArray(connections) ? connections : [];
|
||||||
|
playerSnapshotCache.set(normalizedSlug, {
|
||||||
|
slug: normalizedSlug,
|
||||||
|
count: normalizedConnections.length,
|
||||||
|
connections: normalizedConnections
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPlayerSnapshotSocket(slug) {
|
||||||
|
const key = String(slug || '').trim();
|
||||||
|
playerSnapshotSockets.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePlayerSnapshotSubscription(slug) {
|
||||||
|
const key = String(slug || '').trim();
|
||||||
|
if (!key || playerSnapshotSockets.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketUrl = getPlayerSnapshotSocketUrl(key);
|
||||||
|
const authHeaders = createRequestAuthHeaders({
|
||||||
|
method: 'GET',
|
||||||
|
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
||||||
|
});
|
||||||
|
const socket = new WebSocket(socketUrl, {
|
||||||
|
headers: authHeaders
|
||||||
|
});
|
||||||
|
playerSnapshotSockets.set(key, socket);
|
||||||
|
|
||||||
|
socket.onmessage = function (event) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(String(event.data || '{}'));
|
||||||
|
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storePlayerSnapshot(key, payload.connections || []);
|
||||||
|
if (broadcastDashboardState) {
|
||||||
|
broadcastDashboardState().catch(function (error) {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore malformed player snapshot payloads.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = function () {
|
||||||
|
clearPlayerSnapshotSocket(key);
|
||||||
|
setTimeout(function () {
|
||||||
|
ensurePlayerSnapshotSubscription(key);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = function () {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore close errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardStateService = createDashboardStateService({
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
playerSnapshotCache: playerSnapshotCache,
|
||||||
|
playerSnapshotSockets: playerSnapshotSockets,
|
||||||
|
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
||||||
|
playerPublicBaseUrl: playerPublicBaseUrl,
|
||||||
|
formatDashboardDate: formatDashboardDate
|
||||||
|
});
|
||||||
|
const buildDashboardState = dashboardStateService.buildDashboardState;
|
||||||
|
|
||||||
|
const uploadSyncService = createUploadSyncService({
|
||||||
|
pool: pool,
|
||||||
|
common: common,
|
||||||
|
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||||
|
playerSnapshotCache: playerSnapshotCache,
|
||||||
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
|
backgroundTaskQueue: backgroundTaskQueue
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = uploadSyncService.createUploadMiddleware(uploadDir);
|
||||||
|
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
|
||||||
|
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
|
||||||
|
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
|
||||||
|
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
|
||||||
|
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
|
||||||
|
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
||||||
|
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
|
||||||
|
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
||||||
|
|
||||||
|
async function sendDashboardStateToSocket(socket) {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = await buildDashboardState();
|
||||||
|
socket.send(JSON.stringify({ type: 'dashboard-state', state: state }));
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastDashboardState = async function () {
|
||||||
|
if (dashboardRefreshInFlight) {
|
||||||
|
return dashboardRefreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboardRefreshInFlight = (async function () {
|
||||||
|
const state = await buildDashboardState();
|
||||||
|
const payload = JSON.stringify({ type: 'dashboard-state', state: state });
|
||||||
|
for (const socket of dashboardClients) {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
})().finally(function () {
|
||||||
|
dashboardRefreshInFlight = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return dashboardRefreshInFlight;
|
||||||
|
};
|
||||||
|
|
||||||
|
function installDashboardWebsocket(server, loadCurrentUser) {
|
||||||
|
server.on('upgrade', async function (request, socket, head) {
|
||||||
|
let pathname = '';
|
||||||
|
try {
|
||||||
|
pathname = new URL(request.url, 'http://localhost').pathname;
|
||||||
|
} catch (_error) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname !== '/ws/dashboard') {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUser = await loadCurrentUser(pool, request);
|
||||||
|
if (!currentUser) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboardWs.handleUpgrade(request, socket, head, function (ws) {
|
||||||
|
dashboardWs.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dashboardWs.on('connection', function (socket) {
|
||||||
|
dashboardClients.add(socket);
|
||||||
|
sendDashboardStateToSocket(socket);
|
||||||
|
|
||||||
|
socket.on('close', function () {
|
||||||
|
dashboardClients.delete(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', function () {
|
||||||
|
dashboardClients.delete(socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(function () {
|
||||||
|
broadcastDashboardState().catch(function (error) {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}, dashboardRefreshIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
upload: upload,
|
||||||
|
buildDashboardState: buildDashboardState,
|
||||||
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||||
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||||
|
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||||
|
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||||
|
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||||
|
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||||
|
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||||
|
runMediaSyncTask: runMediaSyncTask,
|
||||||
|
broadcastDashboardState: broadcastDashboardState,
|
||||||
|
installDashboardWebsocket: installDashboardWebsocket
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createWebBootstrap };
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIntervalMs(value, unit) {
|
||||||
|
const numericValue = Math.max(1, Number(value) || 0);
|
||||||
|
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||||
|
if (normalizedUnit === 'seconds') {
|
||||||
|
return numericValue * 1000;
|
||||||
|
}
|
||||||
|
return numericValue * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBackgroundTaskQueue(options) {
|
||||||
|
const pool = options && options.pool;
|
||||||
|
const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1);
|
||||||
|
const taskHandlers = new Map();
|
||||||
|
const tasksById = new Map();
|
||||||
|
const recurringJobsByKey = new Map();
|
||||||
|
const taskIdToRecurringKey = new Map();
|
||||||
|
const pendingIds = [];
|
||||||
|
let nextTaskId = 1;
|
||||||
|
let activeCount = 0;
|
||||||
|
let drainScheduled = false;
|
||||||
|
let initializationPromise = null;
|
||||||
|
|
||||||
|
function createTaskCompletionController() {
|
||||||
|
let resolveCompletion = null;
|
||||||
|
let rejectCompletion = null;
|
||||||
|
const completionPromise = new Promise(function (resolve, reject) {
|
||||||
|
resolveCompletion = resolve;
|
||||||
|
rejectCompletion = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise: completionPromise,
|
||||||
|
resolve: resolveCompletion,
|
||||||
|
reject: rejectCompletion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonValue(value, fallback) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(value));
|
||||||
|
} catch (_error) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyJsonValue(value) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSnapshot(task) {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
key: task.key,
|
||||||
|
taskType: task.taskType || '',
|
||||||
|
title: task.title,
|
||||||
|
category: task.category,
|
||||||
|
status: task.status,
|
||||||
|
createdAt: task.createdAt,
|
||||||
|
startedAt: task.startedAt,
|
||||||
|
finishedAt: task.finishedAt,
|
||||||
|
errorMessage: task.errorMessage,
|
||||||
|
attempts: task.attempts || 0,
|
||||||
|
metadata: task.metadata,
|
||||||
|
payload: task.payload || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskById(taskId) {
|
||||||
|
const numericTaskId = Number(taskId);
|
||||||
|
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasksById.get(numericTaskId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaskFromRow(row) {
|
||||||
|
const task = {
|
||||||
|
id: Number(row.id),
|
||||||
|
key: String(row.task_key || '').trim(),
|
||||||
|
taskType: String(row.task_type || '').trim(),
|
||||||
|
title: String(row.title || 'Background task').trim() || 'Background task',
|
||||||
|
category: String(row.category || 'general').trim() || 'general',
|
||||||
|
status: String(row.status || 'queued').trim() || 'queued',
|
||||||
|
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||||
|
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
|
||||||
|
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
|
||||||
|
errorMessage: String(row.error_message || ''),
|
||||||
|
attempts: Math.max(0, Number(row.attempts) || 0),
|
||||||
|
metadata: parseJsonValue(row.metadata_json, {}),
|
||||||
|
payload: parseJsonValue(row.payload_json, null),
|
||||||
|
completionPromise: null,
|
||||||
|
resolveCompletion: null,
|
||||||
|
rejectCompletion: null,
|
||||||
|
persisted: true,
|
||||||
|
run: typeof row.run === 'function' ? row.run : function () {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const completionController = createTaskCompletionController();
|
||||||
|
task.completionPromise = completionController.promise;
|
||||||
|
task.resolveCompletion = completionController.resolve;
|
||||||
|
task.rejectCompletion = completionController.reject;
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaskRecord(task) {
|
||||||
|
return {
|
||||||
|
task_key: task.key || null,
|
||||||
|
task_type: task.taskType || 'general',
|
||||||
|
title: task.title,
|
||||||
|
category: task.category || 'general',
|
||||||
|
status: task.status,
|
||||||
|
payload_json: stringifyJsonValue(task.payload),
|
||||||
|
metadata_json: stringifyJsonValue(task.metadata),
|
||||||
|
attempts: Math.max(0, Number(task.attempts) || 0),
|
||||||
|
created_at: task.createdAt ? new Date(task.createdAt) : new Date(),
|
||||||
|
started_at: task.startedAt ? new Date(task.startedAt) : null,
|
||||||
|
finished_at: task.finishedAt ? new Date(task.finishedAt) : null,
|
||||||
|
error_message: task.errorMessage || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistTaskInsert(task) {
|
||||||
|
if (!pool || !task.taskType) {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = buildTaskRecord(task);
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`INSERT INTO background_tasks (
|
||||||
|
task_key,
|
||||||
|
task_type,
|
||||||
|
title,
|
||||||
|
category,
|
||||||
|
status,
|
||||||
|
payload_json,
|
||||||
|
metadata_json,
|
||||||
|
attempts,
|
||||||
|
created_at,
|
||||||
|
started_at,
|
||||||
|
finished_at,
|
||||||
|
error_message
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
record.task_key,
|
||||||
|
record.task_type,
|
||||||
|
record.title,
|
||||||
|
record.category,
|
||||||
|
record.status,
|
||||||
|
record.payload_json,
|
||||||
|
record.metadata_json,
|
||||||
|
record.attempts,
|
||||||
|
record.created_at,
|
||||||
|
record.started_at,
|
||||||
|
record.finished_at,
|
||||||
|
record.error_message
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
task.id = Number(result.insertId);
|
||||||
|
task.persisted = true;
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistTaskUpdate(task) {
|
||||||
|
if (!pool || !task.persisted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = buildTaskRecord(task);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE background_tasks
|
||||||
|
SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
record.task_key,
|
||||||
|
record.task_type,
|
||||||
|
record.title,
|
||||||
|
record.category,
|
||||||
|
record.status,
|
||||||
|
record.payload_json,
|
||||||
|
record.metadata_json,
|
||||||
|
record.attempts,
|
||||||
|
record.started_at,
|
||||||
|
record.finished_at,
|
||||||
|
record.error_message,
|
||||||
|
task.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistTaskDelete(taskId) {
|
||||||
|
if (!pool) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query('DELETE FROM background_tasks WHERE id = ?', [taskId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
if (initializationPromise) {
|
||||||
|
return initializationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializationPromise = (async function () {
|
||||||
|
if (!pool) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM background_tasks ORDER BY id ASC'
|
||||||
|
);
|
||||||
|
|
||||||
|
let highestTaskId = 0;
|
||||||
|
for (const row of rows || []) {
|
||||||
|
const task = buildTaskFromRow(row);
|
||||||
|
if (!Number.isInteger(task.id) || task.id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
highestTaskId = Math.max(highestTaskId, task.id);
|
||||||
|
tasksById.set(task.id, task);
|
||||||
|
|
||||||
|
if (task.status === 'running') {
|
||||||
|
task.status = 'queued';
|
||||||
|
task.startedAt = '';
|
||||||
|
task.finishedAt = '';
|
||||||
|
task.errorMessage = '';
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
|
||||||
|
['queued', task.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.status === 'queued' || task.status === 'running') {
|
||||||
|
pendingIds.push(task.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTaskId = Math.max(nextTaskId, highestTaskId + 1);
|
||||||
|
if (pendingIds.length) {
|
||||||
|
scheduleDrain();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return initializationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDrain() {
|
||||||
|
if (drainScheduled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drainScheduled = true;
|
||||||
|
setTimeout(function () {
|
||||||
|
drainScheduled = false;
|
||||||
|
processQueue();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRecurringTimer(job) {
|
||||||
|
if (job && job.timerId) {
|
||||||
|
clearTimeout(job.timerId);
|
||||||
|
job.timerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTaskHandler(taskType, handler) {
|
||||||
|
const normalizedTaskType = normalizeText(taskType);
|
||||||
|
if (!normalizedTaskType || typeof handler !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
taskHandlers.set(normalizedTaskType, handler);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRecurringRun(job, delayMs) {
|
||||||
|
if (!job || job.enabled === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRecurringTimer(job);
|
||||||
|
const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0);
|
||||||
|
job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay));
|
||||||
|
job.timerId = setTimeout(function () {
|
||||||
|
job.timerId = null;
|
||||||
|
triggerRecurringJob(job.key);
|
||||||
|
}, safeDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerRecurringJob(recurringKey) {
|
||||||
|
const job = recurringJobsByKey.get(recurringKey);
|
||||||
|
if (!job || job.enabled === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.activeTaskId && tasksById.has(job.activeTaskId)) {
|
||||||
|
scheduleRecurringRun(job, job.intervalMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.activeTaskId = -1;
|
||||||
|
enqueueTask({
|
||||||
|
key: `${job.key}:${Date.now()}`,
|
||||||
|
title: job.title,
|
||||||
|
category: job.category,
|
||||||
|
taskType: job.taskType || '',
|
||||||
|
metadata: Object.assign({}, job.metadata || {}, {
|
||||||
|
recurringKey: job.key,
|
||||||
|
recurringTitle: job.title
|
||||||
|
}),
|
||||||
|
payload: Object.assign({}, job.payload || {}, {
|
||||||
|
recurringKey: job.key
|
||||||
|
}),
|
||||||
|
run: job.run,
|
||||||
|
persist: Boolean(job.taskType)
|
||||||
|
}).then(function (task) {
|
||||||
|
if (task && Number.isInteger(task.id)) {
|
||||||
|
job.activeTaskId = task.id;
|
||||||
|
taskIdToRecurringKey.set(task.id, job.key);
|
||||||
|
} else {
|
||||||
|
job.activeTaskId = null;
|
||||||
|
}
|
||||||
|
}).catch(function (error) {
|
||||||
|
job.activeTaskId = null;
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduleRecurringRun(job, job.intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRecurringTask(recurringKey) {
|
||||||
|
const normalizedKey = normalizeText(recurringKey);
|
||||||
|
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerRecurringJob(normalizedKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRecurringTaskState(task, status, errorMessage) {
|
||||||
|
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
|
||||||
|
if (!recurringKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = recurringJobsByKey.get(recurringKey);
|
||||||
|
if (!job) {
|
||||||
|
taskIdToRecurringKey.delete(task.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.activeTaskId = null;
|
||||||
|
job.lastRunAt = toIsoDate(new Date());
|
||||||
|
job.lastStatus = status;
|
||||||
|
job.lastError = errorMessage ? String(errorMessage) : '';
|
||||||
|
taskIdToRecurringKey.delete(task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processQueue() {
|
||||||
|
while (activeCount < maxConcurrent) {
|
||||||
|
const nextTaskId = pendingIds.shift();
|
||||||
|
if (!nextTaskId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = tasksById.get(nextTaskId);
|
||||||
|
if (!task || task.status !== 'queued') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCount += 1;
|
||||||
|
task.status = 'running';
|
||||||
|
task.startedAt = toIsoDate(new Date());
|
||||||
|
task.errorMessage = '';
|
||||||
|
task.attempts = Math.max(0, Number(task.attempts) || 0) + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await persistTaskUpdate(task);
|
||||||
|
} catch (error) {
|
||||||
|
task.status = 'failed';
|
||||||
|
task.errorMessage = String(error && error.message ? error.message : 'Unable to update task state.');
|
||||||
|
task.finishedAt = toIsoDate(new Date());
|
||||||
|
if (typeof task.rejectCompletion === 'function') {
|
||||||
|
const completionError = new Error(task.errorMessage);
|
||||||
|
completionError.task = buildSnapshot(task);
|
||||||
|
task.rejectCompletion(completionError);
|
||||||
|
}
|
||||||
|
activeCount = Math.max(0, activeCount - 1);
|
||||||
|
scheduleDrain();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.resolve()
|
||||||
|
.then(function () {
|
||||||
|
if (task.taskType) {
|
||||||
|
const handler = taskHandlers.get(task.taskType);
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error('No handler registered for task type ' + task.taskType + '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler({
|
||||||
|
id: task.id,
|
||||||
|
key: task.key,
|
||||||
|
title: task.title,
|
||||||
|
category: task.category,
|
||||||
|
taskType: task.taskType,
|
||||||
|
payload: task.payload,
|
||||||
|
metadata: task.metadata,
|
||||||
|
attempts: task.attempts
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return task.run({
|
||||||
|
id: task.id,
|
||||||
|
key: task.key,
|
||||||
|
title: task.title,
|
||||||
|
category: task.category,
|
||||||
|
metadata: task.metadata
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
task.status = 'completed';
|
||||||
|
task.finishedAt = toIsoDate(new Date());
|
||||||
|
if (typeof task.resolveCompletion === 'function') {
|
||||||
|
task.resolveCompletion(buildSnapshot(task));
|
||||||
|
}
|
||||||
|
syncRecurringTaskState(task, task.status, '');
|
||||||
|
return persistTaskUpdate(task);
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
task.status = 'failed';
|
||||||
|
task.errorMessage = String(error && error.message ? error.message : 'Background task failed.');
|
||||||
|
task.finishedAt = toIsoDate(new Date());
|
||||||
|
if (typeof task.rejectCompletion === 'function') {
|
||||||
|
const completionError = new Error(task.errorMessage);
|
||||||
|
completionError.task = buildSnapshot(task);
|
||||||
|
task.rejectCompletion(completionError);
|
||||||
|
}
|
||||||
|
syncRecurringTaskState(task, task.status, task.errorMessage);
|
||||||
|
return persistTaskUpdate(task);
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
activeCount = Math.max(0, activeCount - 1);
|
||||||
|
scheduleDrain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueTask(definition) {
|
||||||
|
const normalizedKey = normalizeText(definition && definition.key);
|
||||||
|
const normalizedTitle = normalizeText(definition && definition.title) || 'Background task';
|
||||||
|
const normalizedTaskType = normalizeText(definition && definition.taskType);
|
||||||
|
const shouldPersist = Boolean((definition && definition.persist) || (pool && normalizedTaskType));
|
||||||
|
const existingTask = normalizedKey
|
||||||
|
? Array.from(tasksById.values()).find(function (task) {
|
||||||
|
return task.key === normalizedKey && task.status === 'queued' && (!normalizedTaskType || task.taskType === normalizedTaskType);
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (existingTask) {
|
||||||
|
existingTask.title = normalizedTitle;
|
||||||
|
existingTask.category = normalizeText(definition && definition.category) || existingTask.category || 'general';
|
||||||
|
existingTask.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||||
|
existingTask.taskType = normalizedTaskType || existingTask.taskType || '';
|
||||||
|
existingTask.payload = definition && definition.payload !== undefined ? definition.payload : existingTask.payload;
|
||||||
|
existingTask.run = typeof definition.run === 'function' ? definition.run : existingTask.run;
|
||||||
|
existingTask.createdAt = toIsoDate(new Date());
|
||||||
|
existingTask.errorMessage = '';
|
||||||
|
existingTask.persisted = existingTask.persisted || shouldPersist;
|
||||||
|
await persistTaskUpdate(existingTask);
|
||||||
|
return buildSnapshot(existingTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = {
|
||||||
|
id: shouldPersist ? 0 : nextTaskId,
|
||||||
|
key: normalizedKey,
|
||||||
|
taskType: normalizedTaskType,
|
||||||
|
title: normalizedTitle,
|
||||||
|
category: normalizeText(definition && definition.category) || 'general',
|
||||||
|
status: 'queued',
|
||||||
|
createdAt: toIsoDate(new Date()),
|
||||||
|
startedAt: '',
|
||||||
|
finishedAt: '',
|
||||||
|
errorMessage: '',
|
||||||
|
attempts: 0,
|
||||||
|
metadata: definition && definition.metadata ? definition.metadata : {},
|
||||||
|
payload: definition && definition.payload !== undefined ? definition.payload : null,
|
||||||
|
persisted: shouldPersist,
|
||||||
|
run: typeof definition.run === 'function' ? definition.run : function () {
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
completionPromise: null,
|
||||||
|
resolveCompletion: null,
|
||||||
|
rejectCompletion: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const completionController = createTaskCompletionController();
|
||||||
|
task.completionPromise = completionController.promise;
|
||||||
|
task.resolveCompletion = completionController.resolve;
|
||||||
|
task.rejectCompletion = completionController.reject;
|
||||||
|
|
||||||
|
if (shouldPersist) {
|
||||||
|
await persistTaskInsert(task);
|
||||||
|
} else {
|
||||||
|
nextTaskId += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tasksById.set(task.id, task);
|
||||||
|
pendingIds.push(task.id);
|
||||||
|
scheduleDrain();
|
||||||
|
return buildSnapshot(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerRecurringTask(definition) {
|
||||||
|
const normalizedKey = normalizeText(definition && definition.key);
|
||||||
|
if (!normalizedKey) {
|
||||||
|
throw new Error('Recurring tasks require a key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0);
|
||||||
|
if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
|
||||||
|
throw new Error('Recurring tasks require a valid interval.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = recurringJobsByKey.get(normalizedKey) || {
|
||||||
|
key: normalizedKey,
|
||||||
|
activeTaskId: null,
|
||||||
|
lastRunAt: '',
|
||||||
|
lastStatus: '',
|
||||||
|
lastError: '',
|
||||||
|
nextRunAt: '',
|
||||||
|
timerId: null,
|
||||||
|
enabled: true
|
||||||
|
};
|
||||||
|
|
||||||
|
clearRecurringTimer(job);
|
||||||
|
job.title = normalizeText(definition && definition.title) || 'Background task';
|
||||||
|
job.category = normalizeText(definition && definition.category) || 'general';
|
||||||
|
job.intervalMs = intervalMs;
|
||||||
|
job.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||||
|
job.run = typeof definition.run === 'function' ? definition.run : function () {
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
job.enabled = definition && definition.enabled === false ? false : true;
|
||||||
|
recurringJobsByKey.set(normalizedKey, job);
|
||||||
|
|
||||||
|
if (job.enabled) {
|
||||||
|
scheduleRecurringRun(job, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildRecurringSnapshot(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRecurringTask(recurringKey) {
|
||||||
|
const normalizedKey = normalizeText(recurringKey);
|
||||||
|
const job = recurringJobsByKey.get(normalizedKey);
|
||||||
|
if (!job) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRecurringTimer(job);
|
||||||
|
recurringJobsByKey.delete(normalizedKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecurringSnapshot(job) {
|
||||||
|
return {
|
||||||
|
key: job.key,
|
||||||
|
title: job.title,
|
||||||
|
category: job.category,
|
||||||
|
intervalMs: job.intervalMs,
|
||||||
|
enabled: job.enabled !== false,
|
||||||
|
activeTaskId: job.activeTaskId || null,
|
||||||
|
createdAt: job.createdAt || '',
|
||||||
|
nextRunAt: job.nextRunAt || '',
|
||||||
|
lastRunAt: job.lastRunAt || '',
|
||||||
|
lastStatus: job.lastStatus || '',
|
||||||
|
lastError: job.lastError || '',
|
||||||
|
metadata: job.metadata || {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskSortTime(task) {
|
||||||
|
const finishedTime = task && task.finishedAt ? Date.parse(task.finishedAt) : NaN;
|
||||||
|
if (Number.isFinite(finishedTime)) {
|
||||||
|
return finishedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdTime = task && task.createdAt ? Date.parse(task.createdAt) : NaN;
|
||||||
|
if (Number.isFinite(createdTime)) {
|
||||||
|
return createdTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listTasks() {
|
||||||
|
return Array.from(tasksById.values())
|
||||||
|
.slice()
|
||||||
|
.sort(function (left, right) {
|
||||||
|
const leftQueuedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
|
||||||
|
const rightQueuedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
|
||||||
|
if (Number.isFinite(leftQueuedTime) && Number.isFinite(rightQueuedTime) && leftQueuedTime !== rightQueuedTime) {
|
||||||
|
return rightQueuedTime - leftQueuedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(leftQueuedTime) !== Number.isFinite(rightQueuedTime)) {
|
||||||
|
return Number.isFinite(leftQueuedTime) ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return right.id - left.id;
|
||||||
|
})
|
||||||
|
.map(buildSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRecurringTasks() {
|
||||||
|
return Array.from(recurringJobsByKey.values())
|
||||||
|
.slice()
|
||||||
|
.sort(function (left, right) {
|
||||||
|
return left.key.localeCompare(right.key);
|
||||||
|
})
|
||||||
|
.map(buildRecurringSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFinishedTasks() {
|
||||||
|
let removedCount = 0;
|
||||||
|
Array.from(tasksById.values()).forEach(function (task) {
|
||||||
|
if (task.status === 'running' || task.status === 'queued') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tasksById.delete(task.id);
|
||||||
|
removedCount += 1;
|
||||||
|
persistTaskDelete(task.id).catch(function (error) {
|
||||||
|
console.warn('Unable to delete finished task from persistence:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return removedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelTask(taskId) {
|
||||||
|
const task = getTaskById(taskId);
|
||||||
|
if (!task || task.status !== 'queued') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
task.status = 'canceled';
|
||||||
|
task.finishedAt = toIsoDate(new Date());
|
||||||
|
const pendingIndex = pendingIds.indexOf(task.id);
|
||||||
|
if (pendingIndex >= 0) {
|
||||||
|
pendingIds.splice(pendingIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof task.rejectCompletion === 'function') {
|
||||||
|
const cancellationError = new Error('Task canceled.');
|
||||||
|
cancellationError.task = buildSnapshot(task);
|
||||||
|
task.rejectCompletion(cancellationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
persistTaskUpdate(task).catch(function (error) {
|
||||||
|
console.warn('Unable to persist canceled task:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryTask(taskId) {
|
||||||
|
const task = getTaskById(taskId);
|
||||||
|
if (!task || task.status !== 'failed') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return enqueueTask({
|
||||||
|
key: task.key,
|
||||||
|
title: task.title,
|
||||||
|
category: task.category,
|
||||||
|
metadata: task.metadata,
|
||||||
|
run: task.run
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummary() {
|
||||||
|
const counts = {
|
||||||
|
queued: 0,
|
||||||
|
running: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
canceled: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
listTasks().forEach(function (task) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(counts, task.status)) {
|
||||||
|
counts[task.status] += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCount: activeCount,
|
||||||
|
counts: counts,
|
||||||
|
scheduledCount: recurringJobsByKey.size,
|
||||||
|
total: listTasks().length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueueTask: enqueueTask,
|
||||||
|
enqueueTaskAndWait: function (definition) {
|
||||||
|
return enqueueTask(definition).then(function (snapshot) {
|
||||||
|
const task = snapshot && snapshot.id ? getTaskById(snapshot.id) : null;
|
||||||
|
if (!task || !task.completionPromise) {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return task.completionPromise;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
initialize: initialize,
|
||||||
|
setTaskHandler: setTaskHandler,
|
||||||
|
registerRecurringTask: registerRecurringTask,
|
||||||
|
removeRecurringTask: removeRecurringTask,
|
||||||
|
runRecurringTask: runRecurringTask,
|
||||||
|
listTasks: listTasks,
|
||||||
|
listRecurringTasks: listRecurringTasks,
|
||||||
|
getTaskById: getTaskById,
|
||||||
|
getSummary: getSummary,
|
||||||
|
cancelTask: cancelTask,
|
||||||
|
retryTask: retryTask,
|
||||||
|
clearFinishedTasks: clearFinishedTasks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createBackgroundTaskQueue: createBackgroundTaskQueue,
|
||||||
|
normalizeIntervalMs: normalizeIntervalMs
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
const { WebSocket } = require('ws');
|
||||||
|
|
||||||
|
function normalizeClientName(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug) {
|
||||||
|
return (screens || []).map(function (screen) {
|
||||||
|
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||||
|
return Object.assign({}, screen, {
|
||||||
|
client_name: onboardingNameBySlug[screen.slug] || null,
|
||||||
|
player_connection_count: connectionState.count || 0,
|
||||||
|
player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate) {
|
||||||
|
return (screens || []).flatMap(function (screen) {
|
||||||
|
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||||
|
return (connectionState.connections || []).map(function (connection) {
|
||||||
|
const deviceId = String(connection.deviceId || '').trim();
|
||||||
|
return Object.assign({}, connection, {
|
||||||
|
screen_slug: screen.slug,
|
||||||
|
screen_name: screen.name,
|
||||||
|
client_name: (deviceId && onboardingNameByDeviceId && onboardingNameByDeviceId[deviceId]) || connection.clientName || onboardingNameBySlug[screen.slug] || String(connection.clientId || '').trim() || null,
|
||||||
|
playlist_name: screen.playlist_name || null,
|
||||||
|
connectedAtLabel: formatDashboardDate(connection.connectedAt),
|
||||||
|
lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt),
|
||||||
|
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDashboardStateService(options) {
|
||||||
|
const pool = options && options.pool;
|
||||||
|
const common = options && options.common;
|
||||||
|
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||||
|
const playerSnapshotSockets = options && options.playerSnapshotSockets;
|
||||||
|
const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription;
|
||||||
|
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
|
||||||
|
const formatDashboardDate = options && options.formatDashboardDate;
|
||||||
|
|
||||||
|
if (!pool || !common || !playerSnapshotCache || !playerSnapshotSockets || typeof ensurePlayerSnapshotSubscription !== 'function' || typeof formatDashboardDate !== 'function') {
|
||||||
|
throw new Error('createDashboardStateService requires the dashboard dependencies.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildDashboardState() {
|
||||||
|
const data = await common.fetchAdminData(pool);
|
||||||
|
const screensData = data.screens || [];
|
||||||
|
screensData.forEach(function (screen) {
|
||||||
|
ensurePlayerSnapshotSubscription(screen.slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [onboardingRows] = await pool.query(
|
||||||
|
`SELECT s.slug, pod.device_id, pod.client_name
|
||||||
|
FROM player_onboarding_devices pod
|
||||||
|
JOIN screens s ON s.id = pod.screen_id
|
||||||
|
WHERE pod.client_name IS NOT NULL
|
||||||
|
AND TRIM(pod.client_name) <> ''`
|
||||||
|
);
|
||||||
|
const onboardingNameBySlug = {};
|
||||||
|
const onboardingNameByDeviceId = {};
|
||||||
|
onboardingRows.forEach(function (row) {
|
||||||
|
const clientName = normalizeClientName(row.client_name);
|
||||||
|
const slug = normalizeClientName(row.slug);
|
||||||
|
const deviceId = normalizeClientName(row.device_id);
|
||||||
|
if (slug) {
|
||||||
|
onboardingNameBySlug[slug] = clientName;
|
||||||
|
}
|
||||||
|
if (deviceId) {
|
||||||
|
onboardingNameByDeviceId[deviceId] = clientName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionsBySlug = {};
|
||||||
|
screensData.forEach(function (screen) {
|
||||||
|
const cached = playerSnapshotCache.get(String(screen.slug || '').trim());
|
||||||
|
if (cached && Array.isArray(cached.connections)) {
|
||||||
|
connectionsBySlug[screen.slug] = cached;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) {
|
||||||
|
return Object.assign({}, screen, {
|
||||||
|
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
|
||||||
|
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
||||||
|
return socket && socket.readyState === WebSocket.OPEN;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
playlists: data.playlists || [],
|
||||||
|
screens: screens,
|
||||||
|
clients: clients,
|
||||||
|
slides: data.slides || [],
|
||||||
|
playerServiceConnected: playerServiceConnected,
|
||||||
|
connectedClientsCount: screens.reduce(function (total, screen) {
|
||||||
|
return total + Number(screen.player_connection_count || 0);
|
||||||
|
}, 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildDashboardState: buildDashboardState
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createDashboardStateService };
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
let responseDetails = null;
|
||||||
|
let pullError = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
responseDetails = await common.fetchApiSourceResponse(apiUrl);
|
||||||
|
} catch (error) {
|
||||||
|
pullError = String(error && error.message ? error.message : 'Unable to load API response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.beginTransaction();
|
||||||
|
await connection.query(
|
||||||
|
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||||
|
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
|
||||||
|
);
|
||||||
|
await connection.commit();
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await connection.rollback();
|
||||||
|
} catch (_rollbackError) {
|
||||||
|
// Ignore rollback failures and surface the original error.
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId) {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
let updatedItems = [];
|
||||||
|
let pullError = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
updatedItems = await common.fetchRssFeedItems(feedUrl, itemLimit);
|
||||||
|
} catch (error) {
|
||||||
|
pullError = String(error && error.message ? error.message : 'Unable to load feed items.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.beginTransaction();
|
||||||
|
if (typeof common.replaceRssFeedItems === 'function') {
|
||||||
|
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||||
|
}
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
if (pullError) {
|
||||||
|
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await connection.rollback();
|
||||||
|
} catch (_rollbackError) {
|
||||||
|
// Ignore rollback failures and surface the original error.
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
refreshApiSource: refreshApiSource,
|
||||||
|
refreshRssFeed: refreshRssFeed
|
||||||
|
};
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
const dashboardDateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeUploadRoot(uploadDir) {
|
||||||
|
return require('path').resolve(String(uploadDir || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDashboardDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return dashboardDateFormatter.format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDashboardPayload(state) {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'dashboard-state',
|
||||||
|
state: state
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readArrayField(body, keys) {
|
||||||
|
const searchKeys = Array.isArray(keys) ? keys : [keys];
|
||||||
|
for (let i = 0; i < searchKeys.length; i += 1) {
|
||||||
|
const key = searchKeys[i];
|
||||||
|
const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter(function (item) {
|
||||||
|
return item !== undefined && item !== null && String(item).trim() !== '';
|
||||||
|
}).map(function (item) {
|
||||||
|
return String(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||||
|
return [String(value)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateTimeLocal(value) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = new Date(String(value));
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeLocal(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!/^\d{2}:\d{2}(:\d{2})?$/.test(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return raw.length === 5 ? raw + ':00' : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScheduleMode(value) {
|
||||||
|
const mode = String(value || 'always');
|
||||||
|
if (mode === 'dates' || mode === 'times') {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
return 'always';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuditUserId(req) {
|
||||||
|
return req && req.currentUser ? Number(req.currentUser.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasSignature(width, height) {
|
||||||
|
const normalizedWidth = Number(width);
|
||||||
|
const normalizedHeight = Number(height);
|
||||||
|
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalizedWidth + 'x' + normalizedHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlaylistCanvasSignature(pool, playlistId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
|
FROM playlist_slides ps
|
||||||
|
JOIN slides sl ON sl.id = ps.slide_id
|
||||||
|
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||||
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
|
WHERE ps.playlist_id = ?
|
||||||
|
AND cs.width IS NOT NULL
|
||||||
|
AND cs.height IS NOT NULL`,
|
||||||
|
[playlistId]
|
||||||
|
);
|
||||||
|
const signatures = Array.from(new Set(rows.map(function (row) {
|
||||||
|
return getCanvasSignature(row.canvas_width, row.canvas_height);
|
||||||
|
}).filter(Boolean)));
|
||||||
|
if (!signatures.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return signatures.length === 1 ? signatures[0] : 'mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchScreensByPlaylistId(connection, playlistId) {
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
'SELECT slug FROM screens WHERE playlist_id = ? AND slug IS NOT NULL',
|
||||||
|
[playlistId]
|
||||||
|
);
|
||||||
|
return rows.map(function (row) {
|
||||||
|
return row.slug;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchScreensBySlideId(connection, slideId) {
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT DISTINCT s.slug
|
||||||
|
FROM screens s
|
||||||
|
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||||
|
WHERE ps.slide_id = ?
|
||||||
|
AND s.slug IS NOT NULL`,
|
||||||
|
[slideId]
|
||||||
|
);
|
||||||
|
return rows.map(function (row) {
|
||||||
|
return row.slug;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchScreensByTemplateId(connection, templateId) {
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT DISTINCT s.slug
|
||||||
|
FROM screens s
|
||||||
|
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||||
|
JOIN slides sl ON sl.id = ps.slide_id
|
||||||
|
WHERE sl.template_id = ?
|
||||||
|
AND s.slug IS NOT NULL`,
|
||||||
|
[templateId]
|
||||||
|
);
|
||||||
|
return rows.map(function (row) {
|
||||||
|
return row.slug;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOrderedPlaylistSlides(connection, playlistId) {
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
'SELECT id, position FROM playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC',
|
||||||
|
[playlistId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectAfterSave(req, res, defaultUrl, options) {
|
||||||
|
const safeOptions = options || {};
|
||||||
|
const action = String((req && req.body && req.body.action) || req.query.action || '').toLowerCase();
|
||||||
|
if (action === 'close') {
|
||||||
|
return res.redirect(safeOptions.closeUrl || defaultUrl);
|
||||||
|
}
|
||||||
|
if (action === 'new') {
|
||||||
|
return res.redirect(safeOptions.newUrl || defaultUrl);
|
||||||
|
}
|
||||||
|
const message = safeOptions.message || '';
|
||||||
|
if (message) {
|
||||||
|
const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&';
|
||||||
|
return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message));
|
||||||
|
}
|
||||||
|
return res.redirect(defaultUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeUploadRoot: normalizeUploadRoot,
|
||||||
|
formatDashboardDate: formatDashboardDate,
|
||||||
|
buildDashboardPayload: buildDashboardPayload,
|
||||||
|
readArrayField: readArrayField,
|
||||||
|
parseDateTimeLocal: parseDateTimeLocal,
|
||||||
|
parseTimeLocal: parseTimeLocal,
|
||||||
|
normalizeScheduleMode: normalizeScheduleMode,
|
||||||
|
getAuditUserId: getAuditUserId,
|
||||||
|
getCanvasSignature: getCanvasSignature,
|
||||||
|
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||||
|
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
|
||||||
|
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||||
|
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||||
|
fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides,
|
||||||
|
redirectAfterSave: redirectAfterSave
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
function normalizePageNumber(value) {
|
||||||
|
const pageNumber = Math.floor(Number(value) || 1);
|
||||||
|
return Math.max(1, pageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryString(query) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
Object.keys(query || {}).forEach(function (key) {
|
||||||
|
const value = query[key];
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
return queryString ? `?${queryString}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPaginationPages(totalPages, currentPage, pageParam, queryState, maxVisiblePages) {
|
||||||
|
const pages = [];
|
||||||
|
const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages));
|
||||||
|
let startPage = Math.max(1, currentPage - Math.floor(visiblePageCount / 2));
|
||||||
|
let endPage = startPage + visiblePageCount - 1;
|
||||||
|
|
||||||
|
if (endPage > totalPages) {
|
||||||
|
endPage = totalPages;
|
||||||
|
startPage = Math.max(1, endPage - visiblePageCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) {
|
||||||
|
const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber });
|
||||||
|
pages.push({
|
||||||
|
number: pageNumber,
|
||||||
|
active: pageNumber === currentPage,
|
||||||
|
url: buildQueryString(nextQuery)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPagination(totalItems, currentPage, pageParam, queryState, pageSize, itemLabel, ariaLabel) {
|
||||||
|
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
||||||
|
const normalizedPageSize = Math.max(1, Number(pageSize) || 10);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(Number(totalItems) / normalizedPageSize));
|
||||||
|
const safeCurrentPage = Math.min(normalizePageNumber(currentPage), totalPages);
|
||||||
|
const startIndex = totalItems <= 0 ? 0 : (safeCurrentPage - 1) * normalizedPageSize;
|
||||||
|
const endIndex = totalItems <= 0 ? 0 : Math.min(totalItems, startIndex + normalizedPageSize);
|
||||||
|
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
|
||||||
|
|
||||||
|
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
|
||||||
|
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPage: safeCurrentPage,
|
||||||
|
totalPages: totalPages,
|
||||||
|
totalItems: Number(totalItems) || 0,
|
||||||
|
hasMultiplePages: totalPages > 1,
|
||||||
|
startItem: startIndex + 1,
|
||||||
|
endItem: endIndex,
|
||||||
|
hasPrevious: safeCurrentPage > 1,
|
||||||
|
hasNext: safeCurrentPage < totalPages,
|
||||||
|
previousUrl: buildQueryString(previousQuery),
|
||||||
|
nextUrl: buildQueryString(nextQuery),
|
||||||
|
pages: pages,
|
||||||
|
pageSize: normalizedPageSize,
|
||||||
|
pageParam: normalizedPageParam,
|
||||||
|
itemLabel: String(itemLabel || 'items'),
|
||||||
|
ariaLabel: String(ariaLabel || 'Pagination')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizePageNumber,
|
||||||
|
buildQueryString,
|
||||||
|
buildPaginationPages,
|
||||||
|
buildPagination
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||||
|
|
||||||
|
function createPlayerActionService(options) {
|
||||||
|
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||||
|
const common = options && options.common;
|
||||||
|
|
||||||
|
if (!playerInternalBaseUrl || !common) {
|
||||||
|
throw new Error('createPlayerActionService requires the player action dependencies.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||||
|
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||||
|
? Object.assign({}, commandOrPayload)
|
||||||
|
: { command: commandOrPayload };
|
||||||
|
if (connectionId) {
|
||||||
|
payload.connectionId = connectionId;
|
||||||
|
}
|
||||||
|
const authHeaders = createRequestAuthHeaders({
|
||||||
|
method: 'POST',
|
||||||
|
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/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 ${slug}.`);
|
||||||
|
error.statusCode = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getScreenConnections(slug) {
|
||||||
|
const authHeaders = createRequestAuthHeaders({
|
||||||
|
method: 'GET',
|
||||||
|
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
|
||||||
|
});
|
||||||
|
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||||
|
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 load screen connections for ${slug}.`);
|
||||||
|
error.statusCode = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return { connections: [] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
|
||||||
|
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM player_onboarding_devices WHERE screen_id = ?', [screen.id]);
|
||||||
|
if (Number(rows[0] && rows[0].ref_count) > 0) {
|
||||||
|
return 'This screen is still linked to onboarding devices.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof getScreenConnections === 'function' && String(screen && screen.slug ? screen.slug : '').trim()) {
|
||||||
|
try {
|
||||||
|
const response = await getScreenConnections(screen.slug);
|
||||||
|
const liveConnections = Array.isArray(response && response.connections) ? response.connections : [];
|
||||||
|
if (liveConnections.length > 0) {
|
||||||
|
return 'This screen is still in use by connected players.';
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// Keep the delete guard based on onboarding references if live connection lookup fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSlideDeleteBlockMessage(pool, slide) {
|
||||||
|
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM playlist_slides WHERE slide_id = ?', [slide.id]);
|
||||||
|
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This slide is still used by one or more playlists.' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTemplateDeleteBlockMessage(pool, template) {
|
||||||
|
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slides WHERE template_id = ?', [template.id]);
|
||||||
|
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This template is still used by one or more slides.' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCanvasSizeDeleteBlockMessage(pool, canvasSize) {
|
||||||
|
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slide_templates WHERE canvas_size_id = ?', [canvasSize.id]);
|
||||||
|
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This canvas size is still used by one or more templates.' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPlaylistDeleteBlockMessage(pool, playlist) {
|
||||||
|
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM screens WHERE playlist_id = ?', [playlist.id]);
|
||||||
|
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This playlist is still assigned to one or more screens.' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
forwardPlayerCommand: forwardPlayerCommand,
|
||||||
|
getScreenConnections: getScreenConnections,
|
||||||
|
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
||||||
|
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
||||||
|
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
|
||||||
|
getCanvasSizeDeleteBlockMessage: getCanvasSizeDeleteBlockMessage,
|
||||||
|
getPlaylistDeleteBlockMessage: getPlaylistDeleteBlockMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createPlayerActionService };
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
|
||||||
|
const { fetchPagedRows } = require('../../data/utils');
|
||||||
|
|
||||||
|
function parseCsvIds(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.split(',')
|
||||||
|
.map(function (item) {
|
||||||
|
return Number(item);
|
||||||
|
})
|
||||||
|
.filter(function (item) {
|
||||||
|
return Number.isInteger(item) && item > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPermissions(pool) {
|
||||||
|
const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM permissions ORDER BY section_name ASC, name ASC');
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoles(pool) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||||
|
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||||
|
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||||
|
FROM roles r
|
||||||
|
ORDER BY r.name ASC`
|
||||||
|
);
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRolesPage(pool, page, pageSize) {
|
||||||
|
const paged = await fetchPagedRows(pool, {
|
||||||
|
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||||
|
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||||
|
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||||
|
FROM roles r
|
||||||
|
ORDER BY r.name ASC`,
|
||||||
|
countSql: 'SELECT COUNT(*) AS count FROM roles',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ roles: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoleById(pool, roleId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||||
|
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||||
|
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||||
|
FROM roles r
|
||||||
|
WHERE r.id = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[roleId]
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRolePermissionKeys(pool, roleId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT p.permission_key
|
||||||
|
FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE rp.role_id = ?
|
||||||
|
ORDER BY p.section_name ASC, p.name ASC`,
|
||||||
|
[roleId]
|
||||||
|
);
|
||||||
|
return (rows || []).map(function (row) {
|
||||||
|
return String(row.permission_key || '').trim();
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoleUserIds(pool, roleId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT ur.user_id
|
||||||
|
FROM user_roles ur
|
||||||
|
WHERE ur.role_id = ?
|
||||||
|
ORDER BY ur.user_id ASC`,
|
||||||
|
[roleId]
|
||||||
|
);
|
||||||
|
return (rows || []).map(function (row) {
|
||||||
|
return Number(row.user_id);
|
||||||
|
}).filter(function (userId) {
|
||||||
|
return Number.isInteger(userId) && userId > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRolesForUser(pool, userId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT r.id, r.role_key, r.name, r.description
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = ?
|
||||||
|
ORDER BY r.name ASC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsersWithRoles(pool) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||||
|
COALESCE(role_data.role_names, '') AS role_names,
|
||||||
|
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT ur.user_id,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||||
|
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
GROUP BY ur.user_id
|
||||||
|
) role_data ON role_data.user_id = u.id
|
||||||
|
ORDER BY u.id ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
return (rows || []).map(function (row) {
|
||||||
|
return Object.assign({}, row, {
|
||||||
|
roleIds: parseCsvIds(row.role_ids_csv),
|
||||||
|
roleNames: String(row.role_names || '').trim()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsersWithRolesPage(pool, page, pageSize) {
|
||||||
|
const paged = await fetchPagedRows(pool, {
|
||||||
|
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||||
|
COALESCE(role_data.role_names, '') AS role_names,
|
||||||
|
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT ur.user_id,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||||
|
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
GROUP BY ur.user_id
|
||||||
|
) role_data ON role_data.user_id = u.id
|
||||||
|
ORDER BY u.id ASC`,
|
||||||
|
countSql: 'SELECT COUNT(*) AS count FROM users',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: (paged.rows || []).map(function (row) {
|
||||||
|
return Object.assign({}, row, {
|
||||||
|
roleIds: parseCsvIds(row.role_ids_csv),
|
||||||
|
roleNames: String(row.role_names || '').trim()
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
totalItems: paged.totalItems,
|
||||||
|
totalPages: paged.totalPages,
|
||||||
|
currentPage: paged.currentPage,
|
||||||
|
pageSize: paged.pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserWithRoles(pool, userId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||||
|
COALESCE(role_data.role_names, '') AS role_names,
|
||||||
|
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT ur.user_id,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||||
|
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
GROUP BY ur.user_id
|
||||||
|
) role_data ON role_data.user_id = u.id
|
||||||
|
WHERE u.id = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.assign({}, rows[0], {
|
||||||
|
roleIds: parseCsvIds(rows[0].role_ids_csv),
|
||||||
|
roleNames: String(rows[0].role_names || '').trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncUserRoles(pool, userId, roleIds) {
|
||||||
|
const uniqueRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
|
||||||
|
return Number(roleId);
|
||||||
|
}).filter(function (roleId) {
|
||||||
|
return Number.isInteger(roleId) && roleId > 0;
|
||||||
|
})));
|
||||||
|
|
||||||
|
await pool.query('DELETE FROM user_roles WHERE user_id = ?', [userId]);
|
||||||
|
for (const roleId of uniqueRoleIds) {
|
||||||
|
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRoleUsers(pool, roleId, userIds) {
|
||||||
|
const uniqueUserIds = Array.from(new Set((Array.isArray(userIds) ? userIds : []).map(function (userId) {
|
||||||
|
return Number(userId);
|
||||||
|
}).filter(function (userId) {
|
||||||
|
return Number.isInteger(userId) && userId > 0;
|
||||||
|
})));
|
||||||
|
|
||||||
|
await pool.query('DELETE FROM user_roles WHERE role_id = ?', [roleId]);
|
||||||
|
for (const userId of uniqueUserIds) {
|
||||||
|
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRolePermissions(pool, roleId, permissionKeys) {
|
||||||
|
const uniquePermissionKeys = normalizePermissionKeys(permissionKeys);
|
||||||
|
|
||||||
|
if (!uniquePermissionKeys.length) {
|
||||||
|
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [permissionRows] = await pool.query('SELECT id, permission_key FROM permissions WHERE permission_key IN (?)', [uniquePermissionKeys]);
|
||||||
|
if (permissionRows.length !== uniquePermissionKeys.length) {
|
||||||
|
throw new Error('One or more selected permissions are invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
|
||||||
|
for (const permissionRow of permissionRows) {
|
||||||
|
await pool.query('INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PERMISSIONS,
|
||||||
|
fetchPermissions,
|
||||||
|
fetchRoles,
|
||||||
|
fetchRolesPage,
|
||||||
|
fetchRoleById,
|
||||||
|
fetchRolePermissionKeys,
|
||||||
|
fetchRoleUserIds,
|
||||||
|
fetchRolesForUser,
|
||||||
|
fetchUsersWithRoles,
|
||||||
|
fetchUsersWithRolesPage,
|
||||||
|
fetchUserWithRoles,
|
||||||
|
syncUserRoles,
|
||||||
|
syncRoleUsers,
|
||||||
|
syncRolePermissions
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
const { normalizePermissionKeys } = require('../../rbac');
|
||||||
|
|
||||||
|
function createSessionService(options) {
|
||||||
|
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
||||||
|
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
|
||||||
|
const hashSessionToken = options && options.hashSessionToken;
|
||||||
|
const createSessionToken = options && options.createSessionToken;
|
||||||
|
|
||||||
|
if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') {
|
||||||
|
throw new Error('createSessionService requires the session dependencies.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookies(cookieHeader) {
|
||||||
|
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
|
||||||
|
if (!pair) {
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
const separatorIndex = pair.indexOf('=');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
|
||||||
|
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
|
||||||
|
if (name) {
|
||||||
|
cookies[name] = value;
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeCookie(name, value, options) {
|
||||||
|
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
||||||
|
if (options && options.maxAge !== undefined) {
|
||||||
|
parts.push(`Max-Age=${Math.max(0, Math.trunc(Number(options.maxAge) / 1000))}`);
|
||||||
|
}
|
||||||
|
parts.push('Path=/');
|
||||||
|
parts.push('HttpOnly');
|
||||||
|
parts.push('SameSite=Lax');
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionCookie(res) {
|
||||||
|
res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionCookie(res, token) {
|
||||||
|
res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurrentUser(pool, req) {
|
||||||
|
const cookies = parseCookies(req.headers.cookie || '');
|
||||||
|
const token = cookies[sessionCookieName];
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = hashSessionToken(token);
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT s.user_id, u.id, u.name, u.username
|
||||||
|
FROM auth_sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.session_hash = ?
|
||||||
|
AND s.expires_at > NOW()
|
||||||
|
LIMIT 1`,
|
||||||
|
[tokenHash]
|
||||||
|
);
|
||||||
|
if (!rows.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Number(rows[0].id);
|
||||||
|
const [roleRows] = await pool.query(
|
||||||
|
`SELECT r.role_key
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = ?
|
||||||
|
ORDER BY r.name ASC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const [permissionRows] = await pool.query(
|
||||||
|
`SELECT p.permission_key
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN role_permissions rp ON rp.role_id = ur.role_id
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE ur.user_id = ?
|
||||||
|
ORDER BY p.section_name ASC, p.name ASC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query('UPDATE auth_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
||||||
|
return Object.assign({}, rows[0], {
|
||||||
|
roleKeys: roleRows.map(function (row) {
|
||||||
|
return String(row.role_key || '').trim();
|
||||||
|
}).filter(Boolean),
|
||||||
|
permissionKeys: normalizePermissionKeys(permissionRows.map(function (row) {
|
||||||
|
return String(row.permission_key || '').trim();
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUserSession(pool, userId) {
|
||||||
|
const token = createSessionToken();
|
||||||
|
const tokenHash = hashSessionToken(token);
|
||||||
|
const expiresAt = new Date(Date.now() + sessionMaxAgeMs);
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO auth_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[tokenHash, userId, expiresAt, userId, userId]
|
||||||
|
);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (req.currentUser) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parseCookies: parseCookies,
|
||||||
|
serializeCookie: serializeCookie,
|
||||||
|
clearSessionCookie: clearSessionCookie,
|
||||||
|
setSessionCookie: setSessionCookie,
|
||||||
|
loadCurrentUser: loadCurrentUser,
|
||||||
|
createUserSession: createUserSession,
|
||||||
|
requireAuth: requireAuth
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createSessionService };
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const puppeteer = require('puppeteer-core');
|
||||||
|
const chromiumModule = require('@sparticuz/chromium');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
||||||
|
? chromiumModule
|
||||||
|
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
||||||
|
? chromiumModule.default
|
||||||
|
: chromiumModule;
|
||||||
|
const {
|
||||||
|
escapeHtml,
|
||||||
|
mediaKind,
|
||||||
|
renderEditorJsContent,
|
||||||
|
sanitizeFontFamily,
|
||||||
|
sanitizeFontSize,
|
||||||
|
sanitizeTextColor
|
||||||
|
} = require('../../player/render-helpers');
|
||||||
|
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||||
|
|
||||||
|
const SYSTEM_CHROMIUM_PATHS = [
|
||||||
|
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||||
|
process.env.CHROMIUM_PATH,
|
||||||
|
'/usr/bin/chromium',
|
||||||
|
'/usr/bin/chromium-browser',
|
||||||
|
'/usr/local/bin/chromium',
|
||||||
|
'/snap/bin/chromium'
|
||||||
|
].filter(Boolean);
|
||||||
|
const PLAYER_VIEWPORT = {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
deviceScaleFactor: 1
|
||||||
|
};
|
||||||
|
const THUMBNAIL_MAX_SIZE = {
|
||||||
|
width: 480,
|
||||||
|
height: 270
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl) {
|
||||||
|
return String(baseUrl || '').trim().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAssetUrl(baseUrl, value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
if (!normalizedBaseUrl) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (raw.startsWith('/')) {
|
||||||
|
return normalizedBaseUrl + raw;
|
||||||
|
}
|
||||||
|
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasSize(slide) {
|
||||||
|
const template = slide && slide.template ? slide.template : null;
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
|
||||||
|
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionContent(slide, region) {
|
||||||
|
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
|
||||||
|
return content && typeof content === 'object' ? content : { value: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasVisibleContent(html) {
|
||||||
|
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTextRegionMarkup(region, regionContent) {
|
||||||
|
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
|
||||||
|
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
|
||||||
|
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
|
||||||
|
const renderedBody = renderEditorJsContent(regionContent.value || '');
|
||||||
|
if (!hasVisibleContent(renderedBody)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||||
|
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
|
||||||
|
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||||
|
|
||||||
|
if (regionType === 'image') {
|
||||||
|
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||||
|
return src
|
||||||
|
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'video') {
|
||||||
|
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||||
|
return src
|
||||||
|
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'webpage') {
|
||||||
|
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||||
|
return src
|
||||||
|
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'html') {
|
||||||
|
const html = String(rawValue || '').trim();
|
||||||
|
return html
|
||||||
|
? '<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>'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'rtmp') {
|
||||||
|
const label = String(rawValue || '').trim() || 'RTMP source';
|
||||||
|
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTextRegionMarkup(region, regionContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchBrowser() {
|
||||||
|
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||||
|
return fs.existsSync(candidate);
|
||||||
|
}) || '';
|
||||||
|
const usingSystemChromium = Boolean(executablePath);
|
||||||
|
|
||||||
|
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
|
||||||
|
executablePath = await chromium.executablePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||||
|
throw new Error('Chromium executable was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usingSystemChromium) {
|
||||||
|
return puppeteer.launch({
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-gpu'
|
||||||
|
],
|
||||||
|
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
|
||||||
|
executablePath: executablePath,
|
||||||
|
headless: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return puppeteer.launch({
|
||||||
|
args: puppeteer.defaultArgs({
|
||||||
|
args: chromium && chromium.args ? chromium.args : [],
|
||||||
|
headless: 'shell'
|
||||||
|
}),
|
||||||
|
defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
|
||||||
|
executablePath: executablePath,
|
||||||
|
headless: 'shell'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureSlideThumbnail(options) {
|
||||||
|
const pool = options && options.pool;
|
||||||
|
const common = options && options.common;
|
||||||
|
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||||
|
const baseUrl = normalizeBaseUrl(options && options.baseUrl);
|
||||||
|
const slideId = Number(options && options.slideId || 0);
|
||||||
|
const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim();
|
||||||
|
|
||||||
|
if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) {
|
||||||
|
throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const slide = await common.fetchSlideById(pool, slideId);
|
||||||
|
if (!slide) {
|
||||||
|
throw new Error('Slide not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasSize = getCanvasSize(slide);
|
||||||
|
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
||||||
|
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
||||||
|
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
||||||
|
const fullSizePath = filePath.replace(/\.png$/i, '.full.png');
|
||||||
|
const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
|
||||||
|
const thumbnailPath = thumbnailRelativePath;
|
||||||
|
|
||||||
|
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
|
||||||
|
async function waitForThumbnailRender(page) {
|
||||||
|
await page.waitForFunction(function () {
|
||||||
|
return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas'));
|
||||||
|
}, { timeout: 30000 });
|
||||||
|
|
||||||
|
await page.waitForFunction(function () {
|
||||||
|
var canvas = document.querySelector('.slide-canvas');
|
||||||
|
if (!canvas) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var images = Array.prototype.slice.call(canvas.querySelectorAll('img'));
|
||||||
|
return images.every(function (image) {
|
||||||
|
return image.complete && typeof image.naturalWidth === 'number';
|
||||||
|
});
|
||||||
|
}, { timeout: 30000 });
|
||||||
|
|
||||||
|
await page.evaluate(async function () {
|
||||||
|
if (document.fonts && document.fonts.ready) {
|
||||||
|
try {
|
||||||
|
await document.fonts.ready;
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore font readiness failures and fall back to the rendered frame.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.evaluate(function () {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
window.requestAnimationFrame(function () {
|
||||||
|
window.requestAnimationFrame(resolve);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await launchBrowser();
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
try {
|
||||||
|
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview';
|
||||||
|
const previewUrl = baseUrl + previewPath;
|
||||||
|
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
||||||
|
method: 'GET',
|
||||||
|
pathname: previewPath
|
||||||
|
}));
|
||||||
|
await page.setViewport(PLAYER_VIEWPORT);
|
||||||
|
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
await waitForThumbnailRender(page);
|
||||||
|
const canvas = await page.$('.slide-canvas');
|
||||||
|
if (!canvas) {
|
||||||
|
throw new Error('Player render did not produce a slide canvas.');
|
||||||
|
}
|
||||||
|
await canvas.screenshot({ path: fullSizePath });
|
||||||
|
} finally {
|
||||||
|
await page.close().catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close().catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await sharp(fullSizePath)
|
||||||
|
.resize({
|
||||||
|
width: THUMBNAIL_MAX_SIZE.width,
|
||||||
|
height: THUMBNAIL_MAX_SIZE.height,
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toFile(thumbnailTempPath);
|
||||||
|
|
||||||
|
await fs.promises.rm(filePath, { force: true });
|
||||||
|
await fs.promises.rename(thumbnailTempPath, filePath);
|
||||||
|
await fs.promises.unlink(fullSizePath).catch(function (error) {
|
||||||
|
if (!error || error.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await pool.query('UPDATE slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
|
||||||
|
return {
|
||||||
|
slideId: slide.id,
|
||||||
|
thumbnailPath: thumbnailPath,
|
||||||
|
filePath: filePath,
|
||||||
|
fullSizePath: fullSizePath,
|
||||||
|
mediaKind: mediaKind(slide.media_path || '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
captureSlideThumbnail: captureSlideThumbnail
|
||||||
|
};
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const multer = require('multer');
|
||||||
|
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||||
|
|
||||||
|
function normalizeUploadRoot(uploadDir) {
|
||||||
|
return path.resolve(String(uploadDir || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUploadSyncService(options) {
|
||||||
|
const pool = options && options.pool;
|
||||||
|
const common = options && options.common;
|
||||||
|
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||||
|
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||||
|
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||||
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||||
|
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
let playerUploadSyncMode = null;
|
||||||
|
let playerUploadSyncModePromise = null;
|
||||||
|
const pendingPlayerUploadSyncs = new Map();
|
||||||
|
let pendingPlayerUploadSyncFlushTimer = null;
|
||||||
|
let pendingPlayerUploadSyncFlushInFlight = null;
|
||||||
|
const pendingPlaylistUploadSyncs = new Map();
|
||||||
|
let pendingPlaylistUploadSyncFlushTimer = null;
|
||||||
|
let pendingPlaylistUploadSyncFlushInFlight = null;
|
||||||
|
|
||||||
|
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
|
||||||
|
throw new Error('createUploadSyncService requires the upload dependencies.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUploadMiddleware(uploadDir) {
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: function (_req, _file, cb) {
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: function (_req, file, cb) {
|
||||||
|
const safeExt = path.extname(file.originalname || '').toLowerCase();
|
||||||
|
const stamp = `${Date.now()}-${crypto.randomUUID()}`;
|
||||||
|
cb(null, `${stamp}${safeExt}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_UPLOAD_BYTES
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUploadReference(uploadPath) {
|
||||||
|
const value = String(uploadPath || '').trim();
|
||||||
|
if (!value || !value.startsWith('/media/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadRelativePath(uploadPath) {
|
||||||
|
const value = normalizeUploadReference(uploadPath);
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.replace(/^\/media\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUploadFilePath(uploadDir, uploadPath) {
|
||||||
|
const relativePath = getUploadRelativePath(uploadPath);
|
||||||
|
if (!relativePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
|
||||||
|
if (!normalizedUploadDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relativePath.startsWith('uploads/')) {
|
||||||
|
return path.join(normalizedUploadDir, relativePath.slice('uploads/'.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(path.dirname(normalizedUploadDir), relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUploadReferencesFromValue(value, refs) {
|
||||||
|
if (!value) {
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
const stack = [value];
|
||||||
|
while (stack.length) {
|
||||||
|
const current = stack.pop();
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
current.forEach(function (item) {
|
||||||
|
stack.push(item);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current && typeof current === 'object') {
|
||||||
|
Object.keys(current).forEach(function (key) {
|
||||||
|
stack.push(current[key]);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof current === 'string') {
|
||||||
|
const reference = normalizeUploadReference(current);
|
||||||
|
if (reference) {
|
||||||
|
refs.add(reference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUploadReferencesFromSlide(slide) {
|
||||||
|
const refs = new Set();
|
||||||
|
if (!slide) {
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
collectUploadReferencesFromValue(slide.media_path, refs);
|
||||||
|
collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs);
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUploadReferencesFromTemplate(template) {
|
||||||
|
const refs = new Set();
|
||||||
|
if (!template) {
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
collectUploadReferencesFromValue(template.background_image_path, refs);
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUploadReferencesFromPayload(payload) {
|
||||||
|
const refs = new Set();
|
||||||
|
if (!payload) {
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
collectUploadReferencesFromValue(payload.mediaPath, refs);
|
||||||
|
collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs);
|
||||||
|
collectUploadReferencesFromValue(payload.backgroundImagePath, refs);
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countUploadReferences(pool, uploadPath) {
|
||||||
|
const [slideRows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS ref_count
|
||||||
|
FROM slides
|
||||||
|
WHERE media_path = ?
|
||||||
|
OR JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
|
||||||
|
[uploadPath, uploadPath]
|
||||||
|
);
|
||||||
|
const [templateRows] = await pool.query(
|
||||||
|
'SELECT COUNT(*) AS ref_count FROM slide_templates WHERE background_image_path = ?',
|
||||||
|
[uploadPath]
|
||||||
|
);
|
||||||
|
return Number(slideRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
|
||||||
|
const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean)));
|
||||||
|
for (let i = 0; i < uniquePaths.length; i += 1) {
|
||||||
|
const uploadPath = uniquePaths[i];
|
||||||
|
const referenceCount = await countUploadReferences(pool, uploadPath);
|
||||||
|
if (referenceCount > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code !== 'ENOENT') {
|
||||||
|
console.warn('Unable to remove unused upload file:', filePath, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queuePlayerUploadSync({
|
||||||
|
type: 'delete',
|
||||||
|
uploadPath: uploadPath,
|
||||||
|
uploadDir: uploadDir
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectUploadPathsFromDirectory(uploadDir) {
|
||||||
|
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
|
||||||
|
if (!normalizedUploadDir) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadPaths = [];
|
||||||
|
|
||||||
|
async function walkDirectory(currentDir, relativeDir) {
|
||||||
|
let entries = [];
|
||||||
|
try {
|
||||||
|
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code !== 'ENOENT') {
|
||||||
|
console.warn('Unable to read upload directory:', currentDir, error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryName = String(entry && entry.name || '').trim();
|
||||||
|
if (!entryName || entryName === '.' || entryName === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
|
||||||
|
const nextAbsolutePath = path.join(currentDir, entryName);
|
||||||
|
|
||||||
|
if (entry.isDirectory && entry.isDirectory()) {
|
||||||
|
await walkDirectory(nextAbsolutePath, nextRelativePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isFile && !entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await walkDirectory(normalizedUploadDir, '');
|
||||||
|
return uploadPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPlayerUploadSyncMode(localUploadDir) {
|
||||||
|
if (playerUploadSyncMode) {
|
||||||
|
return playerUploadSyncMode;
|
||||||
|
}
|
||||||
|
if (playerUploadSyncModePromise) {
|
||||||
|
return playerUploadSyncModePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
playerUploadSyncModePromise = (async function () {
|
||||||
|
try {
|
||||||
|
const authHeaders = createRequestAuthHeaders({
|
||||||
|
method: 'GET',
|
||||||
|
pathname: '/api/media/config'
|
||||||
|
});
|
||||||
|
const response = await fetch(`${playerInternalBaseUrl}/api/media/config`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...authHeaders
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const playerUploadDir = data && (data.uploadDir || data.mediaDir) ? normalizeUploadRoot(data.uploadDir || data.mediaDir) : null;
|
||||||
|
if (!playerUploadDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return playerUploadDir === normalizeUploadRoot(localUploadDir) ? 'shared' : 'different';
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})().then(function (mode) {
|
||||||
|
if (mode) {
|
||||||
|
playerUploadSyncMode = mode;
|
||||||
|
}
|
||||||
|
playerUploadSyncModePromise = null;
|
||||||
|
return mode;
|
||||||
|
}, function () {
|
||||||
|
playerUploadSyncModePromise = null;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return playerUploadSyncModePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shouldMirrorUploads(localUploadDir) {
|
||||||
|
return Boolean(localUploadDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePlayerUploadSync(operation) {
|
||||||
|
if (!operation || !operation.uploadPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), {
|
||||||
|
type: operation.type === 'delete' ? 'delete' : 'put',
|
||||||
|
uploadPath: normalizeUploadReference(operation.uploadPath),
|
||||||
|
uploadDir: operation.uploadDir || null
|
||||||
|
});
|
||||||
|
|
||||||
|
schedulePendingPlayerUploadSyncFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePendingPlayerUploadSyncFlush() {
|
||||||
|
if (pendingPlayerUploadSyncFlushTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPlayerUploadSyncFlushTimer = setTimeout(function () {
|
||||||
|
pendingPlayerUploadSyncFlushTimer = null;
|
||||||
|
flushPendingPlayerUploadSyncs().catch(function (error) {
|
||||||
|
console.warn('Unable to flush pending upload syncs:', error);
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushUploadFileToPlayer(uploadPath, localUploadDir) {
|
||||||
|
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = getUploadRelativePath(uploadPath);
|
||||||
|
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
|
||||||
|
if (!relativePath || !sourcePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let fileBuffer = null;
|
||||||
|
try {
|
||||||
|
fileBuffer = await fs.promises.readFile(sourcePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!error || error.code !== 'ENOENT') {
|
||||||
|
console.warn('Unable to read upload for player sync:', sourcePath, error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authHeaders = createRequestAuthHeaders({
|
||||||
|
method: 'PUT',
|
||||||
|
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||||
|
body: fileBuffer
|
||||||
|
});
|
||||||
|
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
...authHeaders
|
||||||
|
},
|
||||||
|
body: fileBuffer
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to sync upload to player:', relativePath, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
|
||||||
|
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = getUploadRelativePath(uploadPath);
|
||||||
|
if (!relativePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const authHeaders = createRequestAuthHeaders({
|
||||||
|
method: 'DELETE',
|
||||||
|
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||||
|
});
|
||||||
|
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...authHeaders
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to remove upload from player:', relativePath, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) {
|
||||||
|
if (!(await shouldMirrorUploads(localUploadDir))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
|
||||||
|
for (let i = 0; i < uniqueRefs.length; i += 1) {
|
||||||
|
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir);
|
||||||
|
if (!success) {
|
||||||
|
queuePlayerUploadSync({
|
||||||
|
type: 'put',
|
||||||
|
uploadPath: uniqueRefs[i],
|
||||||
|
uploadDir: localUploadDir
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncExistingUploadsToPlayer(pool, localUploadDir) {
|
||||||
|
return queueMediaSyncTask('media-sync:initial', 'Initial media sync', {
|
||||||
|
mode: 'initial',
|
||||||
|
uploadDir: localUploadDir
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleCurrentSlideIds() {
|
||||||
|
const visibleSlideIds = new Set();
|
||||||
|
playerSnapshotCache.forEach(function (snapshot) {
|
||||||
|
const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : [];
|
||||||
|
connections.forEach(function (connection) {
|
||||||
|
const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object'
|
||||||
|
? connection.currentSlide
|
||||||
|
: null;
|
||||||
|
const slideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null
|
||||||
|
? String(currentSlide.id).trim()
|
||||||
|
: '';
|
||||||
|
if (slideId) {
|
||||||
|
visibleSlideIds.add(slideId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return visibleSlideIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScreenRefreshBlocked(screenSlug, blockedSlideIds, screenSlideCounts) {
|
||||||
|
const slideIds = Array.isArray(blockedSlideIds)
|
||||||
|
? blockedSlideIds.map(function (value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (!slideIds.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalizedScreenSlug = String(screenSlug || '').trim();
|
||||||
|
const slideCount = screenSlideCounts && Object.prototype.hasOwnProperty.call(screenSlideCounts, normalizedScreenSlug)
|
||||||
|
? Number(screenSlideCounts[normalizedScreenSlug])
|
||||||
|
: null;
|
||||||
|
if (Number.isFinite(slideCount) && slideCount <= 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const snapshot = playerSnapshotCache.get(String(screenSlug || '').trim());
|
||||||
|
const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : [];
|
||||||
|
return connections.some(function (connection) {
|
||||||
|
const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object'
|
||||||
|
? connection.currentSlide
|
||||||
|
: null;
|
||||||
|
const currentSlideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null
|
||||||
|
? String(currentSlide.id).trim()
|
||||||
|
: '';
|
||||||
|
return currentSlideId && slideIds.includes(currentSlideId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitRefreshScreenSlugsByVisibility(screenSlugs, blockedSlideIds, screenSlideCounts) {
|
||||||
|
const ready = [];
|
||||||
|
const blocked = [];
|
||||||
|
Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).forEach(function (screenSlug) {
|
||||||
|
const normalizedScreenSlug = String(screenSlug || '').trim();
|
||||||
|
if (!normalizedScreenSlug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isScreenRefreshBlocked(normalizedScreenSlug, blockedSlideIds, screenSlideCounts)) {
|
||||||
|
blocked.push(normalizedScreenSlug);
|
||||||
|
} else {
|
||||||
|
ready.push(normalizedScreenSlug);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { ready: ready, blocked: blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlaylistUploadSyncOperation(options) {
|
||||||
|
return {
|
||||||
|
key: String(options && options.key ? options.key : '').trim(),
|
||||||
|
pool: options && options.pool ? options.pool : null,
|
||||||
|
localUploadDir: options && options.localUploadDir ? options.localUploadDir : null,
|
||||||
|
previousUploadRefs: Array.from(new Set(options && options.previousUploadRefs ? options.previousUploadRefs : [])),
|
||||||
|
nextUploadRefs: Array.from(new Set(options && options.nextUploadRefs ? options.nextUploadRefs : [])),
|
||||||
|
blockedSlideIds: Array.from(new Set(options && options.blockedSlideIds ? options.blockedSlideIds : [])).map(function (value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}).filter(Boolean),
|
||||||
|
refreshScreenSlugs: Array.from(new Set(options && options.refreshScreenSlugs ? options.refreshScreenSlugs : [])).map(function (value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}).filter(Boolean)
|
||||||
|
,
|
||||||
|
screenSlideCounts: options && options.screenSlideCounts && typeof options.screenSlideCounts === 'object'
|
||||||
|
? options.screenSlideCounts
|
||||||
|
: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePlaylistUploadSync(operation) {
|
||||||
|
if (!operation || !operation.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingPlaylistUploadSyncs.set(operation.key, normalizePlaylistUploadSyncOperation(operation));
|
||||||
|
schedulePendingPlaylistUploadSyncFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePendingPlaylistUploadSyncFlush() {
|
||||||
|
if (pendingPlaylistUploadSyncFlushTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPlaylistUploadSyncFlushTimer = setTimeout(function () {
|
||||||
|
pendingPlaylistUploadSyncFlushTimer = null;
|
||||||
|
flushPendingPlaylistUploadSyncs().catch(function (error) {
|
||||||
|
console.warn('Unable to flush pending playlist upload syncs:', error);
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPlaylistUploadsOnChange(options) {
|
||||||
|
const operation = normalizePlaylistUploadSyncOperation(options);
|
||||||
|
if (!operation.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
|
||||||
|
mode: 'playlist',
|
||||||
|
operation: operation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPendingPlaylistUploadSyncs() {
|
||||||
|
if (pendingPlaylistUploadSyncFlushInFlight) {
|
||||||
|
return pendingPlaylistUploadSyncFlushInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingPlaylistUploadSyncs.size) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPlaylistUploadSyncFlushInFlight = (async function () {
|
||||||
|
const pendingEntries = Array.from(pendingPlaylistUploadSyncs.values());
|
||||||
|
for (let i = 0; i < pendingEntries.length; i += 1) {
|
||||||
|
const operation = pendingEntries[i];
|
||||||
|
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
||||||
|
if (!refreshTargets.ready.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshTargets.ready.length) {
|
||||||
|
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPlaylistUploadSyncs.delete(operation.key);
|
||||||
|
}
|
||||||
|
})().finally(function () {
|
||||||
|
pendingPlaylistUploadSyncFlushInFlight = null;
|
||||||
|
if (pendingPlaylistUploadSyncs.size) {
|
||||||
|
schedulePendingPlaylistUploadSyncFlush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return pendingPlaylistUploadSyncFlushInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPendingPlayerUploadSyncs() {
|
||||||
|
if (pendingPlayerUploadSyncFlushInFlight) {
|
||||||
|
return pendingPlayerUploadSyncFlushInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingPlayerUploadSyncs.size) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPlayerUploadSyncFlushInFlight = (async function () {
|
||||||
|
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values());
|
||||||
|
for (let i = 0; i < pendingEntries.length; i += 1) {
|
||||||
|
const operation = pendingEntries[i];
|
||||||
|
let success = false;
|
||||||
|
if (operation.type === 'delete') {
|
||||||
|
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir);
|
||||||
|
} else {
|
||||||
|
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir);
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
pendingPlayerUploadSyncs.delete(operation.uploadPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().finally(function () {
|
||||||
|
pendingPlayerUploadSyncFlushInFlight = null;
|
||||||
|
if (pendingPlayerUploadSyncs.size) {
|
||||||
|
schedulePendingPlayerUploadSyncFlush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return pendingPlayerUploadSyncFlushInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMediaSyncTask(payload) {
|
||||||
|
const taskPayload = payload || {};
|
||||||
|
const mode = String(taskPayload.mode || '').trim();
|
||||||
|
|
||||||
|
if (mode === 'initial') {
|
||||||
|
const uploadDir = String(taskPayload.uploadDir || '').trim();
|
||||||
|
if (!(await shouldMirrorUploads(uploadDir))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await common.fetchAdminData(pool);
|
||||||
|
const uploadRefs = new Set();
|
||||||
|
(data.slides || []).forEach(function (slide) {
|
||||||
|
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
|
||||||
|
uploadRefs.add(reference);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
(data.templates || []).forEach(function (template) {
|
||||||
|
collectUploadReferencesFromTemplate(template).forEach(function (reference) {
|
||||||
|
uploadRefs.add(reference);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Array.from(uploadRefs).forEach(function (uploadPath) {
|
||||||
|
queuePlayerUploadSync({
|
||||||
|
type: 'put',
|
||||||
|
uploadPath: uploadPath,
|
||||||
|
uploadDir: uploadDir
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await flushPendingPlayerUploadSyncs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'playlist') {
|
||||||
|
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
||||||
|
|
||||||
|
if (operation.nextUploadRefs.length) {
|
||||||
|
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.previousUploadRefs.length) {
|
||||||
|
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||||
|
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
||||||
|
return !nextUploadRefSet.has(reference);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.refreshScreenSlugs.length) {
|
||||||
|
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
||||||
|
if (refreshTargets.ready.length) {
|
||||||
|
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
|
||||||
|
}
|
||||||
|
refreshTargets.blocked.forEach(function (screenSlug) {
|
||||||
|
queuePlaylistUploadSync({
|
||||||
|
key: operation.key + ':refresh:' + screenSlug,
|
||||||
|
blockedSlideIds: operation.blockedSlideIds,
|
||||||
|
refreshScreenSlugs: [screenSlug]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unknown media sync task mode.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueMediaSyncTask(taskKey, title, payload) {
|
||||||
|
const safePayload = Object.assign({}, payload || {});
|
||||||
|
delete safePayload.pool;
|
||||||
|
if (safePayload.operation && typeof safePayload.operation === 'object') {
|
||||||
|
safePayload.operation = Object.assign({}, safePayload.operation);
|
||||||
|
delete safePayload.operation.pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = {
|
||||||
|
key: taskKey,
|
||||||
|
title: title,
|
||||||
|
category: 'media-sync',
|
||||||
|
taskType: 'media-sync',
|
||||||
|
payload: safePayload,
|
||||||
|
persist: true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTaskAndWait === 'function') {
|
||||||
|
return backgroundTaskQueue.enqueueTaskAndWait(definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
return runMediaSyncTask(safePayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createUploadMiddleware: createUploadMiddleware,
|
||||||
|
normalizeUploadReference: normalizeUploadReference,
|
||||||
|
collectUploadReferencesFromValue: collectUploadReferencesFromValue,
|
||||||
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||||
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||||
|
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||||
|
countUploadReferences: countUploadReferences,
|
||||||
|
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||||
|
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||||
|
getPlayerUploadSyncMode: getPlayerUploadSyncMode,
|
||||||
|
shouldMirrorUploads: shouldMirrorUploads,
|
||||||
|
queuePlayerUploadSync: queuePlayerUploadSync,
|
||||||
|
schedulePendingPlayerUploadSyncFlush: schedulePendingPlayerUploadSyncFlush,
|
||||||
|
pushUploadFileToPlayer: pushUploadFileToPlayer,
|
||||||
|
removeUploadFileFromPlayer: removeUploadFileFromPlayer,
|
||||||
|
syncUploadRefsToPlayer: syncUploadRefsToPlayer,
|
||||||
|
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||||
|
getVisibleCurrentSlideIds: getVisibleCurrentSlideIds,
|
||||||
|
isScreenRefreshBlocked: isScreenRefreshBlocked,
|
||||||
|
splitRefreshScreenSlugsByVisibility: splitRefreshScreenSlugsByVisibility,
|
||||||
|
normalizePlaylistUploadSyncOperation: normalizePlaylistUploadSyncOperation,
|
||||||
|
queuePlaylistUploadSync: queuePlaylistUploadSync,
|
||||||
|
schedulePendingPlaylistUploadSyncFlush: schedulePendingPlaylistUploadSyncFlush,
|
||||||
|
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||||
|
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
|
||||||
|
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
|
||||||
|
runMediaSyncTask: runMediaSyncTask,
|
||||||
|
queueMediaSyncTask: queueMediaSyncTask
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createUploadSyncService };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const { renderView } = require('../view');
|
||||||
|
|
||||||
|
function getErrorCopy(statusCode, message) {
|
||||||
|
const normalizedStatusCode = Number(statusCode) || 500;
|
||||||
|
|
||||||
|
if (normalizedStatusCode === 403) {
|
||||||
|
return {
|
||||||
|
title: 'Access denied',
|
||||||
|
errorTitle: 'Access denied',
|
||||||
|
errorMessage: message || 'You do not have permission to access this area.',
|
||||||
|
backLabel: 'Back to dashboard'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedStatusCode === 404) {
|
||||||
|
return {
|
||||||
|
title: 'Not found',
|
||||||
|
errorTitle: 'Oops! Page not found.',
|
||||||
|
errorMessage: message || 'We could not find the page you were looking for. Meanwhile, you may return to the dashboard or try searching for what you need.',
|
||||||
|
backLabel: 'Back to dashboard'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedStatusCode >= 500) {
|
||||||
|
return {
|
||||||
|
title: 'Something went wrong',
|
||||||
|
errorTitle: 'Something went wrong.',
|
||||||
|
errorMessage: message || 'An unexpected error occurred. Please try again in a moment.',
|
||||||
|
backLabel: 'Back to dashboard'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: 'Error',
|
||||||
|
errorTitle: 'Error',
|
||||||
|
errorMessage: message || 'An unexpected error occurred.',
|
||||||
|
backLabel: 'Back to dashboard'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function renderErrorPage(options, currentUser) {
|
||||||
|
const errorOptions = options || {};
|
||||||
|
const statusCode = Number(errorOptions.statusCode || 500);
|
||||||
|
const copy = getErrorCopy(statusCode, String(errorOptions.message || '').trim());
|
||||||
|
return renderView('error/error', {
|
||||||
|
title: String(errorOptions.title || copy.title || 'Error').trim(),
|
||||||
|
active: '',
|
||||||
|
messageVariant: 'primary',
|
||||||
|
currentUser: currentUser || null,
|
||||||
|
statusCode: statusCode,
|
||||||
|
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
|
||||||
|
errorMessage: String(errorOptions.message || copy.errorMessage || 'An unexpected error occurred.').trim(),
|
||||||
|
detail: String(errorOptions.detail || '').trim(),
|
||||||
|
backUrl: String(errorOptions.backUrl || '/dashboard').trim() || '/dashboard',
|
||||||
|
backLabel: String(errorOptions.backLabel || copy.backLabel || 'Back to dashboard').trim() || 'Back to dashboard',
|
||||||
|
searchUrl: String(errorOptions.searchUrl || '').trim(),
|
||||||
|
bodyClass: 'error-page bg-dark text-white',
|
||||||
|
errorShell: true,
|
||||||
|
stylesheets: [],
|
||||||
|
scripts: []
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function routePath(...segments) {
|
||||||
|
return path.join(__dirname, '..', 'routes', ...segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
renderLoginPage: require(routePath('auth', 'login')),
|
||||||
|
renderAccountPage: require(routePath('account', 'password')),
|
||||||
|
renderUsersPage: require(routePath('settings', 'users', 'list')),
|
||||||
|
renderUsersAddPage: require(routePath('settings', 'users', 'add')),
|
||||||
|
renderUsersEditPage: require(routePath('settings', 'users', 'edit')),
|
||||||
|
renderDashboardPage: require(routePath('signage', 'dashboard', 'index')),
|
||||||
|
renderConnectedClientsPage: require(routePath('signage', 'clients', 'list')),
|
||||||
|
renderPlaylistsPage: require(routePath('signage', 'playlists', 'list')),
|
||||||
|
renderPlaylistFormPage: require(routePath('signage', 'playlists', 'add')),
|
||||||
|
renderPlaylistEditPage: require(routePath('signage', 'playlists', 'edit')),
|
||||||
|
renderPlaylistSlideConfigPage: require(routePath('signage', 'playlists', 'slide-config')),
|
||||||
|
renderApiSourcesPage: require(routePath('data-sources', 'api-sources', 'list')),
|
||||||
|
renderApiSourceFormPage: require(routePath('data-sources', 'api-sources', 'add')),
|
||||||
|
renderApiSourceEditPage: require(routePath('data-sources', 'api-sources', 'edit')),
|
||||||
|
renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')),
|
||||||
|
renderRssFeedFormPage: require(routePath('data-sources', 'rss-feeds', 'add')),
|
||||||
|
renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')),
|
||||||
|
renderScreensPage: require(routePath('signage', 'screens', 'list')),
|
||||||
|
renderScreenFormPage: require(routePath('signage', 'screens', 'add')),
|
||||||
|
renderScreenEditPage: require(routePath('signage', 'screens', 'edit')),
|
||||||
|
renderSlidesPage: require(routePath('signage', 'slides', 'list')),
|
||||||
|
renderSlideFormPage: require(routePath('signage', 'slides', 'form')),
|
||||||
|
renderTemplatesPage: require(routePath('signage', 'templates', 'list')),
|
||||||
|
renderTemplateFormPage: require(routePath('signage', 'templates', 'add')),
|
||||||
|
renderTemplateEditPage: require(routePath('signage', 'templates', 'edit')),
|
||||||
|
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
|
||||||
|
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
||||||
|
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
|
||||||
|
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
|
||||||
|
renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage,
|
||||||
|
renderErrorPage: require('./error'),
|
||||||
|
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
|
||||||
|
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
|
||||||
|
renderRbacEditPage: require(routePath('settings', 'rbac', 'edit'))
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
@@ -0,0 +1,872 @@
|
|||||||
|
(function () {
|
||||||
|
var webUiHelpers = window.webUiHelpers || {};
|
||||||
|
var escapeHtml = webUiHelpers.escapeHtml;
|
||||||
|
var formatDashboardDate = webUiHelpers.formatDashboardDate;
|
||||||
|
var getClientRowKey = webUiHelpers.getClientRowKey;
|
||||||
|
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||||
|
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||||
|
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||||
|
|
||||||
|
function initConfirmForms() {
|
||||||
|
document.addEventListener('submit', function (event) {
|
||||||
|
var form = event.target;
|
||||||
|
if (!form || !form.getAttribute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.hasAttribute && form.hasAttribute('data-async-command')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var message = form.getAttribute('data-confirm-message');
|
||||||
|
if (message && !window.confirm(message)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markFormDirty(form) {
|
||||||
|
if (!form || !form.hasAttribute || form.hasAttribute('data-clean-on-load')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.dataset.dirty = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFormDirty(form) {
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.dataset.dirty = 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFormDirty(form) {
|
||||||
|
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDirtyTracking() {
|
||||||
|
document.addEventListener('input', function (event) {
|
||||||
|
var target = event.target;
|
||||||
|
if (!target || !target.form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markFormDirty(target.form);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener('change', function (event) {
|
||||||
|
var target = event.target;
|
||||||
|
if (!target || !target.form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markFormDirty(target.form);
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCancelConfirm() {
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
|
||||||
|
if (!cancelTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = cancelTarget.form || cancelTarget.closest('form') || document.querySelector('form[data-dirty="true"]');
|
||||||
|
if (!isFormDirty(form)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = cancelTarget.getAttribute('data-confirm-unsaved') || 'You have unsaved changes. Leave this page?';
|
||||||
|
if (!window.confirm(message)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAsyncCommandForms() {
|
||||||
|
document.addEventListener('submit', function (event) {
|
||||||
|
var form = event.target;
|
||||||
|
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.dataset && form.dataset.busy === 'true') {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = form.getAttribute('data-confirm-message');
|
||||||
|
if (message && !window.confirm(message)) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
form.dataset.busy = 'true';
|
||||||
|
|
||||||
|
var formData = new FormData(form);
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
formData.forEach(function (value, key) {
|
||||||
|
body.append(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: (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'
|
||||||
|
}).finally(function () {
|
||||||
|
delete form.dataset.busy;
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAsyncSaveForms() {
|
||||||
|
var refreshSequence = 0;
|
||||||
|
|
||||||
|
function setSaveActionValue(form, value) {
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
|
||||||
|
if (!hiddenInput) {
|
||||||
|
hiddenInput = document.createElement('input');
|
||||||
|
hiddenInput.type = 'hidden';
|
||||||
|
hiddenInput.name = 'save_action';
|
||||||
|
form.appendChild(hiddenInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
hiddenInput.value = String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResponseQueryValue(responseUrl, key) {
|
||||||
|
try {
|
||||||
|
var url = new URL(responseUrl, window.location.href);
|
||||||
|
return String(url.searchParams.get(key) || '').trim();
|
||||||
|
} catch (_error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebindRefreshTarget(targetElement) {
|
||||||
|
if (!targetElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.initJsonTogglePanels === 'function') {
|
||||||
|
window.initJsonTogglePanels(targetElement);
|
||||||
|
}
|
||||||
|
if (typeof window.initLocalDateTimes === 'function') {
|
||||||
|
window.initLocalDateTimes(targetElement);
|
||||||
|
}
|
||||||
|
if (typeof window.initTableSearches === 'function') {
|
||||||
|
window.initTableSearches(targetElement);
|
||||||
|
}
|
||||||
|
if (typeof window.initTablePaginations === 'function') {
|
||||||
|
window.initTablePaginations(targetElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||||
|
if (sequenceId !== refreshSequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(window.location.href, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': 'text/html, application/xhtml+xml'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store'
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Unable to refresh saved data.');
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}).then(function (text) {
|
||||||
|
if (sequenceId !== refreshSequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||||
|
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||||
|
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
||||||
|
if (!currentTarget || !nextTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||||
|
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||||
|
}).catch(function (_error) {
|
||||||
|
// Ignore refresh replacement failures and leave the existing content in place.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, sequenceId) {
|
||||||
|
var pollDelayMs = 1000;
|
||||||
|
var maxAttempts = 60;
|
||||||
|
|
||||||
|
function poll(attempt) {
|
||||||
|
if (sequenceId !== refreshSequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stateUrl;
|
||||||
|
try {
|
||||||
|
stateUrl = new URL(refreshStateUrl, window.location.href);
|
||||||
|
} catch (_error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateUrl.searchParams.set('refresh_task_id', refreshTaskId);
|
||||||
|
|
||||||
|
fetch(stateUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': 'application/json, text/plain, */*'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store'
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Unable to check refresh status.');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}).then(function (payload) {
|
||||||
|
if (sequenceId !== refreshSequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = String(payload && payload.status || '').trim().toLowerCase();
|
||||||
|
if (status === 'queued' || status === 'running') {
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
window.setTimeout(function () {
|
||||||
|
poll(attempt + 1);
|
||||||
|
}, pollDelayMs);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId);
|
||||||
|
}).catch(function () {
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
window.setTimeout(function () {
|
||||||
|
poll(attempt + 1);
|
||||||
|
}, pollDelayMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
poll(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
var target = event.target;
|
||||||
|
if (!target || !target.closest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var button = target.closest('button[name="save_action"]');
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = button.form || button.closest('form');
|
||||||
|
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveActionValue(form, button.value || '');
|
||||||
|
form.dataset.submitterValue = String(button.value || '').trim().toLowerCase();
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener('submit', function (event) {
|
||||||
|
var form = event.target;
|
||||||
|
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.dataset && form.dataset.busy === 'true') {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = form.getAttribute('data-confirm-message');
|
||||||
|
if (message && !window.confirm(message)) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
form.dataset.busy = 'true';
|
||||||
|
|
||||||
|
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
|
||||||
|
var formData = new FormData(form);
|
||||||
|
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
|
||||||
|
if (event.submitter && event.submitter.name) {
|
||||||
|
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
|
||||||
|
formData.set(event.submitter.name, event.submitter.value || '');
|
||||||
|
}
|
||||||
|
var hasFileValue = false;
|
||||||
|
formData.forEach(function (value) {
|
||||||
|
if (value && typeof value === 'object' && typeof value.name === 'string') {
|
||||||
|
hasFileValue = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
|
||||||
|
var body = isMultipart ? formData : new URLSearchParams();
|
||||||
|
|
||||||
|
if (!isMultipart) {
|
||||||
|
formData.forEach(function (value, key) {
|
||||||
|
body.append(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: (form.method || 'POST').toUpperCase(),
|
||||||
|
headers: Object.assign({
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': 'text/html, application/json, text/plain, */*'
|
||||||
|
}, isMultipart ? {} : {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||||
|
}),
|
||||||
|
body: isMultipart ? 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 save changes.');
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var actionUrl = '';
|
||||||
|
try {
|
||||||
|
actionUrl = new URL(form.action, window.location.href).pathname;
|
||||||
|
} catch (_error) {
|
||||||
|
actionUrl = String(form.action || '');
|
||||||
|
}
|
||||||
|
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
|
||||||
|
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
|
||||||
|
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
|
||||||
|
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
|
||||||
|
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||||
|
var redirectUrl = submitterValue === 'close'
|
||||||
|
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
|
||||||
|
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
|
||||||
|
window.location.replace(redirectUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldFollowRedirect && response.url) {
|
||||||
|
clearFormDirty(form);
|
||||||
|
window.location.replace(response.url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.hasAttribute('data-async-save-reload-on-success')) {
|
||||||
|
clearFormDirty(form);
|
||||||
|
window.location.replace(response.url || window.location.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearFormDirty(form);
|
||||||
|
return response.text().then(function (text) {
|
||||||
|
var savedMessage = '';
|
||||||
|
var responseDocument = null;
|
||||||
|
try {
|
||||||
|
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||||
|
var toastBody = responseDocument.querySelector('.toast-body');
|
||||||
|
if (toastBody && toastBody.textContent) {
|
||||||
|
savedMessage = toastBody.textContent.trim();
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
savedMessage = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
|
||||||
|
refreshSequence += 1;
|
||||||
|
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
|
||||||
|
} else if (refreshTargetSelector && responseDocument) {
|
||||||
|
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||||
|
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
||||||
|
if (currentTarget && nextTarget) {
|
||||||
|
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||||
|
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(savedMessage || 'Saved.', 'success');
|
||||||
|
});
|
||||||
|
}).catch(function (error) {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||||
|
showToast(error.message || 'Unable to save changes.', variant);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.alert(error.message || 'Unable to save changes.');
|
||||||
|
}).finally(function () {
|
||||||
|
delete form.dataset.busy;
|
||||||
|
delete form.dataset.submitterValue;
|
||||||
|
if (hiddenSaveAction) {
|
||||||
|
hiddenSaveAction.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSubmitOnChange() {
|
||||||
|
var fields = document.querySelectorAll('[data-submit-on-change]');
|
||||||
|
Array.prototype.forEach.call(fields, function (field) {
|
||||||
|
field.addEventListener('change', function () {
|
||||||
|
var form = field.form || field.closest('form');
|
||||||
|
if (form) {
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initJsonTogglePanels(root) {
|
||||||
|
var scope = root && root.querySelectorAll ? root : document;
|
||||||
|
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
|
||||||
|
Array.prototype.forEach.call(panels, function (panel) {
|
||||||
|
var output = panel.querySelector('[data-json-toggle-output]');
|
||||||
|
if (!output) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var card = panel.closest ? panel.closest('.card') : null;
|
||||||
|
var button = card ? card.querySelector('[data-json-toggle]') : null;
|
||||||
|
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
|
||||||
|
var sourceNode = panel.querySelector('[data-json-toggle-source]');
|
||||||
|
var rawJson = '';
|
||||||
|
try {
|
||||||
|
rawJson = JSON.parse(String(sourceNode ? sourceNode.textContent : '""'));
|
||||||
|
} catch (_error) {
|
||||||
|
rawJson = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawJson !== 'string' || !rawJson.trim()) {
|
||||||
|
if (button) {
|
||||||
|
button.classList.add('d-none');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedJson;
|
||||||
|
try {
|
||||||
|
parsedJson = JSON.parse(rawJson);
|
||||||
|
} catch (_error) {
|
||||||
|
if (button) {
|
||||||
|
button.classList.add('d-none');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var compactJson = JSON.stringify(parsedJson);
|
||||||
|
var formattedJson = JSON.stringify(parsedJson, null, 2);
|
||||||
|
var isFormatted = true;
|
||||||
|
|
||||||
|
function syncButtonLabel() {
|
||||||
|
if (!button || !label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
label.textContent = isFormatted
|
||||||
|
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
|
||||||
|
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
|
||||||
|
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncOutput() {
|
||||||
|
output.textContent = isFormatted ? formattedJson : compactJson;
|
||||||
|
syncButtonLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
output.textContent = formattedJson;
|
||||||
|
syncButtonLabel();
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
isFormatted = !isFormatted;
|
||||||
|
syncOutput();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLocalDateTimes(root) {
|
||||||
|
var scope = root && root.querySelectorAll ? root : document;
|
||||||
|
var elements = scope.querySelectorAll('[data-local-datetime]');
|
||||||
|
Array.prototype.forEach.call(elements, function (element) {
|
||||||
|
var rawValue = String(element.getAttribute('datetime') || element.getAttribute('data-local-datetime') || element.textContent || '').trim();
|
||||||
|
if (!rawValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var date = new Date(rawValue);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.textContent = formatDashboardDate(date);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTablePaginations(root) {
|
||||||
|
var scope = root && root.querySelectorAll ? root : document;
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-pagination="auto"]'), function (container) {
|
||||||
|
if (container.getAttribute('data-table-pagination-bound') === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var table = container.querySelector('[data-table-searchable]');
|
||||||
|
var cardHeader = container.querySelector('.card-header');
|
||||||
|
var cardBody = container.querySelector('.card-body.table-responsive') || container.querySelector('.card-body');
|
||||||
|
var footer = container.querySelector('[data-table-pagination-controls]');
|
||||||
|
var footerSummary = null;
|
||||||
|
var footerList = null;
|
||||||
|
var syncFrame = 0;
|
||||||
|
var state = {
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
totalPages: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!table || !cardBody || !table.tBodies.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.setAttribute('data-table-pagination-bound', 'true');
|
||||||
|
|
||||||
|
function getRows() {
|
||||||
|
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSearchMatched(row) {
|
||||||
|
return String(row.getAttribute('data-table-search-match') || 'true') !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFooter() {
|
||||||
|
if (footer) {
|
||||||
|
footerSummary = footer.querySelector('[data-table-pagination-summary]');
|
||||||
|
footerList = footer.querySelector('[data-table-pagination-list]');
|
||||||
|
return footer;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer = document.createElement('div');
|
||||||
|
footer.className = 'card-footer d-none';
|
||||||
|
footer.setAttribute('data-table-pagination-controls', 'true');
|
||||||
|
footer.innerHTML = '' +
|
||||||
|
'<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">' +
|
||||||
|
'<div class="text-muted small" data-table-pagination-summary></div>' +
|
||||||
|
'<nav aria-label="Table pages">' +
|
||||||
|
'<ul class="pagination pagination-sm mb-0" data-table-pagination-list></ul>' +
|
||||||
|
'</nav>' +
|
||||||
|
'</div>';
|
||||||
|
cardBody.parentNode.insertBefore(footer, cardBody.nextSibling);
|
||||||
|
footerSummary = footer.querySelector('[data-table-pagination-summary]');
|
||||||
|
footerList = footer.querySelector('[data-table-pagination-list]');
|
||||||
|
return footer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRowVisible(row, visible) {
|
||||||
|
row.hidden = !visible;
|
||||||
|
row.classList.toggle('d-none', !visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableHeight() {
|
||||||
|
var containerRect = container.getBoundingClientRect();
|
||||||
|
var headerHeight = cardHeader ? cardHeader.getBoundingClientRect().height : 0;
|
||||||
|
var footerEstimate = footer && !footer.classList.contains('d-none') ? footer.getBoundingClientRect().height : 56;
|
||||||
|
return Math.max(0, window.innerHeight - Math.max(0, containerRect.top) - headerHeight - footerEstimate - 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFooter(totalItems) {
|
||||||
|
var pageButtons = [];
|
||||||
|
|
||||||
|
ensureFooter();
|
||||||
|
|
||||||
|
if (state.totalPages <= 1 || totalItems <= 0) {
|
||||||
|
footer.classList.add('d-none');
|
||||||
|
footerSummary.textContent = '';
|
||||||
|
footerList.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer.classList.remove('d-none');
|
||||||
|
footerSummary.textContent = 'Showing ' + (((state.currentPage - 1) * state.pageSize) + 1) + '-' + Math.min(totalItems, state.currentPage * state.pageSize) + ' of ' + totalItems;
|
||||||
|
|
||||||
|
pageButtons.push('<li class="page-item' + (state.currentPage <= 1 ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage - 1) + '" aria-label="Previous page">Previous</a></li>');
|
||||||
|
for (var pageNumber = 1; pageNumber <= state.totalPages; pageNumber += 1) {
|
||||||
|
pageButtons.push('<li class="page-item' + (pageNumber === state.currentPage ? ' active' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + pageNumber + '">' + pageNumber + '</a></li>');
|
||||||
|
}
|
||||||
|
pageButtons.push('<li class="page-item' + (state.currentPage >= state.totalPages ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage + 1) + '" aria-label="Next page">Next</a></li>');
|
||||||
|
footerList.innerHTML = pageButtons.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPagination(resetPage) {
|
||||||
|
var rows = getRows();
|
||||||
|
var matchedRows = rows.filter(isSearchMatched);
|
||||||
|
var totalItems = matchedRows.length;
|
||||||
|
var rowHeight = 48;
|
||||||
|
var pageSize;
|
||||||
|
var startIndex;
|
||||||
|
var endIndex;
|
||||||
|
|
||||||
|
if (resetPage) {
|
||||||
|
state.currentPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
if (!isSearchMatched(row)) {
|
||||||
|
setRowVisible(row, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!totalItems) {
|
||||||
|
state.pageSize = 1;
|
||||||
|
state.totalPages = 1;
|
||||||
|
state.currentPage = 1;
|
||||||
|
renderFooter(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedRows.forEach(function (row) {
|
||||||
|
setRowVisible(row, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchedRows[0]) {
|
||||||
|
rowHeight = Math.max(24, matchedRows[0].getBoundingClientRect().height || matchedRows[0].offsetHeight || 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSize = Math.max(1, Math.floor(getAvailableHeight() / rowHeight));
|
||||||
|
|
||||||
|
if (pageSize >= totalItems) {
|
||||||
|
state.pageSize = totalItems;
|
||||||
|
state.totalPages = 1;
|
||||||
|
state.currentPage = 1;
|
||||||
|
matchedRows.forEach(function (row) {
|
||||||
|
setRowVisible(row, true);
|
||||||
|
});
|
||||||
|
renderFooter(totalItems);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pageSize = pageSize;
|
||||||
|
state.totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||||
|
state.currentPage = Math.min(Math.max(1, state.currentPage), state.totalPages);
|
||||||
|
startIndex = (state.currentPage - 1) * pageSize;
|
||||||
|
endIndex = startIndex + pageSize;
|
||||||
|
|
||||||
|
matchedRows.forEach(function (row, index) {
|
||||||
|
setRowVisible(row, index >= startIndex && index < endIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderFooter(totalItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSync(options) {
|
||||||
|
if (syncFrame) {
|
||||||
|
window.cancelAnimationFrame(syncFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncFrame = window.requestAnimationFrame(function () {
|
||||||
|
syncFrame = 0;
|
||||||
|
applyPagination(Boolean(options && options.resetPage));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureFooter();
|
||||||
|
|
||||||
|
if (footer) {
|
||||||
|
footer.addEventListener('click', function (event) {
|
||||||
|
var target = event.target && event.target.closest ? event.target.closest('[data-table-pagination-page]') : null;
|
||||||
|
var pageValue;
|
||||||
|
|
||||||
|
if (!target || target.closest('.disabled')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
pageValue = Math.max(1, Math.floor(Number(target.getAttribute('data-table-pagination-page')) || 1));
|
||||||
|
state.currentPage = pageValue;
|
||||||
|
scheduleSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
scheduleSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof ResizeObserver === 'function') {
|
||||||
|
try {
|
||||||
|
new ResizeObserver(function () {
|
||||||
|
scheduleSync();
|
||||||
|
}).observe(container);
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore observer setup failures and rely on window resize.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.syncTablePagination = scheduleSync;
|
||||||
|
scheduleSync({ resetPage: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTableSearches(root) {
|
||||||
|
var scope = root && root.querySelectorAll ? root : document;
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) {
|
||||||
|
if (input.getAttribute('data-table-search-bound') === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = input.closest('[data-table-search-container]') || input.closest('.card') || document;
|
||||||
|
var table = container.querySelector('[data-table-searchable]');
|
||||||
|
var emptyRow = null;
|
||||||
|
|
||||||
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.setAttribute('data-table-search-bound', 'true');
|
||||||
|
|
||||||
|
function getSearchableRows() {
|
||||||
|
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultEmptyRow() {
|
||||||
|
return table.querySelector('tbody tr[data-table-search-empty-default]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGeneratedEmptyRow() {
|
||||||
|
if (emptyRow && emptyRow.parentNode) {
|
||||||
|
emptyRow.parentNode.removeChild(emptyRow);
|
||||||
|
}
|
||||||
|
emptyRow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGeneratedEmptyRow(message) {
|
||||||
|
var tbody = table.tBodies[0] || table.querySelector('tbody');
|
||||||
|
var columnCount = 1;
|
||||||
|
|
||||||
|
if (!tbody) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emptyRow) {
|
||||||
|
emptyRow = document.createElement('tr');
|
||||||
|
emptyRow.setAttribute('data-table-search-empty-row', 'true');
|
||||||
|
var cell = document.createElement('td');
|
||||||
|
cell.className = 'empty';
|
||||||
|
cell.setAttribute('data-table-search-empty-cell', 'true');
|
||||||
|
emptyRow.appendChild(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
columnCount = table.tHead && table.tHead.rows && table.tHead.rows[0] ? table.tHead.rows[0].cells.length : (getSearchableRows()[0] ? getSearchableRows()[0].cells.length : 1);
|
||||||
|
emptyRow.firstElementChild.colSpan = Math.max(1, columnCount);
|
||||||
|
emptyRow.firstElementChild.textContent = message;
|
||||||
|
|
||||||
|
if (!emptyRow.parentNode) {
|
||||||
|
tbody.appendChild(emptyRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSearch() {
|
||||||
|
var query = String(input.value || '').trim().toLowerCase();
|
||||||
|
var rows = getSearchableRows();
|
||||||
|
var visibleCount = 0;
|
||||||
|
var defaultEmptyRow = getDefaultEmptyRow();
|
||||||
|
|
||||||
|
removeGeneratedEmptyRow();
|
||||||
|
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
var haystack = String(row.getAttribute('data-search-text') || row.textContent || '').toLowerCase();
|
||||||
|
var visible = !query || haystack.indexOf(query) !== -1;
|
||||||
|
row.setAttribute('data-table-search-match', visible ? 'true' : 'false');
|
||||||
|
row.classList.toggle('d-none', !visible);
|
||||||
|
row.hidden = !visible;
|
||||||
|
if (visible) {
|
||||||
|
visibleCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (defaultEmptyRow) {
|
||||||
|
defaultEmptyRow.classList.toggle('d-none', Boolean(query));
|
||||||
|
defaultEmptyRow.hidden = Boolean(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query && visibleCount === 0) {
|
||||||
|
ensureGeneratedEmptyRow('No results match your search.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.syncTablePagination === 'function') {
|
||||||
|
window.syncTablePagination(container, { resetPage: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', syncSearch);
|
||||||
|
syncSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSlideThumbPlaceholder() {
|
||||||
|
var placeholder = document.createElement('span');
|
||||||
|
var icon = document.createElement('i');
|
||||||
|
|
||||||
|
placeholder.className = 'playlist-slide-thumb-placeholder';
|
||||||
|
icon.className = 'bi bi-image';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
placeholder.appendChild(icon);
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachSlideThumbFallbacks(root) {
|
||||||
|
var scope = root && root.querySelectorAll ? root : document;
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(scope.querySelectorAll('.playlist-slide-thumb-image'), function (image) {
|
||||||
|
if (image.getAttribute('data-slide-thumb-fallback-bound') === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
image.setAttribute('data-slide-thumb-fallback-bound', 'true');
|
||||||
|
image.addEventListener('error', function () {
|
||||||
|
if (image.parentNode) {
|
||||||
|
image.parentNode.replaceChild(createSlideThumbPlaceholder(), image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.initSortableTables) {
|
||||||
|
window.initSortableTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
initConfirmForms();
|
||||||
|
initDirtyTracking();
|
||||||
|
initCancelConfirm();
|
||||||
|
initAsyncCommandForms();
|
||||||
|
initAsyncSaveForms();
|
||||||
|
initSubmitOnChange();
|
||||||
|
initJsonTogglePanels();
|
||||||
|
initLocalDateTimes();
|
||||||
|
initTableSearches();
|
||||||
|
initTablePaginations();
|
||||||
|
attachSlideThumbFallbacks(document);
|
||||||
|
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||||
|
window.initLocalDateTimes = initLocalDateTimes;
|
||||||
|
window.initTableSearches = initTableSearches;
|
||||||
|
window.initTablePaginations = initTablePaginations;
|
||||||
|
window.syncTablePagination = function (container, options) {
|
||||||
|
if (!container || !container.syncTablePagination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.syncTablePagination(options);
|
||||||
|
};
|
||||||
|
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
|
||||||
|
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
|
||||||
|
}());
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
(function () {
|
||||||
|
function getGroupCheckboxes(group) {
|
||||||
|
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionKey(checkbox) {
|
||||||
|
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionKey(checkbox) {
|
||||||
|
var permissionKey = getPermissionKey(checkbox);
|
||||||
|
var parts = permissionKey.split('.');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(parts[1] || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPermissionGroup(group) {
|
||||||
|
var checkboxes = getGroupCheckboxes(group);
|
||||||
|
if (!checkboxes.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var readCheckbox = null;
|
||||||
|
var nonReadChecked = false;
|
||||||
|
|
||||||
|
checkboxes.forEach(function (checkbox) {
|
||||||
|
if (getActionKey(checkbox) === 'read') {
|
||||||
|
readCheckbox = checkbox;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (checkbox.checked) {
|
||||||
|
nonReadChecked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!readCheckbox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readCheckbox.checked && nonReadChecked) {
|
||||||
|
readCheckbox.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readCheckbox.checked) {
|
||||||
|
checkboxes.forEach(function (checkbox) {
|
||||||
|
if (getActionKey(checkbox) !== 'read') {
|
||||||
|
checkbox.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGroupChange(event) {
|
||||||
|
var checkbox = event.target && event.target.matches ? event.target : null;
|
||||||
|
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionKey = getActionKey(checkbox);
|
||||||
|
var group = checkbox.closest('.accordion-item');
|
||||||
|
if (!group) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkboxes = getGroupCheckboxes(group);
|
||||||
|
var readCheckbox = checkboxes.find(function (candidate) {
|
||||||
|
return getActionKey(candidate) === 'read';
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
if (!readCheckbox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionKey === 'read' && !checkbox.checked) {
|
||||||
|
checkboxes.forEach(function (candidate) {
|
||||||
|
if (candidate !== checkbox) {
|
||||||
|
candidate.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionKey !== 'read' && checkbox.checked) {
|
||||||
|
readCheckbox.checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPermissionGroups() {
|
||||||
|
document.querySelectorAll('.accordion-item').forEach(function (group) {
|
||||||
|
syncPermissionGroup(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('change', function (event) {
|
||||||
|
var checkbox = event.target;
|
||||||
|
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGroupChange(event);
|
||||||
|
syncPermissionGroup(checkbox.closest('.accordion-item'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initPermissionGroups();
|
||||||
|
}());
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
(function () {
|
||||||
|
function updateSidebarStatus(status, label) {
|
||||||
|
var dot = document.getElementById('sidebar-status-dot');
|
||||||
|
var text = document.getElementById('sidebar-status-text');
|
||||||
|
var pill = document.getElementById('sidebar-status-pill');
|
||||||
|
if (!dot && !text && !pill) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
|
||||||
|
? status
|
||||||
|
: (status ? 'online' : 'offline');
|
||||||
|
var statusLabel = normalizedStatus === 'online'
|
||||||
|
? (label || 'Connected to player feed')
|
||||||
|
: normalizedStatus === 'offline'
|
||||||
|
? 'Disconnected from player feed'
|
||||||
|
: (label || 'Connecting to player feed');
|
||||||
|
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
|
||||||
|
|
||||||
|
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
|
||||||
|
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
|
||||||
|
dot.setAttribute('aria-label', statusLabel);
|
||||||
|
dot.setAttribute('title', statusLabel);
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
text.textContent = statusLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pill) {
|
||||||
|
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
|
||||||
|
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
|
||||||
|
pill.textContent = pillLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectDashboardSocket() {
|
||||||
|
if (!window.WebSocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasStatusIndicators = document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text') || document.getElementById('sidebar-status-pill');
|
||||||
|
if (!hasStatusIndicators) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var socket = null;
|
||||||
|
var reconnectTimer = null;
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconnectTimer = window.setTimeout(function () {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard');
|
||||||
|
updateSidebarStatus('unknown', 'Connecting to player feed');
|
||||||
|
|
||||||
|
socket.onmessage = function (event) {
|
||||||
|
try {
|
||||||
|
var payload = JSON.parse(String(event.data || '{}'));
|
||||||
|
if (payload && payload.type === 'dashboard-state') {
|
||||||
|
if (typeof window.webHandleDashboardState === 'function') {
|
||||||
|
window.webHandleDashboardState(payload.state);
|
||||||
|
}
|
||||||
|
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore malformed dashboard payloads.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = function () {
|
||||||
|
updateSidebarStatus('offline');
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = function () {
|
||||||
|
updateSidebarStatus('offline');
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore close errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onopen = function () {
|
||||||
|
updateSidebarStatus('unknown', 'Connecting to player feed');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectDashboardSocket();
|
||||||
|
}());
|
||||||
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
(function () {
|
||||||
|
var THEME_STORAGE_KEY = 'web-theme';
|
||||||
|
var CKEDITOR_THEME_STYLE_ID = 'ckeditor-dark-theme-overrides';
|
||||||
|
|
||||||
|
function getPreferredTheme() {
|
||||||
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredTheme() {
|
||||||
|
try {
|
||||||
|
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (storedTheme === 'dark' || storedTheme === 'light') {
|
||||||
|
return storedTheme;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStoredTheme(theme) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateCkeditorThemeStyleElement() {
|
||||||
|
var styleElement = document.getElementById(CKEDITOR_THEME_STYLE_ID);
|
||||||
|
|
||||||
|
if (styleElement) {
|
||||||
|
return styleElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
styleElement = document.createElement('style');
|
||||||
|
styleElement.id = CKEDITOR_THEME_STYLE_ID;
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
|
||||||
|
return styleElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCkeditorTheme(theme) {
|
||||||
|
var styleElement = getOrCreateCkeditorThemeStyleElement();
|
||||||
|
|
||||||
|
if (theme !== 'dark') {
|
||||||
|
styleElement.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
styleElement.textContent = [
|
||||||
|
'.ck-body-wrapper .ck.ck-dropdown__panel,',
|
||||||
|
'.ck-body-wrapper .ck.ck-list__panel,',
|
||||||
|
'.ck-body-wrapper .ck.ck-list,',
|
||||||
|
'.ck-body-wrapper .ck.ck-balloon-panel {',
|
||||||
|
' background: var(--bs-body-bg) !important;',
|
||||||
|
' background-color: var(--bs-body-bg) !important;',
|
||||||
|
' border-color: var(--bs-border-color) !important;',
|
||||||
|
' color: var(--bs-body-color) !important;',
|
||||||
|
'}',
|
||||||
|
'.ck-body-wrapper .ck.ck-list .ck-list-item-button {',
|
||||||
|
' background: transparent !important;',
|
||||||
|
' background-color: transparent !important;',
|
||||||
|
' color: var(--bs-body-color) !important;',
|
||||||
|
'}',
|
||||||
|
'.ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {',
|
||||||
|
' background: var(--bs-secondary-bg) !important;',
|
||||||
|
' background-color: var(--bs-secondary-bg) !important;',
|
||||||
|
'}',
|
||||||
|
'.ck-body-wrapper .ck.ck-color-grid,',
|
||||||
|
'.ck-body-wrapper .ck.ck-color-grid__tile {',
|
||||||
|
' color: var(--bs-body-color) !important;',
|
||||||
|
'}',
|
||||||
|
'.ck-body-wrapper .ck.ck-color-grid__tile {',
|
||||||
|
' border-color: var(--bs-border-color) !important;',
|
||||||
|
'}'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
|
||||||
|
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
|
||||||
|
|
||||||
|
document.documentElement.dataset.bsTheme = normalizedTheme;
|
||||||
|
document.documentElement.style.colorScheme = normalizedTheme;
|
||||||
|
syncCkeditorTheme(normalizedTheme);
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
|
||||||
|
var icon = toggleButton.querySelector('.theme-toggle__icon');
|
||||||
|
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
|
||||||
|
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
|
||||||
|
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initThemeToggle() {
|
||||||
|
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
|
||||||
|
var storedTheme = getStoredTheme();
|
||||||
|
var theme = storedTheme || getPreferredTheme();
|
||||||
|
|
||||||
|
applyTheme(theme);
|
||||||
|
|
||||||
|
if (!toggleButtons.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||||
|
toggleButton.addEventListener('click', function () {
|
||||||
|
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
setStoredTheme(nextTheme);
|
||||||
|
applyTheme(nextTheme);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSidebarToggle() {
|
||||||
|
var body = document.body;
|
||||||
|
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
|
||||||
|
var backdrop = document.querySelector('[data-sidebar-backdrop]');
|
||||||
|
|
||||||
|
if (!toggleButtons.length || !backdrop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSidebarOpen(isOpen) {
|
||||||
|
body.classList.toggle('is-sidebar-open', isOpen);
|
||||||
|
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||||
|
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||||
|
toggleButton.addEventListener('click', function () {
|
||||||
|
toggleSidebar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
backdrop.addEventListener('click', function () {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.getPreferredTheme = getPreferredTheme;
|
||||||
|
window.getStoredTheme = getStoredTheme;
|
||||||
|
window.setStoredTheme = setStoredTheme;
|
||||||
|
window.applyTheme = applyTheme;
|
||||||
|
window.initThemeToggle = initThemeToggle;
|
||||||
|
window.initSidebarToggle = initSidebarToggle;
|
||||||
|
|
||||||
|
initThemeToggle();
|
||||||
|
initSidebarToggle();
|
||||||
|
}());
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
(function () {
|
||||||
|
function getBootstrapToast(toast) {
|
||||||
|
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.bootstrap.Toast.getOrCreateInstance(toast, {
|
||||||
|
autohide: true,
|
||||||
|
delay: 4000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeToast(toast) {
|
||||||
|
if (toast && toast.parentNode) {
|
||||||
|
toast.parentNode.removeChild(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissToast(toast) {
|
||||||
|
var instance = getBootstrapToast(toast);
|
||||||
|
if (instance) {
|
||||||
|
instance.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeToast(toast);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToastVariant(toast, variant) {
|
||||||
|
if (!toast || !toast.classList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextVariant = String(variant || 'info').trim().toLowerCase();
|
||||||
|
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
|
||||||
|
variants.forEach(function (value) {
|
||||||
|
toast.classList.remove('text-bg-' + value);
|
||||||
|
});
|
||||||
|
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageVariant(message, fallbackVariant) {
|
||||||
|
var text = String(message || '').trim();
|
||||||
|
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, variant) {
|
||||||
|
var text = String(message || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = document.getElementById('app-toast-container');
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingToast = document.getElementById('app-toast');
|
||||||
|
if (existingToast) {
|
||||||
|
var existingBody = existingToast.querySelector('.toast-body');
|
||||||
|
if (existingBody) {
|
||||||
|
existingBody.textContent = text;
|
||||||
|
}
|
||||||
|
var nextVariant = getMessageVariant(text, variant);
|
||||||
|
existingToast.setAttribute('data-toast-variant', nextVariant);
|
||||||
|
setToastVariant(existingToast, nextVariant);
|
||||||
|
var existingInstance = getBootstrapToast(existingToast);
|
||||||
|
if (existingInstance) {
|
||||||
|
existingInstance.show();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var toast = document.createElement('div');
|
||||||
|
toast.className = 'toast align-items-center border-0';
|
||||||
|
toast.id = 'app-toast';
|
||||||
|
toast.setAttribute('role', 'status');
|
||||||
|
toast.setAttribute('aria-live', 'polite');
|
||||||
|
toast.setAttribute('aria-atomic', 'true');
|
||||||
|
toast.setAttribute('data-bs-autohide', 'true');
|
||||||
|
toast.setAttribute('data-bs-delay', '4000');
|
||||||
|
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
|
||||||
|
var toastVariant = getMessageVariant(text, variant);
|
||||||
|
toast.setAttribute('data-toast-variant', toastVariant);
|
||||||
|
setToastVariant(toast, toastVariant);
|
||||||
|
toast.querySelector('.toast-body').textContent = text;
|
||||||
|
|
||||||
|
toast.addEventListener('hidden.bs.toast', function () {
|
||||||
|
removeToast(toast);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(toast);
|
||||||
|
var instance = getBootstrapToast(toast);
|
||||||
|
if (instance) {
|
||||||
|
instance.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initToast() {
|
||||||
|
var toast = document.getElementById('app-toast');
|
||||||
|
if (!toast) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var url = new URL(window.location.href);
|
||||||
|
if (url.searchParams.has('message')) {
|
||||||
|
url.searchParams.delete('message');
|
||||||
|
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore URL cleanup failures
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
|
||||||
|
setToastVariant(toast, existingVariant);
|
||||||
|
|
||||||
|
var instance = getBootstrapToast(toast);
|
||||||
|
if (instance) {
|
||||||
|
instance.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dismissToast = dismissToast;
|
||||||
|
window.showToast = showToast;
|
||||||
|
window.initToast = initToast;
|
||||||
|
|
||||||
|
initToast();
|
||||||
|
}());
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
(function () {
|
||||||
|
var webUiHelpers = window.webUiHelpers || {};
|
||||||
|
var escapeHtml = webUiHelpers.escapeHtml;
|
||||||
|
var formatDashboardDate = webUiHelpers.formatDashboardDate;
|
||||||
|
var getClientRowKey = webUiHelpers.getClientRowKey;
|
||||||
|
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||||
|
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||||
|
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||||
|
|
||||||
|
function renderClientActionCell(client) {
|
||||||
|
var paused = Boolean(client.paused);
|
||||||
|
var pauseButtonClass = 'btn btn-sm btn-info';
|
||||||
|
var pauseButtonIcon = paused ? 'bi-play-fill' : 'bi-pause-fill';
|
||||||
|
var pauseButtonLabel = paused ? 'Resume' : 'Pause';
|
||||||
|
var blackout = Boolean(client.blackout);
|
||||||
|
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
|
||||||
|
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
|
||||||
|
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
|
||||||
|
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||||
|
var blackoutCommandValue = blackout ? 'false' : 'true';
|
||||||
|
|
||||||
|
return '<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) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</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) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="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) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="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) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</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="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClientActionCell(cell, client) {
|
||||||
|
if (!cell) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pauseButton = cell.querySelector('button[data-action="pause"]');
|
||||||
|
if (!pauseButton) {
|
||||||
|
cell.innerHTML = renderClientActionCell(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var paused = Boolean(client.paused);
|
||||||
|
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
|
||||||
|
pauseButton.innerHTML = '<i class="bi bi-' + (paused ? 'play-fill' : 'pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
|
||||||
|
|
||||||
|
var pauseForm = pauseButton.form;
|
||||||
|
if (pauseForm) {
|
||||||
|
var commandInput = pauseForm.querySelector('input[name="command"]');
|
||||||
|
if (commandInput) {
|
||||||
|
commandInput.value = 'pause';
|
||||||
|
}
|
||||||
|
var connectionInput = pauseForm.querySelector('input[name="connectionId"]');
|
||||||
|
if (connectionInput) {
|
||||||
|
connectionInput.value = client.id || '';
|
||||||
|
}
|
||||||
|
pauseForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||||
|
}
|
||||||
|
|
||||||
|
var reloadButton = cell.querySelector('button[data-action="reload"]');
|
||||||
|
if (reloadButton) {
|
||||||
|
reloadButton.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload';
|
||||||
|
setButtonVariant(reloadButton, ['btn-danger', 'btn-success', 'btn-outline-secondary', 'btn-outline-dark', 'btn-outline-primary', 'btn-secondary'], 'btn-danger');
|
||||||
|
var reloadForm = reloadButton.form;
|
||||||
|
if (reloadForm) {
|
||||||
|
var reloadInput = reloadForm.querySelector('input[name="connectionId"]');
|
||||||
|
if (reloadInput) {
|
||||||
|
reloadInput.value = client.id || '';
|
||||||
|
}
|
||||||
|
reloadForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||||
|
reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var blackoutButton = cell.querySelector('button[data-action="blackout"]');
|
||||||
|
if (!blackoutButton) {
|
||||||
|
cell.innerHTML = renderClientActionCell(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var blackout = Boolean(client.blackout);
|
||||||
|
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
|
||||||
|
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary');
|
||||||
|
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + (blackout ? 'Restore' : 'Blackout');
|
||||||
|
|
||||||
|
var blackoutForm = blackoutButton.form;
|
||||||
|
if (blackoutForm) {
|
||||||
|
var blackoutCommandInput = blackoutForm.querySelector('input[name="command"]');
|
||||||
|
if (blackoutCommandInput) {
|
||||||
|
blackoutCommandInput.value = 'blackout';
|
||||||
|
}
|
||||||
|
var blackoutStateInput = blackoutForm.querySelector('input[name="blackout"]');
|
||||||
|
if (blackoutStateInput) {
|
||||||
|
blackoutStateInput.value = blackout ? 'false' : 'true';
|
||||||
|
}
|
||||||
|
var blackoutConnectionInput = blackoutForm.querySelector('input[name="connectionId"]');
|
||||||
|
if (blackoutConnectionInput) {
|
||||||
|
blackoutConnectionInput.value = client.id || '';
|
||||||
|
}
|
||||||
|
blackoutForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousButton = cell.querySelector('button[data-action="previous"]');
|
||||||
|
if (!previousButton) {
|
||||||
|
cell.innerHTML = renderClientActionCell(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setButtonVariant(previousButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
|
||||||
|
previousButton.setAttribute('aria-label', 'Previous slide');
|
||||||
|
var previousForm = previousButton.form;
|
||||||
|
if (previousForm) {
|
||||||
|
var previousCommandInput = previousForm.querySelector('input[name="command"]');
|
||||||
|
if (previousCommandInput) {
|
||||||
|
previousCommandInput.value = 'previous';
|
||||||
|
}
|
||||||
|
var previousConnectionInput = previousForm.querySelector('input[name="connectionId"]');
|
||||||
|
if (previousConnectionInput) {
|
||||||
|
previousConnectionInput.value = client.id || '';
|
||||||
|
}
|
||||||
|
previousForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextButton = cell.querySelector('button[data-action="next"]');
|
||||||
|
if (!nextButton) {
|
||||||
|
cell.innerHTML = renderClientActionCell(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setButtonVariant(nextButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
|
||||||
|
nextButton.setAttribute('aria-label', 'Next slide');
|
||||||
|
var nextForm = nextButton.form;
|
||||||
|
if (nextForm) {
|
||||||
|
var nextCommandInput = nextForm.querySelector('input[name="command"]');
|
||||||
|
if (nextCommandInput) {
|
||||||
|
nextCommandInput.value = 'next';
|
||||||
|
}
|
||||||
|
var nextConnectionInput = nextForm.querySelector('input[name="connectionId"]');
|
||||||
|
if (nextConnectionInput) {
|
||||||
|
nextConnectionInput.value = client.id || '';
|
||||||
|
}
|
||||||
|
nextForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncClientActionCell(row, client, hasActionsColumn) {
|
||||||
|
if (!row || !row.cells) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasActionsColumn) {
|
||||||
|
if (row.cells.length > 6) {
|
||||||
|
row.deleteCell(row.cells.length - 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionCell = row.cells.length > 6 ? row.cells[6] : null;
|
||||||
|
if (!actionCell) {
|
||||||
|
actionCell = row.insertCell(-1);
|
||||||
|
actionCell.setAttribute('data-label', 'Actions');
|
||||||
|
actionCell.className = 'text-end';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateClientActionCell(actionCell, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 clientNameValue = getClientDisplayName(client);
|
||||||
|
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
|
||||||
|
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
|
||||||
|
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
|
||||||
|
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
|
||||||
|
'<td data-label="Client"><div>' + clientName + '</div></td>',
|
||||||
|
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
|
||||||
|
'<td data-label="Current Slide">' + currentSlide + '</td>',
|
||||||
|
'<td data-label="IP">' + clientIp + '</td>',
|
||||||
|
'<td data-label="Viewport">' + viewport + '</td>',
|
||||||
|
'<td data-label="Connected/Updated">' + connectedAt + '</td>',
|
||||||
|
actionCell,
|
||||||
|
'</tr>'
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScreenRow(screen) {
|
||||||
|
var clientCount = Number(screen.player_connection_count || 0);
|
||||||
|
return [
|
||||||
|
'<tr>',
|
||||||
|
'<td data-label="Name">' + escapeHtml(screen.name) + '</td>',
|
||||||
|
'<td data-label="Player URL"><a href="' + escapeHtml(screen.player_url || '') + '" target="_blank">' + escapeHtml(screen.player_url || '') + '</a></td>',
|
||||||
|
'<td data-label="Playlist">' + escapeHtml(screen.playlist_name || '') + '</td>',
|
||||||
|
'<td data-label="Connected clients">' + (clientCount ? '<div class="connection-count" data-screen-connection-count="' + escapeHtml(screen.slug) + '">' + clientCount + ' connected</div>' : '<span class="empty">No clients connected.</span>') + '</td>',
|
||||||
|
'</tr>'
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(state) {
|
||||||
|
var clientCount = document.getElementById('dashboard-client-count');
|
||||||
|
var screenCount = document.getElementById('dashboard-screen-count');
|
||||||
|
var slideCount = document.getElementById('dashboard-slide-count');
|
||||||
|
var playlistCount = document.getElementById('dashboard-playlist-count');
|
||||||
|
|
||||||
|
if (playlistCount && Array.isArray(state.playlists)) {
|
||||||
|
playlistCount.textContent = String(state.playlists.length);
|
||||||
|
}
|
||||||
|
if (slideCount && Array.isArray(state.slides)) {
|
||||||
|
slideCount.textContent = String(state.slides.length);
|
||||||
|
}
|
||||||
|
if (screenCount && Array.isArray(state.screens)) {
|
||||||
|
screenCount.textContent = String(state.screens.length);
|
||||||
|
}
|
||||||
|
if (clientCount) {
|
||||||
|
clientCount.textContent = String(Number(state.connectedClientsCount || 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClientTable(state) {
|
||||||
|
var tbody = document.getElementById('dashboard-clients-table-body');
|
||||||
|
if (!tbody || !Array.isArray(state.clients)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var table = document.getElementById('dashboard-clients-table');
|
||||||
|
var hasActionsColumn = Boolean(table && String(table.getAttribute('data-has-actions-column') || '').toLowerCase() === 'true');
|
||||||
|
if (!state.clients.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var existingRows = {};
|
||||||
|
Array.prototype.slice.call(tbody.querySelectorAll('tr[data-client-key]')).forEach(function (row) {
|
||||||
|
existingRows[row.getAttribute('data-client-key')] = row;
|
||||||
|
});
|
||||||
|
|
||||||
|
Array.prototype.slice.call(tbody.querySelectorAll('tr')).forEach(function (row) {
|
||||||
|
if (!row.hasAttribute('data-client-key')) {
|
||||||
|
row.parentNode.removeChild(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.clients.forEach(function (client, index) {
|
||||||
|
var rowKey = getClientRowKey(client);
|
||||||
|
var row = existingRows[rowKey];
|
||||||
|
if (!row) {
|
||||||
|
var tempBody = document.createElement('tbody');
|
||||||
|
tempBody.innerHTML = renderClientRow(client, hasActionsColumn);
|
||||||
|
row = tempBody.firstElementChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.setAttribute('data-client-key', rowKey);
|
||||||
|
row.setAttribute('data-client-id', client.clientId || '');
|
||||||
|
row.setAttribute('data-client-device-id', client.deviceId || '');
|
||||||
|
row.setAttribute('data-client-screen-slug', client.screen_slug || '');
|
||||||
|
if (row.cells && row.cells.length >= 6) {
|
||||||
|
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 clientNameValue = getClientDisplayName(client);
|
||||||
|
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
|
||||||
|
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
|
||||||
|
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
|
||||||
|
|
||||||
|
row.cells[0].innerHTML = '<div>' + clientName + '</div>';
|
||||||
|
row.cells[1].innerHTML = '<div>' + screenName + '</div>';
|
||||||
|
row.cells[2].innerHTML = currentSlide;
|
||||||
|
row.cells[3].innerHTML = clientIp;
|
||||||
|
row.cells[4].innerHTML = viewport;
|
||||||
|
row.cells[5].innerHTML = connectedAt;
|
||||||
|
syncClientActionCell(row, client, hasActionsColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
var referenceNode = tbody.children[index] || null;
|
||||||
|
if (referenceNode !== row) {
|
||||||
|
tbody.insertBefore(row, referenceNode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
while (tbody.children.length > state.clients.length) {
|
||||||
|
tbody.removeChild(tbody.lastElementChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.applyTableSort(document.getElementById('dashboard-clients-table'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScreenTable(state) {
|
||||||
|
var table = document.getElementById('dashboard-screens-table');
|
||||||
|
if (!table || !Array.isArray(state.screens)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
|
||||||
|
if (!tbody) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.screens.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="empty">No screens yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = state.screens.map(renderScreenRow).join('');
|
||||||
|
window.applyTableSort(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboardQuickActions(state) {
|
||||||
|
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
|
||||||
|
if (!blackoutButton || !state || !Array.isArray(state.clients)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasClients = state.clients.length > 0;
|
||||||
|
var allBlackout = hasClients && state.clients.every(function (client) {
|
||||||
|
return Boolean(client && client.blackout);
|
||||||
|
});
|
||||||
|
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
|
||||||
|
var blackoutForm = blackoutButton.form;
|
||||||
|
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
|
||||||
|
var blackoutButtonIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash';
|
||||||
|
|
||||||
|
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');
|
||||||
|
if (blackoutInput) {
|
||||||
|
blackoutInput.value = allBlackout ? 'false' : 'true';
|
||||||
|
}
|
||||||
|
if (blackoutForm) {
|
||||||
|
blackoutForm.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients?' : 'Blackout all connected clients?');
|
||||||
|
}
|
||||||
|
blackoutButton.setAttribute('aria-label', label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDashboardState(state) {
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateStats(state);
|
||||||
|
updateScreenTable(state);
|
||||||
|
updateClientTable(state);
|
||||||
|
updateDashboardQuickActions(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendClientRename(screenSlug, connectionId, clientId, deviceId, clientName) {
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
body.append('command', 'setClientName');
|
||||||
|
body.append('connectionId', String(connectionId || '').trim());
|
||||||
|
body.append('clientId', String(clientId || '').trim());
|
||||||
|
body.append('deviceId', String(deviceId || '').trim());
|
||||||
|
body.append('clientName', String(clientName || '').trim());
|
||||||
|
|
||||||
|
return fetch('/clients/' + encodeURIComponent(String(screenSlug || '').trim()) + '/commands', {
|
||||||
|
method: 'POST',
|
||||||
|
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 message = text || 'Unable to rename client.';
|
||||||
|
try {
|
||||||
|
var payload = JSON.parse(text);
|
||||||
|
message = payload && (payload.error || payload.message) ? String(payload.error || payload.message) : message;
|
||||||
|
} catch (_error) {
|
||||||
|
// fall back to the raw text body
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initClientRenameHandler() {
|
||||||
|
var table = document.getElementById('dashboard-clients-table');
|
||||||
|
var tbody = document.getElementById('dashboard-clients-table-body');
|
||||||
|
if (!table || !tbody) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.addEventListener('dblclick', function (event) {
|
||||||
|
var cell = event.target && event.target.closest ? event.target.closest('td[data-label="Client"]') : null;
|
||||||
|
if (!cell || !tbody.contains(cell)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = cell.parentElement;
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
|
||||||
|
var clientId = String(row.getAttribute('data-client-id') || '').trim();
|
||||||
|
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
|
||||||
|
var screenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
|
||||||
|
var currentName = String(cell.textContent || '').trim();
|
||||||
|
var nextName = window.prompt('Rename connected client', currentName && currentName !== 'Unknown' ? currentName : '');
|
||||||
|
if (nextName === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextName = String(nextName || '').trim();
|
||||||
|
if (!nextName) {
|
||||||
|
window.alert('Client name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!connectionId || !screenSlug) {
|
||||||
|
window.alert('Unable to rename this client right now.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendClientRename(screenSlug, connectionId, clientId, deviceId, nextName).then(function () {
|
||||||
|
if (row && row.cells && row.cells[0]) {
|
||||||
|
row.cells[0].innerHTML = '<div>' + escapeHtml(nextName) + '</div>';
|
||||||
|
}
|
||||||
|
}).catch(function (error) {
|
||||||
|
window.alert(error && error.message ? error.message : 'Unable to rename client.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.webHandleDashboardState = handleDashboardState;
|
||||||
|
|
||||||
|
initClientRenameHandler();
|
||||||
|
}());
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
(function () {
|
||||||
|
function getOrCreate(modalElement) {
|
||||||
|
if (!modalElement || !window.bootstrap || !window.bootstrap.Modal) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return window.bootstrap.Modal.getOrCreateInstance(modalElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(modalElement) {
|
||||||
|
var modal = getOrCreate(modalElement);
|
||||||
|
if (modal) {
|
||||||
|
modal.show();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide(modalElement) {
|
||||||
|
var modal = getOrCreate(modalElement);
|
||||||
|
if (modal) {
|
||||||
|
modal.hide();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.pulseModal = {
|
||||||
|
getOrCreate: getOrCreate,
|
||||||
|
show: show,
|
||||||
|
hide: hide
|
||||||
|
};
|
||||||
|
}());
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
|||||||
|
(function () {
|
||||||
|
function getGroupCheckboxes(group) {
|
||||||
|
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionKey(checkbox) {
|
||||||
|
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionKey(checkbox) {
|
||||||
|
var permissionKey = getPermissionKey(checkbox);
|
||||||
|
var parts = permissionKey.split('.');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(parts[1] || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPermissionGroup(group) {
|
||||||
|
var checkboxes = getGroupCheckboxes(group);
|
||||||
|
if (!checkboxes.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var readCheckbox = null;
|
||||||
|
var nonReadChecked = false;
|
||||||
|
|
||||||
|
checkboxes.forEach(function (checkbox) {
|
||||||
|
if (getActionKey(checkbox) === 'read') {
|
||||||
|
readCheckbox = checkbox;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (checkbox.checked) {
|
||||||
|
nonReadChecked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!readCheckbox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readCheckbox.checked && nonReadChecked) {
|
||||||
|
readCheckbox.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readCheckbox.checked) {
|
||||||
|
checkboxes.forEach(function (checkbox) {
|
||||||
|
if (getActionKey(checkbox) !== 'read') {
|
||||||
|
checkbox.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGroupChange(event) {
|
||||||
|
var checkbox = event.target && event.target.matches ? event.target : null;
|
||||||
|
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionKey = getActionKey(checkbox);
|
||||||
|
var group = checkbox.closest('.accordion-item');
|
||||||
|
if (!group) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkboxes = getGroupCheckboxes(group);
|
||||||
|
var readCheckbox = checkboxes.find(function (candidate) {
|
||||||
|
return getActionKey(candidate) === 'read';
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
if (!readCheckbox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionKey === 'read' && !checkbox.checked) {
|
||||||
|
checkboxes.forEach(function (candidate) {
|
||||||
|
if (candidate !== checkbox) {
|
||||||
|
candidate.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionKey !== 'read' && checkbox.checked) {
|
||||||
|
readCheckbox.checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPermissionGroups() {
|
||||||
|
document.querySelectorAll('.accordion-item').forEach(function (group) {
|
||||||
|
syncPermissionGroup(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('change', function (event) {
|
||||||
|
var checkbox = event.target;
|
||||||
|
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGroupChange(event);
|
||||||
|
syncPermissionGroup(checkbox.closest('.accordion-item'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initPermissionGroups();
|
||||||
|
}());
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
(function () {
|
||||||
|
var REFRESH_INTERVAL_MS = 10000;
|
||||||
|
var pathname = window.location.pathname.replace(/\/$/, '');
|
||||||
|
var stateNode = document.getElementById('background-tasks-state');
|
||||||
|
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
|
||||||
|
|
||||||
|
if (pathname !== '/settings/background-tasks' && pathname !== '/settings/background-tasks/scheduled') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageUrl(href) {
|
||||||
|
try {
|
||||||
|
return new URL(href, window.location.href);
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLocalDateTimes(root) {
|
||||||
|
if (typeof window.initLocalDateTimes === 'function') {
|
||||||
|
window.initLocalDateTimes(root || document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSectionFromDocument(sectionName, responseDocument) {
|
||||||
|
var currentCard = document.querySelector('[data-pagination-card="' + sectionName + '"]');
|
||||||
|
var nextCard = responseDocument.querySelector('[data-pagination-card="' + sectionName + '"]');
|
||||||
|
|
||||||
|
if (!currentCard || !nextCard) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCard.outerHTML = nextCard.outerHTML;
|
||||||
|
updateLocalDateTimes(document.querySelector('[data-pagination-card="' + sectionName + '"]'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSummaryFromDocument(responseDocument) {
|
||||||
|
var currentSummary = document.getElementById('background-tasks-summary');
|
||||||
|
var nextSummary = responseDocument.getElementById('background-tasks-summary');
|
||||||
|
|
||||||
|
if (!currentSummary || !nextSummary) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSummary.outerHTML = nextSummary.outerHTML;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceFiltersFromDocument(responseDocument) {
|
||||||
|
var currentFilters = document.getElementById('background-tasks-filters');
|
||||||
|
var nextFilters = responseDocument.getElementById('background-tasks-filters');
|
||||||
|
|
||||||
|
if (!currentFilters || !nextFilters) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFilters.outerHTML = nextFilters.outerHTML;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceStateFromDocument(responseDocument) {
|
||||||
|
var currentState = document.getElementById('background-tasks-state');
|
||||||
|
var nextState = responseDocument.getElementById('background-tasks-state');
|
||||||
|
|
||||||
|
if (!currentState || !nextState) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentState.outerHTML = nextState.outerHTML;
|
||||||
|
stateNode = document.getElementById('background-tasks-state');
|
||||||
|
currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : currentVersion;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPageResponse(responseDocument, sectionNames) {
|
||||||
|
var updated = false;
|
||||||
|
|
||||||
|
if (replaceStateFromDocument(responseDocument)) {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceSummaryFromDocument(responseDocument)) {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceFiltersFromDocument(responseDocument)) {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionNames.forEach(function (sectionName) {
|
||||||
|
if (replaceSectionFromDocument(sectionName, responseDocument)) {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPage(nextUrl, options) {
|
||||||
|
var url = getPageUrl(nextUrl);
|
||||||
|
var sectionNames = options && options.sectionNames ? options.sectionNames : [];
|
||||||
|
var replaceHistory = Boolean(options && options.replaceHistory);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(url.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/html, application/xhtml+xml',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store'
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Unable to load background tasks page.');
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}).then(function (text) {
|
||||||
|
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||||
|
if (!applyPageResponse(responseDocument, sectionNames.length ? sectionNames : ['tasks', 'recurring'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceHistory) {
|
||||||
|
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||||
|
} else {
|
||||||
|
window.history.pushState({}, document.title, url.pathname + url.search + url.hash);
|
||||||
|
}
|
||||||
|
}).catch(function (_error) {
|
||||||
|
window.location.assign(url.pathname + url.search + url.hash);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMessageParameter() {
|
||||||
|
try {
|
||||||
|
var url = new URL(window.location.href);
|
||||||
|
if (!url.searchParams.has('message')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
url.searchParams.delete('message');
|
||||||
|
window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore URL cleanup failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPaginationLink(target) {
|
||||||
|
if (!target || !target.closest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var link = target.closest('.pagination .page-link');
|
||||||
|
if (!link) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var href = String(link.getAttribute('href') || '').trim();
|
||||||
|
if (!href || href === '#') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var card = link.closest('[data-pagination-card]');
|
||||||
|
if (!card) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
href: href,
|
||||||
|
sectionName: String(card.getAttribute('data-pagination-card') || '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPaginationNavigation() {
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var paginationLink = findPaginationLink(event.target);
|
||||||
|
if (paginationLink && paginationLink.sectionName) {
|
||||||
|
var currentUrl = getPageUrl(window.location.href);
|
||||||
|
var nextUrl = getPageUrl(paginationLink.href);
|
||||||
|
if (!currentUrl || !nextUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search && currentUrl.hash === nextUrl.hash) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
window.addEventListener('popstate', function () {
|
||||||
|
loadPage(window.location.href, { replaceHistory: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPage() {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/settings/background-tasks/state', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store'
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}).then(function (payload) {
|
||||||
|
if (!payload || !payload.version) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextVersion = String(payload.version || '');
|
||||||
|
if (!currentVersion) {
|
||||||
|
currentVersion = nextVersion;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextVersion !== currentVersion) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}).catch(function (_error) {
|
||||||
|
// Ignore refresh probe errors and try again on the next interval.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stripMessageParameter();
|
||||||
|
initPaginationNavigation();
|
||||||
|
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
|
||||||
|
}());
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
|||||||
|
export function createTemplateSelectorLockController(templateSelect) {
|
||||||
|
var locked = false;
|
||||||
|
var armed = false;
|
||||||
|
var lockInput = null;
|
||||||
|
|
||||||
|
function clearLockInput() {
|
||||||
|
if (lockInput && lockInput.parentNode) {
|
||||||
|
lockInput.parentNode.removeChild(lockInput);
|
||||||
|
}
|
||||||
|
lockInput = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
locked = false;
|
||||||
|
armed = false;
|
||||||
|
clearLockInput();
|
||||||
|
if (!templateSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
templateSelect.disabled = false;
|
||||||
|
templateSelect.removeAttribute('aria-disabled');
|
||||||
|
templateSelect.removeAttribute('title');
|
||||||
|
templateSelect.classList.remove('is-locked');
|
||||||
|
}
|
||||||
|
|
||||||
|
function arm() {
|
||||||
|
armed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLockedValue() {
|
||||||
|
if (lockInput) {
|
||||||
|
lockInput.value = templateSelect.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lock() {
|
||||||
|
if (!templateSelect || locked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
locked = true;
|
||||||
|
lockInput = lockInput || document.createElement('input');
|
||||||
|
lockInput.type = 'hidden';
|
||||||
|
lockInput.name = templateSelect.name;
|
||||||
|
lockInput.value = templateSelect.value;
|
||||||
|
templateSelect.insertAdjacentElement('afterend', lockInput);
|
||||||
|
templateSelect.disabled = true;
|
||||||
|
templateSelect.setAttribute('aria-disabled', 'true');
|
||||||
|
templateSelect.setAttribute('title', 'Template is locked after you edit its content.');
|
||||||
|
templateSelect.classList.add('is-locked');
|
||||||
|
}
|
||||||
|
|
||||||
|
function markEdited() {
|
||||||
|
if (!armed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock();
|
||||||
|
syncLockedValue();
|
||||||
|
return locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
if (!templateSelect) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
syncLockedValue();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset: reset,
|
||||||
|
arm: arm,
|
||||||
|
markEdited: markEdited,
|
||||||
|
sync: sync,
|
||||||
|
isLocked: function () {
|
||||||
|
return locked;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user