This commit is contained in:
@@ -11,6 +11,7 @@ MYSQL_ROOT_PASSWORD=root_password
|
||||
|
||||
PLAYER_INTERNAL_BASE_URL=http://player:3001
|
||||
PLAYER_PUBLIC_BASE_URL=http://localhost:3001
|
||||
PULSE_SIGNAGE_SHARED_SECRET=
|
||||
|
||||
SESSION_MAX_AGE_DAYS=14
|
||||
DASHBOARD_REFRESH_INTERVAL_MS=2000
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
node_modules/
|
||||
uploads/
|
||||
media/
|
||||
docker-compose.dev.yml
|
||||
.vscode/
|
||||
.env
|
||||
|
||||
+3
-1
@@ -2,12 +2,14 @@ FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ffmpeg
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN mkdir -p /app/uploads
|
||||
RUN mkdir -p /app/media
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ The app reads its settings from environment variables.
|
||||
- `WEB_PORT` - admin app port, default `3000`
|
||||
- `PLAYER_INTERNAL_BASE_URL` - player address used by the server, default `http://player:3001`
|
||||
- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links, default `http://localhost:3001`
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; page tokens auto-renew while the page stays active and request signatures must be fresh; leave unset to keep the auth checks disabled for compatibility
|
||||
|
||||
### Player App
|
||||
|
||||
@@ -77,13 +78,14 @@ The repository includes a `docker-compose.yml` file that starts three services:
|
||||
|
||||
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, upload storage is controlled by the `uploads` volume mount at `/app/uploads`.
|
||||
In Docker, media storage is controlled by the `media` volume mount at `/app/media`.
|
||||
Outside Docker, the web app and player do not have to use the same upload location or even the same server, as long as each service can access its own configured media path.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`.
|
||||
- When running in Docker, that value should point to the Docker service name, not `localhost`.
|
||||
- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes.
|
||||
- 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.
|
||||
+5
-3
@@ -15,6 +15,7 @@ services:
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:3001}
|
||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
||||
DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000}
|
||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||
@@ -22,7 +23,7 @@ services:
|
||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin}
|
||||
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
- media:/app/media
|
||||
command: ["npm", "run", "start:web"]
|
||||
depends_on:
|
||||
mysql:
|
||||
@@ -40,13 +41,14 @@ services:
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
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_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-signage}
|
||||
DB_USER: ${DB_USER:-signage_user}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
- media:/app/media
|
||||
command: ["npm", "run", "start:player"]
|
||||
depends_on:
|
||||
mysql:
|
||||
@@ -80,7 +82,7 @@ services:
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
uploads:
|
||||
media:
|
||||
|
||||
networks:
|
||||
pulse_signage:
|
||||
|
||||
+187
-3
@@ -4,25 +4,134 @@
|
||||
|
||||
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
|
||||
|
||||
### `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}`
|
||||
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`
|
||||
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}/clients`
|
||||
Returns the live player connection snapshot for a screen.
|
||||
Access: public within the trusted player deployment, but it exposes live connection state.
|
||||
|
||||
Response fields:
|
||||
|
||||
- `screen`
|
||||
- `screenSlug`
|
||||
- `count`
|
||||
- `connections`
|
||||
- `degraded`
|
||||
|
||||
### `POST /api/screens/{slug}/commands`
|
||||
Sends a command to the player connections for a screen.
|
||||
Access: internal-only. The admin dashboard should remain the protected control surface for commands.
|
||||
|
||||
Accepted request fields:
|
||||
|
||||
@@ -31,21 +140,87 @@ Accepted request fields:
|
||||
- `clientId` optional
|
||||
- `blackout` optional when `command=blackout`
|
||||
|
||||
Command-specific fields:
|
||||
|
||||
- `url` for `redirect`
|
||||
- `clientName` for `setclientname`
|
||||
- `deviceId` for `setclientname`
|
||||
|
||||
Supported commands:
|
||||
|
||||
- `refresh`
|
||||
- `reload`
|
||||
- `redirect`
|
||||
- `pause`
|
||||
- `blackout`
|
||||
- `previous`
|
||||
- `next`
|
||||
- `left`
|
||||
- `right`
|
||||
- `setclientname`
|
||||
|
||||
If `connectionId` or `clientId` is provided, the command targets a single player connection. Otherwise it is broadcast to all connections for that screen.
|
||||
|
||||
Response fields:
|
||||
|
||||
- `screen`
|
||||
- `screenSlug`
|
||||
- `command`
|
||||
- `connectionId`
|
||||
- `sent`
|
||||
- `degraded`
|
||||
|
||||
## Response Shapes
|
||||
|
||||
### Onboarding Status Response
|
||||
|
||||
`GET /api/onboarding/status` returns an object with:
|
||||
|
||||
- `deviceId`
|
||||
- `onboarded`
|
||||
- `clientName`
|
||||
- `screenId`
|
||||
- `screenSlug`
|
||||
- `screenName`
|
||||
- `playerUrl`
|
||||
|
||||
### Onboarding Screens Response
|
||||
|
||||
`GET /api/onboarding/screens` returns an object with:
|
||||
|
||||
- `screens`
|
||||
|
||||
### QR Response
|
||||
|
||||
`GET /api/onboarding/qr` returns SVG markup.
|
||||
|
||||
### Upload Config Response
|
||||
|
||||
`GET /api/media/config` returns an object with:
|
||||
|
||||
- `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
|
||||
|
||||
`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`
|
||||
- `playlist`
|
||||
- `slides`
|
||||
- `rssFeeds`
|
||||
- `apiSources`
|
||||
- `revision`
|
||||
|
||||
### Connections Response
|
||||
|
||||
@@ -62,6 +240,7 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
||||
- `screenSlug`
|
||||
- `count`
|
||||
- `connections`
|
||||
- `degraded`
|
||||
|
||||
### Command Response
|
||||
|
||||
@@ -72,6 +251,7 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
||||
- `command`
|
||||
- `connectionId`
|
||||
- `sent`
|
||||
- `degraded`
|
||||
|
||||
## Data Models
|
||||
|
||||
@@ -113,11 +293,15 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
||||
|
||||
- `id`
|
||||
- `clientId`
|
||||
- `clientName`
|
||||
- `deviceId`
|
||||
- `label`
|
||||
- `userAgent`
|
||||
- `viewport`
|
||||
- `page`
|
||||
- `currentSlide`
|
||||
- `currentSlideId`
|
||||
- `currentSlideTitle`
|
||||
- `paused`
|
||||
- `blackout`
|
||||
- `clientIp`
|
||||
@@ -128,4 +312,4 @@ If `connectionId` or `clientId` is provided, the command targets a single player
|
||||
## Notes
|
||||
|
||||
- The player API is the only surface documented here.
|
||||
- Web UI/admin routes are intentionally omitted.
|
||||
- Web UI/admin routes are intentionally omitted, except where the player service itself exposes onboarding and upload endpoints.
|
||||
|
||||
@@ -6,6 +6,8 @@ Player service websocket base URL: `ws://localhost:3001`
|
||||
|
||||
This document uses OpenAPI-style sections, but stays in plain markdown.
|
||||
|
||||
The `PULSE_SIGNAGE_SHARED_SECRET` setting does not change the websocket message format here. It is used to sign the player control socket URL and the server-side snapshot subscription; the message payloads themselves remain the same.
|
||||
|
||||
## Channels
|
||||
|
||||
### `GET /ws/screens/{slug}`
|
||||
@@ -13,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.
|
||||
|
||||
Access: the player page must include a valid `auth` query parameter signed with the shared secret. The browser page refreshes this token automatically while it is active.
|
||||
|
||||
### `GET /ws/screens/{slug}/events`
|
||||
Player snapshot channel.
|
||||
|
||||
This is the server-to-dashboard snapshot stream for live player connection state.
|
||||
|
||||
Access: internal-only. The web backend subscribes with signed request headers; browsers should not connect directly.
|
||||
|
||||
## Player Control Channel
|
||||
|
||||
### Messages from player to server
|
||||
@@ -35,6 +41,8 @@ Example payload:
|
||||
{
|
||||
"type": "hello",
|
||||
"clientId": "client-id",
|
||||
"clientName": "friendly label",
|
||||
"deviceId": "device-id",
|
||||
"userAgent": "browser ua",
|
||||
"page": "http://.../screen/demo",
|
||||
"viewport": { "width": 1920, "height": 1080 },
|
||||
@@ -52,6 +60,8 @@ Example payload:
|
||||
{
|
||||
"type": "state",
|
||||
"clientId": "client-id",
|
||||
"clientName": "friendly label",
|
||||
"deviceId": "device-id",
|
||||
"userAgent": "browser ua",
|
||||
"page": "http://.../screen/demo",
|
||||
"viewport": { "width": 1920, "height": 1080 },
|
||||
@@ -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
|
||||
|
||||
The server sends command messages with:
|
||||
@@ -75,17 +87,21 @@ The server sends command messages with:
|
||||
- optional `sentAt`
|
||||
- optional `targetConnectionId`
|
||||
- optional `blackout` for blackout commands
|
||||
- optional `url` for redirect commands
|
||||
- optional `clientName` and `deviceId` for client-name updates
|
||||
|
||||
Supported commands:
|
||||
|
||||
- `refresh`
|
||||
- `reload`
|
||||
- `redirect`
|
||||
- `pause`
|
||||
- `blackout`
|
||||
- `previous`
|
||||
- `next`
|
||||
- `left`
|
||||
- `right`
|
||||
- `setclientname`
|
||||
|
||||
#### `refresh`
|
||||
Asks the player to refetch the current playlist.
|
||||
@@ -111,12 +127,19 @@ Example payload:
|
||||
|
||||
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`
|
||||
Moves to the previous slide.
|
||||
|
||||
#### `next` / `right`
|
||||
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
|
||||
|
||||
### Messages from server to client
|
||||
@@ -142,11 +165,15 @@ The snapshot channel is server-to-client only.
|
||||
|
||||
- `id`
|
||||
- `clientId`
|
||||
- `clientName`
|
||||
- `deviceId`
|
||||
- `label`
|
||||
- `userAgent`
|
||||
- `viewport`
|
||||
- `page`
|
||||
- `currentSlide`
|
||||
- `currentSlideId`
|
||||
- `currentSlideTitle`
|
||||
- `paused`
|
||||
- `blackout`
|
||||
- `clientIp`
|
||||
|
||||
@@ -10,6 +10,17 @@ module.exports = {
|
||||
parseJsonSafe: data.parseJsonSafe,
|
||||
fetchAdminData: data.fetchAdminData,
|
||||
fetchPlaylistById: data.fetchPlaylistById,
|
||||
fetchApiSourcesData: data.fetchApiSourcesData,
|
||||
fetchApiSourceById: data.fetchApiSourceById,
|
||||
fetchApiSourceResponse: data.fetchApiSourceResponse,
|
||||
buildApiSourcePayload: data.buildApiSourcePayload,
|
||||
fetchRssFeedsData: data.fetchRssFeedsData,
|
||||
fetchRssFeedById: data.fetchRssFeedById,
|
||||
fetchRssFeedItemsByFeedId: data.fetchRssFeedItemsByFeedId,
|
||||
normalizeRssFeedItem: data.normalizeRssFeedItem,
|
||||
buildRssFeedPayload: data.buildRssFeedPayload,
|
||||
fetchRssFeedItems: data.fetchRssFeedItems,
|
||||
replaceRssFeedItems: data.replaceRssFeedItems,
|
||||
fetchScreenById: data.fetchScreenById,
|
||||
fetchScreenEditData: data.fetchScreenEditData,
|
||||
fetchTemplateById: data.fetchTemplateById,
|
||||
@@ -21,6 +32,7 @@ module.exports = {
|
||||
buildSlidePayload: data.buildSlidePayload,
|
||||
extractTemplateRegions: data.extractTemplateRegions,
|
||||
buildTemplatePayload: data.buildTemplatePayload,
|
||||
fetchDuplicateName: data.fetchDuplicateName,
|
||||
mediaKind: player.mediaKind,
|
||||
renderPlayerPage: player.renderPlayerPage,
|
||||
renderPlayerOnboardingLandingPage: player.renderPlayerOnboardingLandingPage,
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ async function fetchAdminData(pool) {
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, 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(`
|
||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides s
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
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 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,
|
||||
fetchApiSourceById: fetchApiSourceById,
|
||||
fetchApiSourceResponse: fetchApiSourceResponse,
|
||||
buildApiSourcePayload: buildApiSourcePayload
|
||||
};
|
||||
+16
-2
@@ -1,10 +1,12 @@
|
||||
const { fetchAdminData } = require('./admin');
|
||||
const { fetchPlaylistById } = require('./playlists');
|
||||
const { fetchApiSourcesData, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||
const { fetchRssFeedsData, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
const { parseJsonSafe, fetchDuplicateName } = require('./utils');
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
@@ -12,6 +14,17 @@ module.exports = {
|
||||
parseJsonSafe,
|
||||
fetchAdminData,
|
||||
fetchPlaylistById,
|
||||
fetchApiSourcesData,
|
||||
fetchApiSourceById,
|
||||
fetchApiSourceResponse,
|
||||
buildApiSourcePayload,
|
||||
fetchRssFeedsData,
|
||||
fetchRssFeedById,
|
||||
fetchRssFeedItemsByFeedId,
|
||||
normalizeRssFeedItem,
|
||||
buildRssFeedPayload,
|
||||
fetchRssFeedItems,
|
||||
replaceRssFeedItems,
|
||||
fetchScreenById,
|
||||
fetchScreenEditData,
|
||||
fetchTemplateById,
|
||||
@@ -22,5 +35,6 @@ module.exports = {
|
||||
buildCanvasSizePayload,
|
||||
buildSlidePayload,
|
||||
extractTemplateRegions,
|
||||
buildTemplatePayload
|
||||
buildTemplatePayload,
|
||||
fetchDuplicateName
|
||||
};
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
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 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,
|
||||
fetchRssFeedById,
|
||||
fetchRssFeedItemsByFeedId,
|
||||
normalizeRssFeedItem,
|
||||
buildRssFeedPayload,
|
||||
fetchRssFeedItems,
|
||||
replaceRssFeedItems
|
||||
};
|
||||
+54
-2
@@ -2,6 +2,7 @@ const { fetchTemplateById } = require('./templates');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
||||
const DEFAULT_FONT_SIZE = 32;
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
let output = String(html || '');
|
||||
@@ -60,10 +61,19 @@ function sanitizeTextColor(value, fallback) {
|
||||
return fallback || '#000000';
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value, fallback) {
|
||||
const raw = String(value || '').trim();
|
||||
const parsed = Math.round(Number(raw.replace(/[^0-9.]/g, '')));
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
return Math.max(8, Number(fallback || DEFAULT_FONT_SIZE));
|
||||
}
|
||||
|
||||
function getTextRegionStyle(body, region, existingContent) {
|
||||
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const fontFamily = String(body[`region_font_family_${region.id}`] || existing.font_family || region.font_family || 'Arial').trim() || 'Arial';
|
||||
const fontSize = Math.max(8, Number(body[`region_font_size_${region.id}`] || existing.font_size || 24));
|
||||
const fontSize = sanitizeFontSize(body[`region_font_size_${region.id}`], existing.font_size || DEFAULT_FONT_SIZE);
|
||||
const fontColor = sanitizeTextColor(body[`region_font_color_${region.id}`] || existing.font_color || region.font_color || '#000000');
|
||||
return {
|
||||
font_family: fontFamily,
|
||||
@@ -80,7 +90,7 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
const existing = body[`existing_region_image_${region.id}`];
|
||||
content[region.region_key] = {
|
||||
type: 'image',
|
||||
value: uploaded ? `/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
||||
value: uploaded ? `/media/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
||||
};
|
||||
} else if (region.region_type === 'webpage') {
|
||||
const submitted = body[`region_webpage_${region.id}`];
|
||||
@@ -89,6 +99,14 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
type: 'webpage',
|
||||
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') {
|
||||
const submitted = body[`region_html_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
@@ -96,6 +114,40 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
type: 'html',
|
||||
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 {
|
||||
const submitted = body[`region_text_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
|
||||
+31
-27
@@ -1,6 +1,7 @@
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
|
||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html'];
|
||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html', 'rtmp', 'rss', 'api'];
|
||||
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
|
||||
|
||||
function sanitizeBackgroundColor(value) {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -15,6 +16,14 @@ function normalizeTemplateRegionType(value) {
|
||||
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();
|
||||
}
|
||||
@@ -49,7 +58,7 @@ async function fetchTemplateById(pool, id) {
|
||||
return null;
|
||||
}
|
||||
const template = templates[0];
|
||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, 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;
|
||||
return template;
|
||||
}
|
||||
@@ -62,7 +71,7 @@ async function fetchTemplatesData(pool) {
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, 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 };
|
||||
}
|
||||
|
||||
@@ -71,17 +80,21 @@ function extractTemplateRegions(body) {
|
||||
if (regionsJson) {
|
||||
const parsed = parseJsonSafe(regionsJson);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((region) => ({
|
||||
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
||||
region_type: normalizeTemplateRegionType(region.region_type),
|
||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||
font_family: ['text', 'html'].includes(normalizeTemplateRegionType(region.region_type)) ? String(region.font_family || '').trim() || null : null,
|
||||
x: Number(region.x || 0),
|
||||
y: Number(region.y || 0),
|
||||
width: Number(region.width || 100),
|
||||
height: Number(region.height || 100),
|
||||
z_index: Number(region.z_index || 0)
|
||||
})).filter((region) => region.region_key && region.label);
|
||||
return parsed.map((region) => {
|
||||
const regionType = normalizeTemplateRegionType(region.region_type);
|
||||
return {
|
||||
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
||||
region_type: regionType,
|
||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(region.font_family || '').trim() || null : null,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
||||
x: Number(region.x || 0),
|
||||
y: Number(region.y || 0),
|
||||
width: Number(region.width || 100),
|
||||
height: Number(region.height || 100),
|
||||
z_index: Number(region.z_index || 0)
|
||||
};
|
||||
}).filter((region) => region.region_key && region.label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +102,7 @@ function extractTemplateRegions(body) {
|
||||
const names = readFormArray(body, 'region_name[]');
|
||||
const labels = readFormArray(body, 'region_label[]');
|
||||
const types = readFormArray(body, 'region_type[]');
|
||||
const ratios = readFormArray(body, 'region_lock_ratio[]');
|
||||
const xs = readFormArray(body, 'region_x[]');
|
||||
const ys = readFormArray(body, 'region_y[]');
|
||||
const widths = readFormArray(body, 'region_width[]');
|
||||
@@ -108,7 +122,8 @@ function extractTemplateRegions(body) {
|
||||
region_key: name,
|
||||
region_type: regionType,
|
||||
label: name,
|
||||
font_family: ['text', 'html'].includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
|
||||
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
||||
x: Number(xs[i] || 0),
|
||||
y: Number(ys[i] || 0),
|
||||
width: Number(widths[i] || 100),
|
||||
@@ -139,7 +154,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
const removeBackgroundImage = Boolean(req.body.remove_background_image);
|
||||
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
|
||||
const backgroundImagePath = backgroundImage
|
||||
? `/uploads/${backgroundImage.filename}`
|
||||
? `/media/${backgroundImage.filename}`
|
||||
: removeBackgroundImage
|
||||
? null
|
||||
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
|
||||
@@ -171,17 +186,6 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
regions = [{
|
||||
region_key: 'region_1',
|
||||
region_type: 'text',
|
||||
label: 'Region 1',
|
||||
font_family: 'Arial',
|
||||
x: 120,
|
||||
y: 120,
|
||||
width: Math.max(200, Math.round(canvasWidth * 0.22)),
|
||||
height: Math.max(120, Math.round(canvasHeight * 0.15)),
|
||||
z_index: 1
|
||||
}];
|
||||
}
|
||||
|
||||
ensureUniqueTemplateRegionNames(regions);
|
||||
|
||||
+21
-1
@@ -22,7 +22,27 @@ function readFormArray(body, key) {
|
||||
return [body[key]];
|
||||
}
|
||||
|
||||
async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) {
|
||||
const normalizedName = String(name || '').trim();
|
||||
if (!normalizedName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedColumnName = String(columnName || 'name').trim() || 'name';
|
||||
const params = [normalizedName];
|
||||
let sql = `SELECT id, \`${normalizedColumnName}\` AS name FROM \`${tableName}\` WHERE LOWER(TRIM(\`${normalizedColumnName}\`)) = LOWER(TRIM(?))`;
|
||||
if (excludeId !== undefined && excludeId !== null) {
|
||||
sql += ' AND id <> ?';
|
||||
params.push(excludeId);
|
||||
}
|
||||
sql += ' LIMIT 1';
|
||||
|
||||
const [rows] = await pool.query(sql, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseJsonSafe,
|
||||
readFormArray
|
||||
readFormArray,
|
||||
fetchDuplicateName
|
||||
};
|
||||
|
||||
@@ -1,730 +0,0 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const { hashPassword } = require('./auth');
|
||||
const { PERMISSIONS, DEFAULT_ROLE, normalizePermissionKeys } = require('./rbac');
|
||||
|
||||
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 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 addUserAuditColumns(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
|
||||
AND created_user.id IS 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
|
||||
AND modified_user.id IS 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 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 = getPermissionKey(row);
|
||||
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 pruneStaleOnboardingDevices(pool) {
|
||||
await pool.query(
|
||||
`DELETE FROM player_onboarding_devices
|
||||
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
|
||||
);
|
||||
}
|
||||
|
||||
async function getTableColumnNames(pool, tableName) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
return new Set((rows || []).map(function (row) {
|
||||
return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase();
|
||||
}).filter(Boolean));
|
||||
}
|
||||
|
||||
function getPermissionKey(row) {
|
||||
return String((row && row.permission_key) || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getLegacyPermissionTargets(permissionKey) {
|
||||
const normalizedKey = String(permissionKey || '').trim().toLowerCase();
|
||||
const parts = normalizedKey.split('.');
|
||||
if (parts.length !== 2) {
|
||||
return [normalizedKey].filter(Boolean);
|
||||
}
|
||||
|
||||
const sectionKey = parts[0];
|
||||
const actionKey = parts[1];
|
||||
if (normalizedKey === 'screens.allow') {
|
||||
return ['clients.allow'];
|
||||
}
|
||||
if (actionKey === 'edit') {
|
||||
return [`${sectionKey}.update`];
|
||||
}
|
||||
if (actionKey === 'update') {
|
||||
return [`${sectionKey}.update`];
|
||||
}
|
||||
if (actionKey === 'view') {
|
||||
return [`${sectionKey}.read`];
|
||||
}
|
||||
if (actionKey === 'manage') {
|
||||
return [`${sectionKey}.read`, `${sectionKey}.create`, `${sectionKey}.update`, `${sectionKey}.delete`];
|
||||
}
|
||||
|
||||
return [normalizedKey].filter(Boolean);
|
||||
}
|
||||
|
||||
function buildPermissionSeedColumns(columnNames) {
|
||||
const columns = [];
|
||||
if (columnNames.has('permission_key')) {
|
||||
columns.push('permission_key');
|
||||
}
|
||||
if (columnNames.has('name')) {
|
||||
columns.push('name');
|
||||
}
|
||||
if (columnNames.has('section_name')) {
|
||||
columns.push('section_name');
|
||||
}
|
||||
if (columnNames.has('description')) {
|
||||
columns.push('description');
|
||||
}
|
||||
if (columnNames.has('created_by')) {
|
||||
columns.push('created_by');
|
||||
}
|
||||
if (columnNames.has('modified_by')) {
|
||||
columns.push('modified_by');
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
async function backfillLegacyRbacSchema(pool) {
|
||||
const permissionColumnNames = await getTableColumnNames(pool, 'permissions');
|
||||
|
||||
const [permissionRows] = await pool.query('SELECT id, permission_key, name, section_name FROM permissions ORDER BY id ASC');
|
||||
const [rolePermissionRows] = await pool.query(
|
||||
`SELECT rp.role_id, p.permission_key
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.id = rp.permission_id`
|
||||
);
|
||||
|
||||
const permissionIdByKey = new Map();
|
||||
for (const row of permissionRows || []) {
|
||||
const currentKey = getPermissionKey(row);
|
||||
if (currentKey) {
|
||||
permissionIdByKey.set(currentKey, Number(row.id));
|
||||
}
|
||||
}
|
||||
|
||||
const rolePermissionTargets = new Map();
|
||||
const desiredPermissionKeys = new Set(PERMISSIONS.map(function (permission) {
|
||||
return permission.key;
|
||||
}));
|
||||
const legacyPermissionRowIds = [];
|
||||
|
||||
function addRoleTarget(roleId, permissionKey) {
|
||||
const normalizedPermissionKey = String(permissionKey || '').trim().toLowerCase();
|
||||
if (!normalizedPermissionKey) {
|
||||
return;
|
||||
}
|
||||
if (!rolePermissionTargets.has(roleId)) {
|
||||
rolePermissionTargets.set(roleId, new Set());
|
||||
}
|
||||
rolePermissionTargets.get(roleId).add(normalizedPermissionKey);
|
||||
}
|
||||
|
||||
for (const row of rolePermissionRows || []) {
|
||||
const currentKey = getPermissionKey(row);
|
||||
const targetKeys = getLegacyPermissionTargets(currentKey);
|
||||
if (currentKey.endsWith('.view') || currentKey.endsWith('.manage') || currentKey.endsWith('.edit') || currentKey === 'screens.allow') {
|
||||
for (const targetKey of targetKeys) {
|
||||
addRoleTarget(Number(row.role_id), targetKey);
|
||||
}
|
||||
} else {
|
||||
addRoleTarget(Number(row.role_id), currentKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of permissionRows || []) {
|
||||
const currentKey = getPermissionKey(row);
|
||||
const permissionId = Number(row.id);
|
||||
if (currentKey.endsWith('.edit')) {
|
||||
const targetKey = currentKey.replace(/\.edit$/, '.update');
|
||||
const targetPermissionId = permissionIdByKey.get(targetKey);
|
||||
if (targetPermissionId) {
|
||||
legacyPermissionRowIds.push(permissionId);
|
||||
} else if (targetKey) {
|
||||
await pool.query('UPDATE permissions SET permission_key = ? WHERE id = ?', [targetKey, permissionId]);
|
||||
permissionIdByKey.delete(currentKey);
|
||||
permissionIdByKey.set(targetKey, permissionId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (currentKey.endsWith('.view') || currentKey.endsWith('.manage') || currentKey.endsWith('.edit') || currentKey === 'screens.allow') {
|
||||
legacyPermissionRowIds.push(permissionId);
|
||||
}
|
||||
}
|
||||
|
||||
const roleRows = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC').then(function (result) {
|
||||
return result[0] || [];
|
||||
});
|
||||
const defaultRoleRow = roleRows.find(function (row) {
|
||||
return String(row.role_key || '').trim().toLowerCase() === DEFAULT_ROLE.key;
|
||||
}) || null;
|
||||
if (defaultRoleRow) {
|
||||
if (!rolePermissionTargets.has(Number(defaultRoleRow.id))) {
|
||||
rolePermissionTargets.set(Number(defaultRoleRow.id), new Set());
|
||||
}
|
||||
const defaultPermissions = rolePermissionTargets.get(Number(defaultRoleRow.id));
|
||||
for (const permission of PERMISSIONS) {
|
||||
defaultPermissions.add(permission.key);
|
||||
}
|
||||
}
|
||||
|
||||
const seedColumns = buildPermissionSeedColumns(permissionColumnNames);
|
||||
if (!seedColumns.length) {
|
||||
throw new Error('permissions table is missing required columns.');
|
||||
}
|
||||
|
||||
for (const permission of PERMISSIONS) {
|
||||
const seedValues = [];
|
||||
if (permissionColumnNames.has('permission_key')) {
|
||||
seedValues.push(permission.key);
|
||||
}
|
||||
if (permissionColumnNames.has('name')) {
|
||||
seedValues.push(permission.name);
|
||||
}
|
||||
if (permissionColumnNames.has('section_name')) {
|
||||
seedValues.push(permission.sectionName);
|
||||
}
|
||||
if (permissionColumnNames.has('description')) {
|
||||
seedValues.push(permission.description || null);
|
||||
}
|
||||
if (permissionColumnNames.has('created_by')) {
|
||||
seedValues.push(null);
|
||||
}
|
||||
if (permissionColumnNames.has('modified_by')) {
|
||||
seedValues.push(null);
|
||||
}
|
||||
|
||||
const updateAssignments = [];
|
||||
if (permissionColumnNames.has('name')) {
|
||||
updateAssignments.push('name = VALUES(name)');
|
||||
}
|
||||
if (permissionColumnNames.has('section_name')) {
|
||||
updateAssignments.push('section_name = VALUES(section_name)');
|
||||
}
|
||||
if (permissionColumnNames.has('description')) {
|
||||
updateAssignments.push('description = VALUES(description)');
|
||||
}
|
||||
if (permissionColumnNames.has('permission_key')) {
|
||||
updateAssignments.push('permission_key = VALUES(permission_key)');
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO permissions (${seedColumns.join(', ')})
|
||||
VALUES (${seedColumns.map(function () { return '?'; }).join(', ')})
|
||||
ON DUPLICATE KEY UPDATE ${updateAssignments.join(', ')}`,
|
||||
seedValues
|
||||
);
|
||||
}
|
||||
|
||||
if (legacyPermissionRowIds.length) {
|
||||
await pool.query('DELETE FROM permissions WHERE id IN (?)', [legacyPermissionRowIds]);
|
||||
}
|
||||
|
||||
const [currentPermissionRows] = await pool.query('SELECT id, permission_key FROM permissions');
|
||||
const permissionIdByKeyAfterBackfill = new Map();
|
||||
for (const row of currentPermissionRows || []) {
|
||||
const currentKey = getPermissionKey(row);
|
||||
if (currentKey) {
|
||||
permissionIdByKeyAfterBackfill.set(currentKey, Number(row.id));
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM role_permissions');
|
||||
for (const [roleId, permissionKeys] of rolePermissionTargets.entries()) {
|
||||
const expandedPermissionKeys = normalizePermissionKeys(Array.from(permissionKeys.values()));
|
||||
for (const permissionKey of expandedPermissionKeys) {
|
||||
const permissionId = permissionIdByKeyAfterBackfill.get(permissionKey);
|
||||
if (!permissionId) {
|
||||
continue;
|
||||
}
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
|
||||
[Number(roleId), permissionId, null, null]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [roleRowsAfter] = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC');
|
||||
for (const row of roleRowsAfter || []) {
|
||||
const currentKey = String(row.role_key || '').trim();
|
||||
const isAdministratorsRole = String(row.name || '').trim().toLowerCase() === DEFAULT_ROLE.name.toLowerCase();
|
||||
const expectedKey = isAdministratorsRole ? DEFAULT_ROLE.key : `role-${row.id}`;
|
||||
if (!currentKey || currentKey !== expectedKey) {
|
||||
await pool.query(
|
||||
'UPDATE roles SET role_key = ?, name = ?, description = COALESCE(description, ?) WHERE id = ?',
|
||||
[expectedKey, String(row.name || '').trim() || DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, row.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 addUserAuditColumns(pool, 'canvas_sizes');
|
||||
|
||||
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 addUserAuditColumns(pool, 'playlists');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS slide_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
canvas_size_id INT NULL,
|
||||
background_image_path VARCHAR(512) NULL,
|
||||
background_color VARCHAR(32) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addUserAuditColumns(pool, 'slide_templates');
|
||||
|
||||
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 addUserAuditColumns(pool, 'slide_template_regions');
|
||||
|
||||
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 addUserAuditColumns(pool, 'slides');
|
||||
|
||||
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 addUserAuditColumns(pool, 'playlist_slides');
|
||||
|
||||
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 addUserAuditColumns(pool, 'screens');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS player_onboarding_devices (
|
||||
device_id VARCHAR(128) PRIMARY KEY,
|
||||
client_name VARCHAR(255) NULL,
|
||||
screen_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
|
||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addUserAuditColumns(pool, 'player_onboarding_devices');
|
||||
|
||||
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 addUserAuditColumns(pool, 'users');
|
||||
|
||||
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,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
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 addUserAuditColumns(pool, 'roles');
|
||||
|
||||
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,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
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 addUserAuditColumns(pool, 'permissions');
|
||||
await dropColumnIfPresent(pool, 'permissions', 'perm_key');
|
||||
await dedupePermissionRows(pool);
|
||||
await addUniqueIndexIfMissing(pool, 'permissions', 'permission_key', 'uq_permissions_permission_key');
|
||||
|
||||
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,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
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 addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addUserAuditColumns(pool, 'role_permissions');
|
||||
|
||||
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,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
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 addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addUserAuditColumns(pool, 'user_roles');
|
||||
|
||||
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 addUserAuditColumns(pool, 'auth_sessions');
|
||||
|
||||
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 = ""');
|
||||
|
||||
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 backfillLegacyRbacSchema(pool);
|
||||
|
||||
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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPool,
|
||||
ensureSchema,
|
||||
pruneStaleOnboardingDevices
|
||||
};
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
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) {
|
||||
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,
|
||||
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,
|
||||
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 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,
|
||||
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);
|
||||
|
||||
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,588 @@
|
||||
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 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 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: 'Add the current table columns and audit fields that define the released schema.',
|
||||
order: 20,
|
||||
up: async function (pool) {
|
||||
// v1.4.6: keep the current canvas_sizes shape 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');
|
||||
|
||||
// v1.4.6: playlists gained fade_between_slides plus audit fields.
|
||||
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 addAuditColumns(pool, 'playlists');
|
||||
|
||||
// v1.4.6: slide_templates now store canvas sizing and background metadata.
|
||||
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);
|
||||
|
||||
// v1.4.6: slide_template_regions gained font family and audit fields.
|
||||
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');
|
||||
|
||||
// v1.4.6: slides gained structured content and media fields.
|
||||
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 addAuditColumns(pool, 'slides');
|
||||
|
||||
// v1.4.6: playlist_slides gained scheduling fields and audit fields.
|
||||
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 addAuditColumns(pool, 'playlist_slides');
|
||||
|
||||
// v1.4.6: screens gained an optional playlist binding and audit fields.
|
||||
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');
|
||||
|
||||
// v1.4.6: RSS feeds now use a neutral interval value plus unit.
|
||||
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');
|
||||
|
||||
// v1.4.6: rss_feed_items stores normalized JSON snapshots.
|
||||
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);
|
||||
|
||||
// v1.4.6: API sources now track their latest response snapshot.
|
||||
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');
|
||||
|
||||
// v1.4.6: onboarding devices, users, roles, permissions, and link tables now carry audit fields.
|
||||
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: '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: '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) {
|
||||
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);
|
||||
await recordMigration(pool, migration);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appVersion: appVersion,
|
||||
compareVersions: compareVersions,
|
||||
runMigrations: runMigrations
|
||||
};
|
||||
+14
-8
@@ -5,20 +5,21 @@ const path = require('path');
|
||||
const common = require('./common');
|
||||
const { createPlayerRuntime } = require('./player/runtime');
|
||||
const { createPlayerPlaylistService } = require('./player/playlist');
|
||||
const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
|
||||
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
|
||||
const { createOnboardingStore } = require('./player/onboarding-store');
|
||||
const { createOnboardingStore } = require('./player/onboarding/store');
|
||||
const { registerPlayerRoutes } = require('./player/routes');
|
||||
const { pruneStaleOnboardingDevices } = require('./db');
|
||||
|
||||
|
||||
// Player runtime, upload API, and websocket wiring.
|
||||
// Player runtime, media API, and websocket wiring.
|
||||
async function start() {
|
||||
const app = express();
|
||||
const pool = common.createPool();
|
||||
const PORT = Number(process.env.PLAYER_PORT || 3001);
|
||||
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
||||
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
|
||||
const ONBOARDING_QUEUE_FILE = path.join(UPLOAD_DIR, 'player-onboarding-queue.json');
|
||||
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
|
||||
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
|
||||
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
|
||||
const playerRuntime = createPlayerRuntime({
|
||||
@@ -27,7 +28,11 @@ async function start() {
|
||||
});
|
||||
const playerPlaylistService = createPlayerPlaylistService({
|
||||
pool: pool,
|
||||
common: common
|
||||
common: common,
|
||||
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
|
||||
});
|
||||
const rtmpStreamService = createRtmpStreamService({
|
||||
mediaDir: MEDIA_DIR
|
||||
});
|
||||
const server = http.createServer(app);
|
||||
playerRuntime.installWebsocket(server);
|
||||
@@ -42,10 +47,11 @@ async function start() {
|
||||
registerPlayerRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
uploadDir: UPLOAD_DIR,
|
||||
mediaDir: MEDIA_DIR,
|
||||
assetDir: ASSET_DIR,
|
||||
playerRuntime: playerRuntime,
|
||||
playerPlaylistService: playerPlaylistService
|
||||
playerPlaylistService: playerPlaylistService,
|
||||
rtmpStreamService: rtmpStreamService
|
||||
});
|
||||
|
||||
app.use(function (error, _req, res, _next) {
|
||||
@@ -53,7 +59,7 @@ async function start() {
|
||||
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
|
||||
});
|
||||
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
|
||||
server.listen(PORT, function () {
|
||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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 sessions = new Map();
|
||||
|
||||
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 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);
|
||||
|
||||
if (sessions.has(key)) {
|
||||
return sessions.get(key);
|
||||
}
|
||||
|
||||
const directory = path.join(cacheRoot, key);
|
||||
const manifestPath = path.join(directory, 'index.m3u8');
|
||||
|
||||
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,
|
||||
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();
|
||||
}
|
||||
|
||||
function getSessionByKey(key) {
|
||||
return sessions.get(String(key || '').trim()) || null;
|
||||
}
|
||||
|
||||
async function getManifestFilePath(key) {
|
||||
const session = getSessionByKey(key);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
await session.ready.catch(function () {
|
||||
return false;
|
||||
});
|
||||
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,
|
||||
getManifestFilePath: getManifestFilePath,
|
||||
getSegmentFilePath: getSegmentFilePath
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createRtmpStreamService: createRtmpStreamService
|
||||
};
|
||||
@@ -1,5 +1,11 @@
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('../client-name-check');
|
||||
const { isTransientDbError } = require('./onboarding-store');
|
||||
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);
|
||||
}
|
||||
@@ -16,6 +22,50 @@ function getPublicBaseUrl(req) {
|
||||
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) {
|
||||
@@ -112,6 +162,23 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
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());
|
||||
@@ -122,7 +189,17 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/status', async function (req, res, next) {
|
||||
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({
|
||||
@@ -139,7 +216,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/onboarding/screens', async function (_req, res, next) {
|
||||
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 });
|
||||
@@ -164,11 +241,16 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/onboarding', async function (req, res, next) {
|
||||
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' });
|
||||
}
|
||||
+1
-1
@@ -85,4 +85,4 @@
|
||||
.catch(function (error) { setMessage(error && error.message ? error.message : "Unable to save onboarding."); });
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
</script>
|
||||
+1
-1
@@ -138,4 +138,4 @@
|
||||
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
</script>
|
||||
+71
-1225
File diff suppressed because it is too large
Load Diff
+150
-70
@@ -1,8 +1,11 @@
|
||||
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');
|
||||
@@ -11,81 +14,155 @@ function createPlayerPlaylistService(options) {
|
||||
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) {
|
||||
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: [] };
|
||||
}
|
||||
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) {
|
||||
return {
|
||||
screen: screen,
|
||||
playlist: null,
|
||||
slides: [],
|
||||
revision: getPlaylistRevision(screen, null, [], [], [])
|
||||
};
|
||||
}
|
||||
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, 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.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
|
||||
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 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;
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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) || {}
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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: screen, playlist: playlist, slides: slides, revision: revision };
|
||||
}
|
||||
|
||||
function updatePlaylistRevisionHash(hash, value) {
|
||||
@@ -93,7 +170,7 @@ function createPlayerPlaylistService(options) {
|
||||
hash.update('\0');
|
||||
}
|
||||
|
||||
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows) {
|
||||
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources) {
|
||||
const hash = crypto.createHash('sha1');
|
||||
|
||||
updatePlaylistRevisionHash(hash, screen && screen.id);
|
||||
@@ -149,6 +226,9 @@ function createPlayerPlaylistService(options) {
|
||||
updatePlaylistRevisionHash(hash, region.modified_at);
|
||||
});
|
||||
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
|
||||
@@ -382,6 +382,23 @@ body.screen-blackout #app {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-region.rtmp {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.template-region.rtmp video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.template-region-rtmp-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.template-region-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
if (!shouldFade) {
|
||||
app.innerHTML = markup;
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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,160 @@
|
||||
// Show or hide the offline status banner.
|
||||
function setOfflineBannerVisible(visible, message) {
|
||||
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.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 (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,190 @@
|
||||
// Render the slide at the requested index within the active set.
|
||||
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) {
|
||||
}
|
||||
|
||||
const slide = availableSlides[normalizedIndex];
|
||||
if (!slide) {
|
||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||
return false;
|
||||
}
|
||||
|
||||
index = normalizedIndex;
|
||||
var markup = buildSlideMarkup(slide);
|
||||
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
|
||||
sendCommandState(slide);
|
||||
if (!isPaused) {
|
||||
scheduleSlideAdvance(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;
|
||||
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 syncRtmpPreloads === 'function') {
|
||||
syncRtmpPreloads(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;
|
||||
}
|
||||
renderSlideAtIndex(activeSlides, index);
|
||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||
}
|
||||
|
||||
// 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(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);
|
||||
savePlaylistSnapshot({
|
||||
slides: nextSlides,
|
||||
signature: nextSignature,
|
||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||
etag: responseEtag
|
||||
});
|
||||
markRefreshHealthy();
|
||||
setOfflineBannerVisible(false);
|
||||
const currentActiveSlides = getCurrentActiveSlides();
|
||||
if (responseEtag) {
|
||||
currentPlaylistEtag = responseEtag;
|
||||
}
|
||||
if (!currentPlaylistSignature) {
|
||||
slides = nextSlides;
|
||||
currentPlaylistSignature = nextSignature;
|
||||
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
||||
index = 0;
|
||||
showCurrent();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
return;
|
||||
}
|
||||
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
|
||||
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
|
||||
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
|
||||
if (typeof syncRtmpPreloads === 'function') {
|
||||
syncRtmpPreloads(getActiveSlidesFrom(nextSlides), index);
|
||||
}
|
||||
if (currentActiveSlides.length < 2) {
|
||||
slides = nextSlides;
|
||||
currentPlaylistSignature = nextSignature;
|
||||
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
||||
pendingPlaylistUpdate = null;
|
||||
index = 0;
|
||||
showCurrent();
|
||||
sendCommandState(lastRenderedSlide);
|
||||
return;
|
||||
}
|
||||
pendingPlaylistUpdate = {
|
||||
slides: nextSlides,
|
||||
signature: nextSignature,
|
||||
fadeBetweenSlides: nextFadeBetweenSlides
|
||||
};
|
||||
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(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(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,537 @@
|
||||
// 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 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 === '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 ? 'background-color:' + escapeHtml(layout.backgroundColor || '#111111') + ';' : '';
|
||||
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);
|
||||
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media);
|
||||
}
|
||||
|
||||
// Render the shared slide shell around slide-specific inner content.
|
||||
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) {
|
||||
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
|
||||
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
|
||||
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';">' + 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 : '', viewportKey].join('|');
|
||||
}
|
||||
|
||||
// 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,141 @@
|
||||
const CACHE_VERSION = 'v1';
|
||||
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' });
|
||||
}
|
||||
|
||||
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(staleWhileRevalidate(request, MEDIA_CACHE));
|
||||
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,227 @@
|
||||
function renderRtmpRegion(region, regionContent) {
|
||||
var url = String(regionContent.value || '').trim();
|
||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||
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') + '" 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 rtmpPreloadSignature = '';
|
||||
|
||||
function getRtmpPreloadSlides(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 preloadSlides = [];
|
||||
var currentSlide = availableSlides[normalizedIndex];
|
||||
var nextSlide = availableSlides[normalizedIndex + 1];
|
||||
|
||||
if (currentSlide) {
|
||||
preloadSlides.push(currentSlide);
|
||||
}
|
||||
if (nextSlide && nextSlide !== currentSlide) {
|
||||
preloadSlides.push(nextSlide);
|
||||
}
|
||||
|
||||
return preloadSlides;
|
||||
}
|
||||
|
||||
function getRtmpPreloadEntries(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 = url + '\n' + (disableAudio ? '1' : '0');
|
||||
if (seen[key]) {
|
||||
return;
|
||||
}
|
||||
seen[key] = true;
|
||||
entries.push({
|
||||
url: url,
|
||||
disableAudio: disableAudio
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function syncRtmpPreloads(sourceSlides, targetIndex) {
|
||||
var entries = getRtmpPreloadEntries(getRtmpPreloadSlides(sourceSlides, targetIndex));
|
||||
var signature = entries.map(function (entry) {
|
||||
return entry.url + '\n' + (entry.disableAudio ? '1' : '0');
|
||||
}).join('\n');
|
||||
|
||||
if (signature === rtmpPreloadSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
rtmpPreloadSignature = signature;
|
||||
if (!entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach(function (entry) {
|
||||
fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).catch(function () {
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
video.dataset.rtmpInitialized = '1';
|
||||
video.muted = disableAudio;
|
||||
video.controls = false;
|
||||
video.playsInline = true;
|
||||
video.autoplay = true;
|
||||
|
||||
var markReady = function () {
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
var tryPlay = function () {
|
||||
if (video && typeof video.play === 'function') {
|
||||
video.play().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('canplay', markReady, { once: true });
|
||||
video.addEventListener('playing', markReady, { once: true });
|
||||
|
||||
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to initialize RTMP stream.');
|
||||
}
|
||||
return response.json();
|
||||
}).then(function (payload) {
|
||||
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
|
||||
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);
|
||||
});
|
||||
hls.on(window.Hls.Events.MANIFEST_PARSED, function () {
|
||||
tryPlay();
|
||||
});
|
||||
hls.on(window.Hls.Events.ERROR, function (_event, data) {
|
||||
if (data && data.fatal) {
|
||||
try {
|
||||
hls.destroy();
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
video.__rtmpHls = null;
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'flex';
|
||||
placeholder.textContent = 'RTMP playback failed.';
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = playlistUrl;
|
||||
tryPlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'flex';
|
||||
placeholder.textContent = 'RTMP playback is not supported in this browser.';
|
||||
}
|
||||
}).catch(function (_error) {
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'flex';
|
||||
placeholder.textContent = 'Unable to load RTMP stream.';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function destroyRtmpRegions(root) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var videos = root.querySelectorAll('video[data-rtmp-source]');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
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,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>';
|
||||
}
|
||||
@@ -278,12 +278,32 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
|
||||
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
|
||||
const playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html');
|
||||
const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js');
|
||||
const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playlist.js');
|
||||
const playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js');
|
||||
const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js');
|
||||
const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js');
|
||||
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
||||
const playerOnboardingLandingScriptPath = path.join(__dirname, 'player-onboarding-landing.script.html');
|
||||
const playerOnboardingFormScriptPath = path.join(__dirname, 'player-onboarding-form.script.html');
|
||||
const playerRegionScriptPaths = [
|
||||
path.join(__dirname, 'regions', 'image.js'),
|
||||
path.join(__dirname, 'regions', 'webpage.js'),
|
||||
path.join(__dirname, 'regions', 'html.js'),
|
||||
path.join(__dirname, 'regions', 'rtmp.js'),
|
||||
path.join(__dirname, 'regions', 'rss.js'),
|
||||
path.join(__dirname, 'regions', 'api.js'),
|
||||
path.join(__dirname, 'regions', 'text.js')
|
||||
];
|
||||
const playerOnboardingLandingScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-landing.script.html');
|
||||
const 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;
|
||||
|
||||
@@ -306,10 +326,48 @@ 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 = {}));
|
||||
}
|
||||
@@ -339,7 +397,13 @@ module.exports = {
|
||||
loadTemplate: loadTemplate,
|
||||
getPlayerPageTemplate: getPlayerPageTemplate,
|
||||
getPlayerClientNameScript: getPlayerClientNameScript,
|
||||
getPlayerPageOfflineScript: getPlayerPageOfflineScript,
|
||||
getPlayerPagePlaylistScript: getPlayerPagePlaylistScript,
|
||||
getPlayerPageCommandsScript: getPlayerPageCommandsScript,
|
||||
getPlayerPageRenderingScript: getPlayerPageRenderingScript,
|
||||
getPlayerPagePlaybackScript: getPlayerPagePlaybackScript,
|
||||
getPlayerPageScript: getPlayerPageScript,
|
||||
getPlayerRegionScripts: getPlayerRegionScripts,
|
||||
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
|
||||
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript
|
||||
};
|
||||
+31
-5
@@ -1,5 +1,6 @@
|
||||
const Handlebars = require('handlebars');
|
||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
|
||||
|
||||
function renderPage(template, options) {
|
||||
return template({
|
||||
@@ -10,6 +11,20 @@ function renderPage(template, options) {
|
||||
});
|
||||
}
|
||||
|
||||
function getPlayerServiceWorkerRegistrationScript() {
|
||||
return [
|
||||
'<script>',
|
||||
' if ("serviceWorker" in navigator) {',
|
||||
' window.addEventListener("load", function () {',
|
||||
' navigator.serviceWorker.register("/sw.js").catch(function () {',
|
||||
' return null;',
|
||||
' });',
|
||||
' });',
|
||||
' }',
|
||||
'</script>'
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderOnboardingLandingBody() {
|
||||
return [
|
||||
'<main class="onboarding-shell">',
|
||||
@@ -84,34 +99,45 @@ function renderOnboardingFormScript(deviceId) {
|
||||
|
||||
function renderPlayerPage(slug, initialData) {
|
||||
const onboardingScript = getPlayerClientNameScript()();
|
||||
const offlineScript = getPlayerPageOfflineScript()();
|
||||
const playlistScript = getPlayerPagePlaylistScript()();
|
||||
const commandScript = getPlayerPageCommandsScript()();
|
||||
const renderingScript = getPlayerPageRenderingScript()();
|
||||
const playbackScript = getPlayerPagePlaybackScript()();
|
||||
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript();
|
||||
const template = getPlayerPageTemplate();
|
||||
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
|
||||
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
|
||||
const script = getPlayerPageScript()({
|
||||
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null))
|
||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
|
||||
REGION_SCRIPTS: new Handlebars.SafeString(getPlayerRegionScripts())
|
||||
});
|
||||
|
||||
return renderPage(template, {
|
||||
title: 'Screen ' + slug,
|
||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||
script: onboardingScript + script
|
||||
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + '<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: renderOnboardingLandingScript()
|
||||
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: renderOnboardingFormScript(deviceId)
|
||||
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingFormScript(deviceId)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+181
-27
@@ -1,36 +1,111 @@
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
||||
|
||||
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 uploadDir = options && options.uploadDir ? options.uploadDir : null;
|
||||
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
|
||||
const assetDir = options && options.assetDir ? options.assetDir : null;
|
||||
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
|
||||
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
|
||||
|
||||
if (!app || !pool || !common || !uploadDir || !assetDir || !playerRuntime || !playerPlaylistService) {
|
||||
throw new Error('registerPlayerRoutes requires app, pool, common, uploadDir, assetDir, playerRuntime, and playerPlaylistService.');
|
||||
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();
|
||||
}
|
||||
|
||||
app.use('/assets', express.static(assetDir));
|
||||
app.use('/uploads', express.static(uploadDir));
|
||||
app.use('/media', express.static(mediaDir));
|
||||
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
|
||||
|
||||
app.get('/api/uploads/config', function (_req, res) {
|
||||
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({
|
||||
uploadDir: uploadDir
|
||||
mediaDir: mediaDir
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
|
||||
app.put('/api/media/:filename', requireRequestAuth, express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
|
||||
try {
|
||||
const filename = require('path').basename(String(req.params.filename || '').trim());
|
||||
if (!filename) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
const filePath = require('path').join(uploadDir, filename);
|
||||
const filePath = require('path').join(mediaDir, filename);
|
||||
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
||||
await fs.promises.mkdir(uploadDir, { recursive: true });
|
||||
await fs.promises.mkdir(mediaDir, { recursive: true });
|
||||
await fs.promises.writeFile(filePath, body);
|
||||
res.json({ ok: true, filename: filename });
|
||||
} catch (error) {
|
||||
@@ -38,13 +113,13 @@ function registerPlayerRoutes(app, options) {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/uploads/:filename', async function (req, res, next) {
|
||||
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const filename = require('path').basename(String(req.params.filename || '').trim());
|
||||
if (!filename) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
const filePath = require('path').join(uploadDir, filename);
|
||||
const filePath = require('path').join(mediaDir, filename);
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
@@ -58,6 +133,53 @@ function registerPlayerRoutes(app, options) {
|
||||
}
|
||||
});
|
||||
|
||||
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 session = await rtmpStreamService.ensureSession(source, useMutedOutput);
|
||||
await session.ready.catch(function () {
|
||||
return false;
|
||||
});
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.json({
|
||||
key: session.key,
|
||||
playlistUrl: session.playlistUrl,
|
||||
disableAudio: session.disableAudio,
|
||||
ready: 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');
|
||||
@@ -65,11 +187,12 @@ function registerPlayerRoutes(app, options) {
|
||||
res.send(common.renderPlayerPage(req.params.slug, data));
|
||||
}).catch(function (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal server error');
|
||||
res.set('X-Player-Offline', '1');
|
||||
res.send(common.renderPlayerPage(req.params.slug, null));
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/screens/:slug/playlist', async function (req, res, next) {
|
||||
app.get('/api/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);
|
||||
@@ -89,25 +212,33 @@ function registerPlayerRoutes(app, options) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) {
|
||||
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], requireRequestAuth, 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 = 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: screenRows[0],
|
||||
screen: screen,
|
||||
screenSlug: req.params.slug,
|
||||
count: connections.length,
|
||||
connections: connections
|
||||
connections: connections,
|
||||
degraded: screenLookupFailed
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/screens/:slug/commands', async function (req, res, next) {
|
||||
app.post('/api/screens/:slug/commands', requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
|
||||
@@ -121,15 +252,29 @@ function registerPlayerRoutes(app, options) {
|
||||
return res.status(400).json({ error: 'Unsupported command' });
|
||||
}
|
||||
|
||||
const liveConnections = playerRuntime.snapshotConnections(req.params.slug);
|
||||
const isRedirectCommand = command === 'redirect';
|
||||
let screenRows = [];
|
||||
let screen = null;
|
||||
let screenLookupFailed = false;
|
||||
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' });
|
||||
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;
|
||||
@@ -141,12 +286,21 @@ function registerPlayerRoutes(app, options) {
|
||||
? await playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
||||
: await playerRuntime.broadcastCommand(req.params.slug, commandPayload);
|
||||
|
||||
if (!screen && !screenLookupFailed && !liveConnections.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
|
||||
if (!screen && screenLookupFailed && !liveConnections.length) {
|
||||
return res.status(503).json({ error: 'Screen metadata unavailable while the database is down.' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
screen: screenRows[0] || null,
|
||||
screen: screen,
|
||||
screenSlug: req.params.slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
sent: sent
|
||||
sent: sent,
|
||||
degraded: screenLookupFailed
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
+18
-1
@@ -1,6 +1,7 @@
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { isClientNameAvailable } = require('../client-name-check');
|
||||
const { isClientNameAvailable } = require('../data/client-name-check');
|
||||
const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
||||
|
||||
function createPlayerRuntime(options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
@@ -239,6 +240,22 @@ function createPlayerRuntime(options) {
|
||||
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');
|
||||
|
||||
+34
@@ -79,6 +79,40 @@ const PERMISSION_SECTIONS = [
|
||||
{ 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 background tasks and scheduled refreshes.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
order: 80,
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
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 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 getRequestPath(req) {
|
||||
const explicitPath = String(req && req.path ? req.path : '').trim();
|
||||
if (explicitPath) {
|
||||
return explicitPath;
|
||||
}
|
||||
|
||||
const rawUrl = String(req && req.url ? req.url : '').trim();
|
||||
if (!rawUrl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(rawUrl, 'http://localhost').pathname;
|
||||
} catch (_error) {
|
||||
return 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 = String(options && options.pathname || '').trim();
|
||||
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(req.body)}`);
|
||||
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
|
||||
};
|
||||
+132
-26
@@ -6,20 +6,24 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const common = require('./common');
|
||||
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth');
|
||||
const pages = require('./web/routes');
|
||||
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 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 { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh');
|
||||
const { createWebBootstrap } = require('./web/bootstrap');
|
||||
const { requirePermission } = require('./rbac');
|
||||
const rbacData = require('./web/rbac-data');
|
||||
const { createPlayerActionService } = require('./web/player-actions');
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('./client-name-check');
|
||||
const { createSessionService } = require('./web/session');
|
||||
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 {
|
||||
formatDashboardDate,
|
||||
readArrayField,
|
||||
@@ -34,7 +38,7 @@ const {
|
||||
fetchScreensByTemplateId,
|
||||
fetchOrderedPlaylistSlides,
|
||||
redirectAfterSave
|
||||
} = require('./web/helpers');
|
||||
} = 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');
|
||||
@@ -47,8 +51,12 @@ async function start() {
|
||||
const server = http.createServer(app);
|
||||
const pool = common.createPool();
|
||||
const PORT = Number(process.env.WEB_PORT || 3000);
|
||||
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
|
||||
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||
const ASSET_DIR = path.join(__dirname, 'web', 'public');
|
||||
const backgroundTaskQueue = createBackgroundTaskQueue({
|
||||
pool: pool,
|
||||
maxConcurrent: 1
|
||||
});
|
||||
const playerActionService = createPlayerActionService({
|
||||
common: common,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
||||
@@ -75,10 +83,11 @@ async function start() {
|
||||
common: common,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
uploadDir: UPLOAD_DIR,
|
||||
uploadDir: MEDIA_DIR,
|
||||
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
notifyPlayerScreens: notifyPlayerScreens
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
});
|
||||
const upload = webBootstrap.upload;
|
||||
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
|
||||
@@ -86,6 +95,7 @@ async function start() {
|
||||
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
|
||||
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
|
||||
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
|
||||
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
|
||||
const broadcastDashboardState = webBootstrap.broadcastDashboardState;
|
||||
const sessionService = createSessionService({
|
||||
sessionCookieName: SESSION_COOKIE_NAME,
|
||||
@@ -100,10 +110,38 @@ async function start() {
|
||||
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.');
|
||||
});
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/assets', express.static(ASSET_DIR));
|
||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||
app.use('/media', express.static(MEDIA_DIR));
|
||||
|
||||
app.use(async function (req, _res, next) {
|
||||
try {
|
||||
@@ -114,7 +152,13 @@ async function start() {
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/admin', requireAuth);
|
||||
app.use(function (req, res, next) {
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout') {
|
||||
return next();
|
||||
}
|
||||
|
||||
return requireAuth(req, res, next);
|
||||
});
|
||||
|
||||
registerAuthRoutes(app, {
|
||||
pool: pool,
|
||||
@@ -149,6 +193,7 @@ async function start() {
|
||||
|
||||
registerAdminUsersRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
pages: pages,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
getAuditUserId: getAuditUserId,
|
||||
@@ -191,9 +236,10 @@ async function start() {
|
||||
requirePermission: requirePermission
|
||||
});
|
||||
|
||||
const registerAdminRbacRoutes = require('./web/routes/admin-rbac');
|
||||
const registerAdminRbacRoutes = require('./web/routes/admin/rbac');
|
||||
registerAdminRbacRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
pages: pages,
|
||||
getAuditUserId: getAuditUserId,
|
||||
rbacData: rbacData,
|
||||
@@ -208,7 +254,7 @@ async function start() {
|
||||
common: common,
|
||||
pages: pages,
|
||||
upload: upload,
|
||||
uploadDir: UPLOAD_DIR,
|
||||
uploadDir: MEDIA_DIR,
|
||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
@@ -225,6 +271,23 @@ async function start() {
|
||||
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 || '');
|
||||
@@ -239,7 +302,7 @@ async function start() {
|
||||
title: 'Not found',
|
||||
errorTitle: 'Oops! Page not found.',
|
||||
message: 'We could not find the page you were looking for.',
|
||||
backUrl: req.currentUser ? '/admin' : '/login',
|
||||
backUrl: req.currentUser ? '/dashboard' : '/login',
|
||||
backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in'
|
||||
}, req.currentUser));
|
||||
});
|
||||
@@ -247,7 +310,8 @@ async function start() {
|
||||
app.use(function (error, req, res, _next) {
|
||||
console.error(error);
|
||||
const statusCode = Number(error && (error.statusCode || error.status)) || 500;
|
||||
const wantsHtml = !String(req.originalUrl || '').startsWith('/api/') && (!req.accepts || req.accepts('html'));
|
||||
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;
|
||||
@@ -266,7 +330,7 @@ async function start() {
|
||||
errorTitle: title,
|
||||
message: message,
|
||||
detail: statusCode >= 500 ? 'The server could not complete the request.' : '',
|
||||
backUrl: req.currentUser ? '/admin' : '/login',
|
||||
backUrl: req.currentUser ? '/dashboard' : '/login',
|
||||
backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in'
|
||||
}, req.currentUser));
|
||||
}
|
||||
@@ -274,11 +338,53 @@ async function start() {
|
||||
res.status(statusCode).send(statusCode >= 500 ? 'Internal server error' : String(error && error.message ? error.message : 'Error'));
|
||||
});
|
||||
|
||||
// Ensure schema and mirror uploads before the web service starts handling traffic.
|
||||
// Ensure schema and mirror media before the web service starts handling traffic.
|
||||
await common.ensureSchema(pool);
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
syncExistingUploadsToPlayer(pool, UPLOAD_DIR).catch(function (error) {
|
||||
console.warn('Unable to sync existing uploads to player:', error);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await syncRecurringRefreshes();
|
||||
|
||||
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);
|
||||
|
||||
|
||||
Vendored
+18
-5
@@ -1,6 +1,7 @@
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { createDashboardStateService } = require('./dashboard-state');
|
||||
const { createUploadSyncService } = require('./upload-sync');
|
||||
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;
|
||||
@@ -11,6 +12,7 @@ function createWebBootstrap(options) {
|
||||
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.');
|
||||
@@ -51,7 +53,14 @@ function createWebBootstrap(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(getPlayerSnapshotSocketUrl(key));
|
||||
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) {
|
||||
@@ -99,10 +108,12 @@ function createWebBootstrap(options) {
|
||||
const buildDashboardState = dashboardStateService.buildDashboardState;
|
||||
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerSnapshotCache: playerSnapshotCache,
|
||||
notifyPlayerScreens: notifyPlayerScreens
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
});
|
||||
|
||||
const upload = uploadSyncService.createUploadMiddleware(uploadDir);
|
||||
@@ -111,6 +122,7 @@ function createWebBootstrap(options) {
|
||||
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
|
||||
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
||||
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
|
||||
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
||||
|
||||
async function sendDashboardStateToSocket(socket) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
@@ -151,7 +163,7 @@ function createWebBootstrap(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname !== '/ws/admin/dashboard') {
|
||||
if (pathname !== '/ws/dashboard') {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
@@ -200,6 +212,7 @@ function createWebBootstrap(options) {
|
||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
installDashboardWebsocket: installDashboardWebsocket
|
||||
};
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function toIsoDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function normalizeIntervalMs(value, unit) {
|
||||
const numericValue = Math.max(1, Number(value) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return numericValue * 1000;
|
||||
}
|
||||
return numericValue * 60 * 1000;
|
||||
}
|
||||
|
||||
function createBackgroundTaskQueue(options) {
|
||||
const pool = options && options.pool;
|
||||
const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1);
|
||||
const taskHandlers = new Map();
|
||||
const tasksById = new Map();
|
||||
const recurringJobsByKey = new Map();
|
||||
const taskIdToRecurringKey = new Map();
|
||||
const pendingIds = [];
|
||||
let nextTaskId = 1;
|
||||
let activeCount = 0;
|
||||
let drainScheduled = false;
|
||||
let initializationPromise = null;
|
||||
|
||||
function createTaskCompletionController() {
|
||||
let resolveCompletion = null;
|
||||
let rejectCompletion = null;
|
||||
const completionPromise = new Promise(function (resolve, reject) {
|
||||
resolveCompletion = resolve;
|
||||
rejectCompletion = reject;
|
||||
});
|
||||
|
||||
return {
|
||||
promise: completionPromise,
|
||||
resolve: resolveCompletion,
|
||||
reject: rejectCompletion
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonValue(value, fallback) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value));
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyJsonValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildSnapshot(task) {
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
taskType: task.taskType || '',
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
startedAt: task.startedAt,
|
||||
finishedAt: task.finishedAt,
|
||||
errorMessage: task.errorMessage,
|
||||
attempts: task.attempts || 0,
|
||||
metadata: task.metadata,
|
||||
payload: task.payload || null
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskById(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tasksById.get(numericTaskId) || null;
|
||||
}
|
||||
|
||||
function buildTaskFromRow(row) {
|
||||
const task = {
|
||||
id: Number(row.id),
|
||||
key: String(row.task_key || '').trim(),
|
||||
taskType: String(row.task_type || '').trim(),
|
||||
title: String(row.title || 'Background task').trim() || 'Background task',
|
||||
category: String(row.category || 'general').trim() || 'general',
|
||||
status: String(row.status || 'queued').trim() || 'queued',
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
|
||||
errorMessage: String(row.error_message || ''),
|
||||
attempts: Math.max(0, Number(row.attempts) || 0),
|
||||
metadata: parseJsonValue(row.metadata_json, {}),
|
||||
payload: parseJsonValue(row.payload_json, null),
|
||||
completionPromise: null,
|
||||
resolveCompletion: null,
|
||||
rejectCompletion: null,
|
||||
persisted: true,
|
||||
run: typeof row.run === 'function' ? row.run : function () {
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const completionController = createTaskCompletionController();
|
||||
task.completionPromise = completionController.promise;
|
||||
task.resolveCompletion = completionController.resolve;
|
||||
task.rejectCompletion = completionController.reject;
|
||||
return task;
|
||||
}
|
||||
|
||||
function buildTaskRecord(task) {
|
||||
return {
|
||||
task_key: task.key || null,
|
||||
task_type: task.taskType || 'general',
|
||||
title: task.title,
|
||||
category: task.category || 'general',
|
||||
status: task.status,
|
||||
payload_json: stringifyJsonValue(task.payload),
|
||||
metadata_json: stringifyJsonValue(task.metadata),
|
||||
attempts: Math.max(0, Number(task.attempts) || 0),
|
||||
created_at: task.createdAt ? new Date(task.createdAt) : new Date(),
|
||||
started_at: task.startedAt ? new Date(task.startedAt) : null,
|
||||
finished_at: task.finishedAt ? new Date(task.finishedAt) : null,
|
||||
error_message: task.errorMessage || null
|
||||
};
|
||||
}
|
||||
|
||||
async function persistTaskInsert(task) {
|
||||
if (!pool || !task.taskType) {
|
||||
return task;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO background_tasks (
|
||||
task_key,
|
||||
task_type,
|
||||
title,
|
||||
category,
|
||||
status,
|
||||
payload_json,
|
||||
metadata_json,
|
||||
attempts,
|
||||
created_at,
|
||||
started_at,
|
||||
finished_at,
|
||||
error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.created_at,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message
|
||||
]
|
||||
);
|
||||
|
||||
task.id = Number(result.insertId);
|
||||
task.persisted = true;
|
||||
return task;
|
||||
}
|
||||
|
||||
async function persistTaskUpdate(task) {
|
||||
if (!pool || !task.persisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
await pool.query(
|
||||
`UPDATE background_tasks
|
||||
SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message,
|
||||
task.id
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function persistTaskDelete(taskId) {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM background_tasks WHERE id = ?', [taskId]);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (initializationPromise) {
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
initializationPromise = (async function () {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM background_tasks ORDER BY id ASC'
|
||||
);
|
||||
|
||||
let highestTaskId = 0;
|
||||
for (const row of rows || []) {
|
||||
const task = buildTaskFromRow(row);
|
||||
if (!Number.isInteger(task.id) || task.id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
highestTaskId = Math.max(highestTaskId, task.id);
|
||||
tasksById.set(task.id, task);
|
||||
|
||||
if (task.status === 'running') {
|
||||
task.status = 'queued';
|
||||
task.startedAt = '';
|
||||
task.finishedAt = '';
|
||||
task.errorMessage = '';
|
||||
await pool.query(
|
||||
'UPDATE background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
|
||||
['queued', task.id]
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'queued' || task.status === 'running') {
|
||||
pendingIds.push(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
nextTaskId = Math.max(nextTaskId, highestTaskId + 1);
|
||||
if (pendingIds.length) {
|
||||
scheduleDrain();
|
||||
}
|
||||
})();
|
||||
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
function scheduleDrain() {
|
||||
if (drainScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
drainScheduled = true;
|
||||
setTimeout(function () {
|
||||
drainScheduled = false;
|
||||
processQueue();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function clearRecurringTimer(job) {
|
||||
if (job && job.timerId) {
|
||||
clearTimeout(job.timerId);
|
||||
job.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setTaskHandler(taskType, handler) {
|
||||
const normalizedTaskType = normalizeText(taskType);
|
||||
if (!normalizedTaskType || typeof handler !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
taskHandlers.set(normalizedTaskType, handler);
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleRecurringRun(job, delayMs) {
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0);
|
||||
job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay));
|
||||
job.timerId = setTimeout(function () {
|
||||
job.timerId = null;
|
||||
triggerRecurringJob(job.key);
|
||||
}, safeDelay);
|
||||
}
|
||||
|
||||
function triggerRecurringJob(recurringKey) {
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.activeTaskId && tasksById.has(job.activeTaskId)) {
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
return;
|
||||
}
|
||||
|
||||
job.activeTaskId = -1;
|
||||
enqueueTask({
|
||||
key: `${job.key}:${Date.now()}`,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
taskType: job.taskType || '',
|
||||
metadata: Object.assign({}, job.metadata || {}, {
|
||||
recurringKey: job.key,
|
||||
recurringTitle: job.title
|
||||
}),
|
||||
payload: Object.assign({}, job.payload || {}, {
|
||||
recurringKey: job.key
|
||||
}),
|
||||
run: job.run,
|
||||
persist: Boolean(job.taskType)
|
||||
}).then(function (task) {
|
||||
if (task && Number.isInteger(task.id)) {
|
||||
job.activeTaskId = task.id;
|
||||
taskIdToRecurringKey.set(task.id, job.key);
|
||||
} else {
|
||||
job.activeTaskId = null;
|
||||
}
|
||||
}).catch(function (error) {
|
||||
job.activeTaskId = null;
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
}
|
||||
|
||||
function syncRecurringTaskState(task, status, errorMessage) {
|
||||
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
|
||||
if (!recurringKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job) {
|
||||
taskIdToRecurringKey.delete(task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
job.activeTaskId = null;
|
||||
job.lastRunAt = toIsoDate(new Date());
|
||||
job.lastStatus = status;
|
||||
job.lastError = errorMessage ? String(errorMessage) : '';
|
||||
taskIdToRecurringKey.delete(task.id);
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
while (activeCount < maxConcurrent) {
|
||||
const nextTaskId = pendingIds.shift();
|
||||
if (!nextTaskId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const task = tasksById.get(nextTaskId);
|
||||
if (!task || task.status !== 'queued') {
|
||||
continue;
|
||||
}
|
||||
|
||||
activeCount += 1;
|
||||
task.status = 'running';
|
||||
task.startedAt = toIsoDate(new Date());
|
||||
task.errorMessage = '';
|
||||
task.attempts = Math.max(0, Number(task.attempts) || 0) + 1;
|
||||
|
||||
try {
|
||||
await persistTaskUpdate(task);
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Unable to update task state.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const completionError = new Error(task.errorMessage);
|
||||
completionError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(completionError);
|
||||
}
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
continue;
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(function () {
|
||||
if (task.taskType) {
|
||||
const handler = taskHandlers.get(task.taskType);
|
||||
if (!handler) {
|
||||
throw new Error('No handler registered for task type ' + task.taskType + '.');
|
||||
}
|
||||
|
||||
return handler({
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
taskType: task.taskType,
|
||||
payload: task.payload,
|
||||
metadata: task.metadata,
|
||||
attempts: task.attempts
|
||||
});
|
||||
}
|
||||
|
||||
return task.run({
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
metadata: task.metadata
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
task.status = 'completed';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.resolveCompletion === 'function') {
|
||||
task.resolveCompletion(buildSnapshot(task));
|
||||
}
|
||||
syncRecurringTaskState(task, task.status, '');
|
||||
return persistTaskUpdate(task);
|
||||
})
|
||||
.catch(function (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Background task failed.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const completionError = new Error(task.errorMessage);
|
||||
completionError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(completionError);
|
||||
}
|
||||
syncRecurringTaskState(task, task.status, task.errorMessage);
|
||||
return persistTaskUpdate(task);
|
||||
})
|
||||
.finally(function () {
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
const normalizedTitle = normalizeText(definition && definition.title) || 'Background task';
|
||||
const normalizedTaskType = normalizeText(definition && definition.taskType);
|
||||
const shouldPersist = Boolean((definition && definition.persist) || (pool && normalizedTaskType));
|
||||
const existingTask = normalizedKey
|
||||
? Array.from(tasksById.values()).find(function (task) {
|
||||
return task.key === normalizedKey && task.status === 'queued' && (!normalizedTaskType || task.taskType === normalizedTaskType);
|
||||
})
|
||||
: null;
|
||||
|
||||
if (existingTask) {
|
||||
existingTask.title = normalizedTitle;
|
||||
existingTask.category = normalizeText(definition && definition.category) || existingTask.category || 'general';
|
||||
existingTask.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
existingTask.taskType = normalizedTaskType || existingTask.taskType || '';
|
||||
existingTask.payload = definition && definition.payload !== undefined ? definition.payload : existingTask.payload;
|
||||
existingTask.run = typeof definition.run === 'function' ? definition.run : existingTask.run;
|
||||
existingTask.createdAt = toIsoDate(new Date());
|
||||
existingTask.errorMessage = '';
|
||||
existingTask.persisted = existingTask.persisted || shouldPersist;
|
||||
await persistTaskUpdate(existingTask);
|
||||
return buildSnapshot(existingTask);
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: shouldPersist ? 0 : nextTaskId,
|
||||
key: normalizedKey,
|
||||
taskType: normalizedTaskType,
|
||||
title: normalizedTitle,
|
||||
category: normalizeText(definition && definition.category) || 'general',
|
||||
status: 'queued',
|
||||
createdAt: toIsoDate(new Date()),
|
||||
startedAt: '',
|
||||
finishedAt: '',
|
||||
errorMessage: '',
|
||||
attempts: 0,
|
||||
metadata: definition && definition.metadata ? definition.metadata : {},
|
||||
payload: definition && definition.payload !== undefined ? definition.payload : null,
|
||||
persisted: shouldPersist,
|
||||
run: typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
},
|
||||
completionPromise: null,
|
||||
resolveCompletion: null,
|
||||
rejectCompletion: null
|
||||
};
|
||||
|
||||
const completionController = createTaskCompletionController();
|
||||
task.completionPromise = completionController.promise;
|
||||
task.resolveCompletion = completionController.resolve;
|
||||
task.rejectCompletion = completionController.reject;
|
||||
|
||||
if (shouldPersist) {
|
||||
await persistTaskInsert(task);
|
||||
} else {
|
||||
nextTaskId += 1;
|
||||
}
|
||||
|
||||
tasksById.set(task.id, task);
|
||||
pendingIds.push(task.id);
|
||||
scheduleDrain();
|
||||
return buildSnapshot(task);
|
||||
}
|
||||
|
||||
function registerRecurringTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
if (!normalizedKey) {
|
||||
throw new Error('Recurring tasks require a key.');
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0);
|
||||
if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
|
||||
throw new Error('Recurring tasks require a valid interval.');
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(normalizedKey) || {
|
||||
key: normalizedKey,
|
||||
activeTaskId: null,
|
||||
lastRunAt: '',
|
||||
lastStatus: '',
|
||||
lastError: '',
|
||||
nextRunAt: '',
|
||||
timerId: null,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
clearRecurringTimer(job);
|
||||
job.title = normalizeText(definition && definition.title) || 'Background task';
|
||||
job.category = normalizeText(definition && definition.category) || 'general';
|
||||
job.intervalMs = intervalMs;
|
||||
job.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
job.run = typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
};
|
||||
job.enabled = definition && definition.enabled === false ? false : true;
|
||||
recurringJobsByKey.set(normalizedKey, job);
|
||||
|
||||
if (job.enabled) {
|
||||
scheduleRecurringRun(job, intervalMs);
|
||||
}
|
||||
|
||||
return buildRecurringSnapshot(job);
|
||||
}
|
||||
|
||||
function removeRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
const job = recurringJobsByKey.get(normalizedKey);
|
||||
if (!job) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
recurringJobsByKey.delete(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecurringSnapshot(job) {
|
||||
return {
|
||||
key: job.key,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
intervalMs: job.intervalMs,
|
||||
enabled: job.enabled !== false,
|
||||
activeTaskId: job.activeTaskId || null,
|
||||
createdAt: job.createdAt || '',
|
||||
nextRunAt: job.nextRunAt || '',
|
||||
lastRunAt: job.lastRunAt || '',
|
||||
lastStatus: job.lastStatus || '',
|
||||
lastError: job.lastError || '',
|
||||
metadata: job.metadata || {}
|
||||
};
|
||||
}
|
||||
|
||||
function listTasks() {
|
||||
return Array.from(tasksById.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
const statusRank = {
|
||||
running: 0,
|
||||
queued: 1,
|
||||
failed: 2,
|
||||
completed: 3,
|
||||
canceled: 4
|
||||
};
|
||||
|
||||
const leftRank = Object.prototype.hasOwnProperty.call(statusRank, left.status) ? statusRank[left.status] : 9;
|
||||
const rightRank = Object.prototype.hasOwnProperty.call(statusRank, right.status) ? statusRank[right.status] : 9;
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank - rightRank;
|
||||
}
|
||||
|
||||
return right.id - left.id;
|
||||
})
|
||||
.map(buildSnapshot);
|
||||
}
|
||||
|
||||
function listRecurringTasks() {
|
||||
return Array.from(recurringJobsByKey.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
return left.key.localeCompare(right.key);
|
||||
})
|
||||
.map(buildRecurringSnapshot);
|
||||
}
|
||||
|
||||
function clearFinishedTasks() {
|
||||
let removedCount = 0;
|
||||
Array.from(tasksById.values()).forEach(function (task) {
|
||||
if (task.status === 'running' || task.status === 'queued') {
|
||||
return;
|
||||
}
|
||||
tasksById.delete(task.id);
|
||||
removedCount += 1;
|
||||
persistTaskDelete(task.id).catch(function (error) {
|
||||
console.warn('Unable to delete finished task from persistence:', error);
|
||||
});
|
||||
});
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
function cancelTask(taskId) {
|
||||
const task = getTaskById(taskId);
|
||||
if (!task || task.status !== 'queued') {
|
||||
return false;
|
||||
}
|
||||
|
||||
task.status = 'canceled';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
const pendingIndex = pendingIds.indexOf(task.id);
|
||||
if (pendingIndex >= 0) {
|
||||
pendingIds.splice(pendingIndex, 1);
|
||||
}
|
||||
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const cancellationError = new Error('Task canceled.');
|
||||
cancellationError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(cancellationError);
|
||||
}
|
||||
|
||||
persistTaskUpdate(task).catch(function (error) {
|
||||
console.warn('Unable to persist canceled task:', error);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function retryTask(taskId) {
|
||||
const task = getTaskById(taskId);
|
||||
if (!task || task.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
metadata: task.metadata,
|
||||
run: task.run
|
||||
});
|
||||
}
|
||||
|
||||
function getSummary() {
|
||||
const counts = {
|
||||
queued: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
canceled: 0
|
||||
};
|
||||
|
||||
listTasks().forEach(function (task) {
|
||||
if (Object.prototype.hasOwnProperty.call(counts, task.status)) {
|
||||
counts[task.status] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activeCount: activeCount,
|
||||
counts: counts,
|
||||
scheduledCount: recurringJobsByKey.size,
|
||||
total: listTasks().length
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueTask: enqueueTask,
|
||||
enqueueTaskAndWait: function (definition) {
|
||||
return enqueueTask(definition).then(function (snapshot) {
|
||||
const task = snapshot && snapshot.id ? getTaskById(snapshot.id) : null;
|
||||
if (!task || !task.completionPromise) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
return task.completionPromise;
|
||||
});
|
||||
},
|
||||
initialize: initialize,
|
||||
setTaskHandler: setTaskHandler,
|
||||
registerRecurringTask: registerRecurringTask,
|
||||
removeRecurringTask: removeRecurringTask,
|
||||
listTasks: listTasks,
|
||||
listRecurringTasks: listRecurringTasks,
|
||||
getTaskById: getTaskById,
|
||||
getSummary: getSummary,
|
||||
cancelTask: cancelTask,
|
||||
retryTask: retryTask,
|
||||
clearFinishedTasks: clearFinishedTasks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackgroundTaskQueue: createBackgroundTaskQueue,
|
||||
normalizeIntervalMs: normalizeIntervalMs
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
function createPlayerActionService(options) {
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const common = options && options.common;
|
||||
@@ -13,11 +15,17 @@ function createPlayerActionService(options) {
|
||||
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'
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
@@ -35,10 +43,15 @@ function createPlayerActionService(options) {
|
||||
}
|
||||
|
||||
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'
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('../rbac');
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
|
||||
|
||||
function parseCsvIds(value) {
|
||||
return String(value || '')
|
||||
@@ -1,4 +1,4 @@
|
||||
const { normalizePermissionKeys } = require('../rbac');
|
||||
const { normalizePermissionKeys } = require('../../rbac');
|
||||
|
||||
function createSessionService(options) {
|
||||
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
||||
@@ -2,16 +2,19 @@ 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;
|
||||
|
||||
let playerUploadSyncMode = null;
|
||||
let playerUploadSyncModePromise = null;
|
||||
@@ -42,7 +45,7 @@ function createUploadSyncService(options) {
|
||||
|
||||
function normalizeUploadReference(uploadPath) {
|
||||
const value = String(uploadPath || '').trim();
|
||||
if (!value || !value.startsWith('/uploads/')) {
|
||||
if (!value || !value.startsWith('/media/')) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
@@ -157,16 +160,21 @@ function createUploadSyncService(options) {
|
||||
|
||||
playerUploadSyncModePromise = (async function () {
|
||||
try {
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/uploads/config`, {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: '/api/media/config'
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/config`, {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
const playerUploadDir = data && data.uploadDir ? normalizeUploadRoot(data.uploadDir) : null;
|
||||
const playerUploadDir = data && data.mediaDir ? normalizeUploadRoot(data.mediaDir) : null;
|
||||
if (!playerUploadDir) {
|
||||
return null;
|
||||
}
|
||||
@@ -237,10 +245,16 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/uploads/${encodeURIComponent(filename)}`, {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'PUT',
|
||||
pathname: `/api/media/${encodeURIComponent(filename)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
'Content-Type': 'application/octet-stream',
|
||||
...authHeaders
|
||||
},
|
||||
body: fileBuffer
|
||||
});
|
||||
@@ -262,10 +276,15 @@ function createUploadSyncService(options) {
|
||||
|
||||
const filename = path.basename(uploadPath);
|
||||
try {
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/uploads/${encodeURIComponent(filename)}`, {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(filename)}`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
@@ -298,30 +317,10 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
|
||||
async function syncExistingUploadsToPlayer(pool, localUploadDir) {
|
||||
if (!(await shouldMirrorUploads(localUploadDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const uploadRefs = new Set();
|
||||
(data.slides || []).forEach(function (slide) {
|
||||
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
|
||||
uploadRefs.add(reference);
|
||||
});
|
||||
return queueMediaSyncTask('media-sync:initial', 'Initial media sync', {
|
||||
mode: 'initial',
|
||||
uploadDir: localUploadDir
|
||||
});
|
||||
(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: localUploadDir
|
||||
});
|
||||
});
|
||||
await flushPendingPlayerUploadSyncs();
|
||||
}
|
||||
|
||||
function getVisibleCurrentSlideIds() {
|
||||
@@ -436,30 +435,10 @@ function createUploadSyncService(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.previousUploadRefs.length) {
|
||||
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||
await removeUnusedUploadFiles(operation.pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
||||
return !nextUploadRefSet.has(reference);
|
||||
}));
|
||||
}
|
||||
|
||||
if (operation.nextUploadRefs.length) {
|
||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||
}
|
||||
|
||||
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 queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
|
||||
mode: 'playlist',
|
||||
operation: operation
|
||||
});
|
||||
}
|
||||
|
||||
async function flushPendingPlaylistUploadSyncs() {
|
||||
@@ -529,6 +508,96 @@ function createUploadSyncService(options) {
|
||||
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.previousUploadRefs.length) {
|
||||
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
||||
return !nextUploadRefSet.has(reference);
|
||||
}));
|
||||
}
|
||||
|
||||
if (operation.nextUploadRefs.length) {
|
||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -554,7 +623,9 @@ function createUploadSyncService(options) {
|
||||
schedulePendingPlaylistUploadSyncFlush: schedulePendingPlaylistUploadSyncFlush,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
|
||||
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs
|
||||
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
queueMediaSyncTask: queueMediaSyncTask
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,41 @@
|
||||
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')),
|
||||
renderErrorPage: require('./error'),
|
||||
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
|
||||
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
|
||||
renderRbacEditPage: require(routePath('settings', 'rbac', 'edit'))
|
||||
};
|
||||
@@ -527,6 +527,14 @@ td[data-label="Slides"] {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-toolbar .ck-font-size-input,
|
||||
.template-field-card .ck.ck-toolbar .ck-font-size-input {
|
||||
flex: 0 0 5% !important;
|
||||
width: 5% !important;
|
||||
max-width: 5% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-dropdown__panel,
|
||||
.slide-form-stack .ck.ck-list__panel,
|
||||
.slide-form-stack .ck.ck-balloon-panel,
|
||||
@@ -597,10 +605,14 @@ td[data-label="Slides"] {
|
||||
box-shadow: inset 0 0 0 1px var(--bs-primary), 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.18);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck-editor__editable_inline,
|
||||
.template-field-card .ck-editor__editable_inline {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.slide-form-stack .ck-content,
|
||||
.template-field-card .ck-content {
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@@ -624,6 +636,50 @@ td[data-label="Slides"] {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__panel,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-balloon-panel {
|
||||
background: var(--bs-body-bg) !important;
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__item .ck-button,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__item .ck-button__label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button__label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-button__label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-list__item-text,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-list__item-label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-list-item-button {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button:hover {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
|
||||
.slide-preview-dimensions-chip {
|
||||
display: inline-flex;
|
||||
@@ -1035,10 +1091,6 @@ td[data-label="Slides"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-field-card .ck-editor__editable_inline {
|
||||
min-height: 18rem;
|
||||
}
|
||||
|
||||
.template-field-card.is-expanded .ck-editor__editable_inline {
|
||||
min-height: 32rem;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,8 @@
|
||||
}
|
||||
|
||||
function initAsyncSaveForms() {
|
||||
var refreshSequence = 0;
|
||||
|
||||
function setSaveActionValue(form, value) {
|
||||
if (!form) {
|
||||
return;
|
||||
@@ -212,6 +214,123 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -288,9 +407,21 @@
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
throw new Error(text || 'Unable to save changes.');
|
||||
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)
|
||||
@@ -298,21 +429,45 @@
|
||||
window.location.replace(redirectUrl);
|
||||
return;
|
||||
}
|
||||
if (shouldFollowRedirect && response.url) {
|
||||
clearFormDirty(form);
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
clearFormDirty(form);
|
||||
return response.text().then(function (text) {
|
||||
var savedMessage = '';
|
||||
var responseDocument = null;
|
||||
try {
|
||||
var doc = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var toastBody = doc.querySelector('.toast-body');
|
||||
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;
|
||||
@@ -336,6 +491,92 @@
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
if (window.initSortableTables) {
|
||||
window.initSortableTables();
|
||||
}
|
||||
@@ -346,4 +587,8 @@
|
||||
initAsyncCommandForms();
|
||||
initAsyncSaveForms();
|
||||
initSubmitOnChange();
|
||||
initJsonTogglePanels();
|
||||
initLocalDateTimes();
|
||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||
window.initLocalDateTimes = initLocalDateTimes;
|
||||
}());
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
(function () {
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function getClientDisplayName(client) {
|
||||
if (client && client.client_name) {
|
||||
return String(client.client_name).trim();
|
||||
}
|
||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.classList) {
|
||||
classesToRemove.forEach(function (className) {
|
||||
button.classList.remove(className);
|
||||
});
|
||||
if (classToAdd) {
|
||||
button.classList.add(classToAdd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var className = String(button.className || '');
|
||||
classesToRemove.forEach(function (removeClass) {
|
||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||
});
|
||||
if (classToAdd) {
|
||||
className += ' ' + classToAdd;
|
||||
}
|
||||
button.className = className.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeDisplayIp(value) {
|
||||
var ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||
return ip.slice(7).trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
if (window.initSortableTables) {
|
||||
window.initSortableTables();
|
||||
}
|
||||
|
||||
initConfirmForms();
|
||||
initDirtyTracking();
|
||||
initCancelConfirm();
|
||||
initAsyncCommandForms();
|
||||
initAsyncSaveForms();
|
||||
initSubmitOnChange();
|
||||
initJsonTogglePanels();
|
||||
initLocalDateTimes();
|
||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||
window.initLocalDateTimes = initLocalDateTimes;
|
||||
}());
|
||||
@@ -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();
|
||||
}());
|
||||
@@ -91,7 +91,7 @@
|
||||
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="/admin/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="/admin/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="/admin/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="/admin/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="/admin/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>';
|
||||
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) {
|
||||
@@ -119,7 +119,7 @@
|
||||
if (connectionInput) {
|
||||
connectionInput.value = client.id || '';
|
||||
}
|
||||
pauseForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
pauseForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
var reloadButton = cell.querySelector('button[data-action="reload"]');
|
||||
@@ -132,7 +132,7 @@
|
||||
if (reloadInput) {
|
||||
reloadInput.value = client.id || '';
|
||||
}
|
||||
reloadForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
reloadForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?');
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@
|
||||
if (blackoutConnectionInput) {
|
||||
blackoutConnectionInput.value = client.id || '';
|
||||
}
|
||||
blackoutForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
blackoutForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
var previousButton = cell.querySelector('button[data-action="previous"]');
|
||||
@@ -182,7 +182,7 @@
|
||||
if (previousConnectionInput) {
|
||||
previousConnectionInput.value = client.id || '';
|
||||
}
|
||||
previousForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
previousForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
var nextButton = cell.querySelector('button[data-action="next"]');
|
||||
@@ -202,7 +202,7 @@
|
||||
if (nextConnectionInput) {
|
||||
nextConnectionInput.value = client.id || '';
|
||||
}
|
||||
nextForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
nextForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +416,7 @@
|
||||
body.append('deviceId', String(deviceId || '').trim());
|
||||
body.append('clientName', String(clientName || '').trim());
|
||||
|
||||
return fetch('/admin/clients/' + encodeURIComponent(String(screenSlug || '').trim()) + '/commands', {
|
||||
return fetch('/clients/' + encodeURIComponent(String(screenSlug || '').trim()) + '/commands', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
@@ -495,4 +495,4 @@
|
||||
window.webHandleDashboardState = handleDashboardState;
|
||||
|
||||
initClientRenameHandler();
|
||||
}());
|
||||
}());
|
||||
|
||||
@@ -553,7 +553,7 @@
|
||||
'</td>' +
|
||||
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" /></td>' +
|
||||
'<td><div class="actions playlist-item-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/admin/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
||||
'</div></td>';
|
||||
return row;
|
||||
@@ -655,3 +655,4 @@
|
||||
initPlaylistScheduleForm();
|
||||
initPlaylistEditStaging();
|
||||
}());
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
(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') {
|
||||
return;
|
||||
}
|
||||
|
||||
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 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();
|
||||
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
|
||||
}());
|
||||
+183
-33
@@ -3,6 +3,7 @@ import {
|
||||
BlockQuote,
|
||||
Bold,
|
||||
ClassicEditor,
|
||||
Base64UploadAdapter,
|
||||
ClipboardPipeline,
|
||||
Essentials,
|
||||
FontBackgroundColor,
|
||||
@@ -11,6 +12,20 @@ import {
|
||||
FontSize,
|
||||
Heading,
|
||||
HorizontalLine,
|
||||
Image,
|
||||
ImageBlockEditing,
|
||||
ImageCaption,
|
||||
ImageInlineEditing,
|
||||
ImageInsert,
|
||||
ImageInsertUI,
|
||||
ImageInsertViaUrl,
|
||||
ImageInsertViaUrlUI,
|
||||
ImageResize,
|
||||
ImageResizeEditing,
|
||||
ImageResizeHandles,
|
||||
ImageStyle,
|
||||
ImageTextAlternative,
|
||||
ImageUpload,
|
||||
Indent,
|
||||
IndentBlock,
|
||||
Italic,
|
||||
@@ -25,6 +40,8 @@ import {
|
||||
import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js';
|
||||
|
||||
(function () {
|
||||
var DEFAULT_FONT_SIZE = 32;
|
||||
|
||||
var dataElement = document.getElementById('slide-editor-data');
|
||||
if (!dataElement) {
|
||||
return;
|
||||
@@ -94,7 +111,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
function getCurrentFontSizeValue() {
|
||||
var value = fontSizeCommand.value;
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
return String(DEFAULT_FONT_SIZE);
|
||||
}
|
||||
|
||||
var parsed = Math.round(Number(String(value).replace(/[^0-9.]/g, '')));
|
||||
@@ -118,6 +135,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
view.element.inputMode = 'numeric';
|
||||
view.element.autocomplete = 'off';
|
||||
view.element.spellcheck = false;
|
||||
view.element.style.width = '3.5em';
|
||||
syncValue();
|
||||
});
|
||||
|
||||
@@ -170,6 +188,10 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
var clipboardPipeline = editor.plugins.get(ClipboardPipeline);
|
||||
|
||||
this.listenTo(clipboardPipeline, 'inputTransformation', function (_event, data) {
|
||||
if (clipboardContainsImageContent(data.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var plainText = data.dataTransfer.getData('text/plain');
|
||||
data.content = editor.data.processor.toView(plainTextToHtml(plainText));
|
||||
}, { priority: 'high' });
|
||||
@@ -211,6 +233,22 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return paragraphs.length ? paragraphs.join('') : '<p></p>';
|
||||
}
|
||||
|
||||
function clipboardContainsImageContent(dataTransfer) {
|
||||
if (!dataTransfer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var files = Array.from(dataTransfer.files || []);
|
||||
if (files.some(function (file) {
|
||||
return file && String(file.type || '').indexOf('image/') === 0;
|
||||
})) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var html = dataTransfer.getData('text/html') || '';
|
||||
return /<img\b|<figure\b[^>]*\bclass=["'][^"']*\bimage\b/i.test(html);
|
||||
}
|
||||
|
||||
function getTemplateById(id) {
|
||||
return templates.find(function (item) { return Number(item.id) === Number(id); }) || null;
|
||||
}
|
||||
@@ -240,30 +278,108 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
}
|
||||
|
||||
function sanitizePreviewHtml(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, function (tag) {
|
||||
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
var container = document.createElement('div');
|
||||
container.innerHTML = String(html || '');
|
||||
return sanitizePreviewNode(container);
|
||||
}
|
||||
|
||||
function sanitizePreviewNode(node) {
|
||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
var output = '';
|
||||
|
||||
Array.from(node.childNodes || []).forEach(function (child) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
output += escapeHtml(child.textContent);
|
||||
return;
|
||||
}
|
||||
var closing = Boolean(match[1]);
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var attrText = String(match[3] || '');
|
||||
var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr';
|
||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
var name = String(child.tagName || '').toLowerCase();
|
||||
if (allowed.indexOf(name) === -1) {
|
||||
return '';
|
||||
return;
|
||||
}
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
|
||||
if (name === 'br' || name === 'hr') {
|
||||
output += '<' + name + '>';
|
||||
return;
|
||||
}
|
||||
if (selfClosing) {
|
||||
return '<' + name + sanitizeTagAttributes(name, attrText) + '>';
|
||||
|
||||
var attrs = sanitizePreviewElementAttributes(child, name);
|
||||
if (name === 'img') {
|
||||
output += '<img' + attrs + '>';
|
||||
return;
|
||||
}
|
||||
return '<' + name + sanitizeTagAttributes(name, attrText) + '>';
|
||||
|
||||
output += '<' + name + attrs + '>' + sanitizePreviewNode(child) + '</' + name + '>';
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function sanitizePreviewElementAttributes(element, tagName) {
|
||||
var allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
img: ['alt', 'class', 'decoding', 'height', 'loading', 'src', 'style', 'title', 'width'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
var allowed = allowedAttributes[tagName] || [];
|
||||
var attrs = [];
|
||||
|
||||
Array.from(element.attributes || []).forEach(function (attribute) {
|
||||
var lowerKey = String(attribute.name || '').toLowerCase();
|
||||
if (allowed.indexOf(lowerKey) === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
var value = String(attribute.value || '');
|
||||
if (tagName === 'img' && lowerKey === 'src') {
|
||||
value = sanitizeImageSrc(value);
|
||||
}
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(value)) {
|
||||
return;
|
||||
}
|
||||
if (tagName === 'img' && lowerKey === 'src' && !value) {
|
||||
return;
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(value)) {
|
||||
return;
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
var targetValue = value.trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizeTagAttributes(tagName, attrText) {
|
||||
@@ -279,6 +395,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
img: ['alt', 'class', 'decoding', 'height', 'loading', 'src', 'style', 'title', 'width'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
@@ -302,9 +419,15 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return '';
|
||||
}
|
||||
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (tagName === 'img' && lowerKey === 'src') {
|
||||
value = sanitizeImageSrc(value);
|
||||
}
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (tagName === 'img' && lowerKey === 'src' && !value) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
@@ -325,6 +448,23 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizeImageSrc(value) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^(?:https?:|\/|\.\.?\/|\/\/)/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
if (/^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderPreviewListItem(item, tag) {
|
||||
if (item && typeof item === 'object') {
|
||||
var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||
@@ -442,7 +582,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
var current = existingContent[region.region_key] || {};
|
||||
return {
|
||||
font_family: String(current.font_family || region.font_family || 'Arial').trim() || 'Arial',
|
||||
font_size: Math.max(8, Number(current.font_size || 24)),
|
||||
font_size: Math.max(8, Number(current.font_size || DEFAULT_FONT_SIZE)),
|
||||
font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000'
|
||||
};
|
||||
}
|
||||
@@ -869,8 +1009,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'undo',
|
||||
'redo',
|
||||
'|',
|
||||
'heading',
|
||||
'|',
|
||||
'fontSizeInput',
|
||||
'fontFamily',
|
||||
'fontColor',
|
||||
@@ -889,12 +1027,20 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'alignment',
|
||||
'|',
|
||||
'outdent',
|
||||
'indent'
|
||||
'indent',
|
||||
'|',
|
||||
'imageInsert',
|
||||
'imageInsertViaUrl',
|
||||
'imageStyle:block',
|
||||
'imageStyle:inline',
|
||||
'imageTextAlternative',
|
||||
'imageResize'
|
||||
],
|
||||
shouldNotGroupWhenFull: false
|
||||
},
|
||||
plugins: [
|
||||
Alignment,
|
||||
Base64UploadAdapter,
|
||||
BlockQuote,
|
||||
Bold,
|
||||
Essentials,
|
||||
@@ -904,11 +1050,24 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
FontFamily,
|
||||
FontSize,
|
||||
FontSizeInputUI,
|
||||
Heading,
|
||||
HorizontalLine,
|
||||
Indent,
|
||||
IndentBlock,
|
||||
Italic,
|
||||
Image,
|
||||
ImageBlockEditing,
|
||||
ImageCaption,
|
||||
ImageInlineEditing,
|
||||
ImageInsert,
|
||||
ImageInsertUI,
|
||||
ImageInsertViaUrl,
|
||||
ImageInsertViaUrlUI,
|
||||
ImageResize,
|
||||
ImageResizeEditing,
|
||||
ImageResizeHandles,
|
||||
ImageStyle,
|
||||
ImageTextAlternative,
|
||||
ImageUpload,
|
||||
Paragraph,
|
||||
Strikethrough,
|
||||
Subscript,
|
||||
@@ -922,15 +1081,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
options: [20, 24, 28, 32, 36, 40, 44],
|
||||
supportAllValues: true
|
||||
},
|
||||
heading: {
|
||||
options: [
|
||||
{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
|
||||
{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
|
||||
{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
|
||||
{ model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
|
||||
{ model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' }
|
||||
]
|
||||
},
|
||||
initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : ''))
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js';
|
||||
|
||||
(function () {
|
||||
var DEFAULT_FONT_SIZE = 32;
|
||||
|
||||
var dataElement = document.getElementById('slide-editor-data');
|
||||
if (!dataElement) {
|
||||
return;
|
||||
@@ -38,6 +40,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
}
|
||||
|
||||
var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : [];
|
||||
var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : [];
|
||||
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
|
||||
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
|
||||
var existingContent = slideEditorData.existingContent || {};
|
||||
var templateSelect = document.getElementById('template-select');
|
||||
@@ -94,7 +98,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
function getCurrentFontSizeValue() {
|
||||
var value = fontSizeCommand.value;
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
return String(DEFAULT_FONT_SIZE);
|
||||
}
|
||||
|
||||
var parsed = Math.round(Number(String(value).replace(/[^0-9.]/g, '')));
|
||||
@@ -118,6 +122,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
view.element.inputMode = 'numeric';
|
||||
view.element.autocomplete = 'off';
|
||||
view.element.spellcheck = false;
|
||||
view.element.style.width = '3.5em';
|
||||
syncValue();
|
||||
});
|
||||
|
||||
@@ -406,6 +411,15 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
|
||||
}
|
||||
|
||||
function renderPreviewRtmpRegion(value, disableAudio) {
|
||||
var src = String(value || '').trim();
|
||||
var label = src ? 'RTMP' : 'RTMP stream';
|
||||
if (disableAudio) {
|
||||
label += ' (muted)';
|
||||
}
|
||||
return '<div class="slide-preview-placeholder">' + label + '</div>';
|
||||
}
|
||||
|
||||
function renderPreviewHtmlRegion(value) {
|
||||
var html = String(value || '').trim();
|
||||
if (!html) {
|
||||
@@ -434,19 +448,286 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
}
|
||||
|
||||
function normalizeFontSizeValue(value) {
|
||||
var size = Math.max(1, Math.round(Number(String(value || '').replace(/[^0-9.]/g, ''))));
|
||||
return size ? String(size) : '';
|
||||
var raw = String(value || '').trim().toLowerCase();
|
||||
if (/^\d+(?:\.\d+)?px$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw.replace(/px$/, '')))));
|
||||
}
|
||||
if (/^\d+(?:\.\d+)?$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw))));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function syncEditorFontSizeHidden(regionId, editor) {
|
||||
var fontSizeHidden = getEditorFontSizeHiddenInput(regionId);
|
||||
if (!fontSizeHidden || !editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
var fontSizeCommand = editor.commands.get('fontSize');
|
||||
fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand && fontSizeCommand.value) || String(DEFAULT_FONT_SIZE);
|
||||
}
|
||||
|
||||
function getCurrentTextStyle(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
return {
|
||||
font_family: String(current.font_family || region.font_family || 'Arial').trim() || 'Arial',
|
||||
font_size: Math.max(8, Number(current.font_size || 24)),
|
||||
font_size: Math.max(8, Number(current.font_size || DEFAULT_FONT_SIZE)),
|
||||
font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function getRssFeedById(feedId) {
|
||||
var normalizedId = Number(feedId || 0);
|
||||
return rssFeeds.find(function (feed) {
|
||||
return Number(feed.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getRssFieldList(feedId) {
|
||||
var feed = getRssFeedById(feedId);
|
||||
var sampleItem = feed && Array.isArray(feed.items) ? feed.items[0] : null;
|
||||
var sampleJson = sampleItem && sampleItem.itemJson && typeof sampleItem.itemJson === 'object' ? sampleItem.itemJson : null;
|
||||
|
||||
function walkFields(value, prefix, output) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
if (key === 'rawXml') {
|
||||
return;
|
||||
}
|
||||
|
||||
var nextPath = prefix ? prefix + '.' + key : key;
|
||||
var nextValue = value[key];
|
||||
if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue)) {
|
||||
walkFields(nextValue, nextPath, output);
|
||||
return;
|
||||
}
|
||||
|
||||
if (output.indexOf(nextPath) === -1) {
|
||||
output.push(nextPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var fields = [];
|
||||
walkFields(sampleJson, '', fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function updatePlaceholderChipList(card, fieldList, emptyLabel) {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = card.querySelector('[data-placeholder-chips]');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
var chips = Array.isArray(fieldList) ? fieldList.map(function (field) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('') : '';
|
||||
|
||||
container.innerHTML = chips || '<span class="muted slide-image-file">' + escapeHtml(emptyLabel) + '</span>';
|
||||
}
|
||||
|
||||
function updateRssPlaceholderChips(regionId, feedId) {
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
updatePlaceholderChipList(card, getRssFieldList(feedId), 'No RSS fields available.');
|
||||
}
|
||||
|
||||
function getCurrentRssConfig(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var parsedItemNumber = Math.max(1, Number(current.item_number || 1));
|
||||
return {
|
||||
feed_id: current.feed_id === undefined || current.feed_id === null || current.feed_id === '' ? '' : Number(current.feed_id),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
value: current.value !== undefined ? current.value : ''
|
||||
};
|
||||
}
|
||||
|
||||
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 normalizeRssVariableName(value) {
|
||||
var next = String(value || '').trim();
|
||||
return next || 'item';
|
||||
}
|
||||
|
||||
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 getRssItem(region, rssConfig) {
|
||||
var feed = getRssFeedById(rssConfig.feed_id);
|
||||
var items = feed && Array.isArray(feed.items) ? feed.items : [];
|
||||
var parsedItemNumber = Math.max(1, Number(rssConfig.item_number || 1));
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||
return items[index] || null;
|
||||
}
|
||||
|
||||
function getRssPreviewFallback(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '<div class="slide-preview-placeholder">RSS item</div>';
|
||||
}
|
||||
|
||||
var title = String(item.title || '').trim();
|
||||
var description = String(item.description || '').trim();
|
||||
var summary = [];
|
||||
if (title) {
|
||||
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
||||
}
|
||||
if (description) {
|
||||
summary.push('<p>' + sanitizePreviewHtml(description) + '</p>');
|
||||
}
|
||||
if (!summary.length) {
|
||||
return '<div class="slide-preview-placeholder">RSS item</div>';
|
||||
}
|
||||
return summary.join('');
|
||||
}
|
||||
|
||||
function getApiSourceById(sourceId) {
|
||||
var normalizedId = Number(sourceId || 0);
|
||||
return apiSources.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 getApiFieldList(sourceId) {
|
||||
var sampleItem = getApiSourceItems(sourceId)[0] || null;
|
||||
|
||||
function walkFields(value, prefix, output) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
var nextPath = prefix ? prefix + '.' + key : key;
|
||||
var nextValue = value[key];
|
||||
if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue)) {
|
||||
walkFields(nextValue, nextPath, output);
|
||||
return;
|
||||
}
|
||||
|
||||
if (output.indexOf(nextPath) === -1) {
|
||||
output.push(nextPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var fields = [];
|
||||
walkFields(sampleItem && typeof sampleItem === 'object' ? sampleItem : null, '', fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function updateApiPlaceholderChips(regionId, sourceId) {
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
updatePlaceholderChipList(card, getApiFieldList(sourceId), 'No JSON fields available.');
|
||||
}
|
||||
|
||||
function getCurrentApiConfig(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var parsedItemNumber = Math.max(1, Number(current.item_number || 1));
|
||||
return {
|
||||
source_id: current.source_id === undefined || current.source_id === null || current.source_id === '' ? '' : Number(current.source_id),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
value: current.value !== undefined ? current.value : ''
|
||||
};
|
||||
}
|
||||
|
||||
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 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 getApiPreviewFallback(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '<div class="slide-preview-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('<p>' + sanitizePreviewHtml(description) + '</p>');
|
||||
}
|
||||
if (!summary.length) {
|
||||
return '<div class="slide-preview-placeholder">API item</div>';
|
||||
}
|
||||
return summary.join('');
|
||||
}
|
||||
|
||||
function getTextStyleFromCard(_card, region) {
|
||||
return getCurrentTextStyle(region);
|
||||
}
|
||||
@@ -724,10 +1005,25 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
var imageHidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]');
|
||||
var imageFileInput = card && card.querySelector('input[type="file"][name="region_image_' + region.id + '"]');
|
||||
var webpageInput = card && card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]');
|
||||
var rtmpInput = card && card.querySelector('input[type="url"][name="region_rtmp_' + region.id + '"]');
|
||||
var disableAudioInput = card && card.querySelector('input[type="checkbox"][name="region_disable_audio_' + region.id + '"]');
|
||||
var rssFeedInput = card && card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]');
|
||||
var rssItemInput = card && card.querySelector('input[name="region_rss_item_number_' + region.id + '"]');
|
||||
var apiSourceInput = card && card.querySelector('select[name="region_api_source_id_' + region.id + '"]');
|
||||
var apiItemInput = card && card.querySelector('input[name="region_api_item_number_' + region.id + '"]');
|
||||
var existingRtmp = existingContent[region.region_key] || {};
|
||||
var existingRss = existingContent[region.region_key] || {};
|
||||
var existingApi = existingContent[region.region_key] || {};
|
||||
var value = region.region_type === 'image'
|
||||
? ((imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : ''))
|
||||
: region.region_type === 'webpage'
|
||||
? (webpageInput ? webpageInput.value : '')
|
||||
: region.region_type === 'rtmp'
|
||||
? (rtmpInput ? rtmpInput.value : '')
|
||||
: region.region_type === 'rss'
|
||||
? (hiddenInput ? hiddenInput.value : '')
|
||||
: region.region_type === 'api'
|
||||
? (hiddenInput ? hiddenInput.value : '')
|
||||
: region.region_type === 'html'
|
||||
? (htmlInput ? htmlInput.value : '')
|
||||
: (hiddenInput ? hiddenInput.value : '');
|
||||
@@ -735,6 +1031,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
? null
|
||||
: region.region_type === 'webpage'
|
||||
? null
|
||||
: region.region_type === 'rtmp'
|
||||
? null
|
||||
: region.region_type === 'html'
|
||||
? null
|
||||
: getTextStyleFromCard(card, region);
|
||||
@@ -742,6 +1040,23 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
? (value ? '<img class="slide-preview-image" src="' + escapeHtml(value) + '" alt="" />' : '<div class="slide-preview-placeholder">Image</div>')
|
||||
: region.region_type === 'webpage'
|
||||
? renderPreviewWebpageRegion(value)
|
||||
: region.region_type === 'rtmp'
|
||||
? renderPreviewRtmpRegion(value, disableAudioInput ? disableAudioInput.checked : (existingRtmp.disable_audio === undefined ? true : Boolean(existingRtmp.disable_audio)))
|
||||
: region.region_type === 'rss'
|
||||
? (function () {
|
||||
var item = getRssItem(region, {
|
||||
feed_id: rssFeedInput ? rssFeedInput.value : existingRss.feed_id,
|
||||
item_number: rssItemInput ? rssItemInput.value : existingRss.item_number
|
||||
});
|
||||
var rendered = substituteRssVariables(value, item);
|
||||
return renderPreviewTextRegion(region, rendered ? rendered : getRssPreviewFallback(item), style, scale);
|
||||
}())
|
||||
: region.region_type === 'api'
|
||||
? (function () {
|
||||
var item = getApiItem(apiSourceInput ? apiSourceInput.value : existingApi.source_id, apiItemInput ? apiItemInput.value : existingApi.item_number);
|
||||
var rendered = substituteApiVariables(value, item);
|
||||
return renderPreviewTextRegion(region, rendered ? rendered : getApiPreviewFallback(item), style, scale);
|
||||
}())
|
||||
: region.region_type === 'html'
|
||||
? renderPreviewHtmlRegion(value)
|
||||
: renderPreviewTextRegion(region, value, style, scale);
|
||||
@@ -749,6 +1064,12 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
? ' slide-preview-image-region'
|
||||
: region.region_type === 'webpage'
|
||||
? ' slide-preview-webpage-region'
|
||||
: region.region_type === 'rtmp'
|
||||
? ' slide-preview-rtmp-region'
|
||||
: region.region_type === 'rss'
|
||||
? ' slide-preview-rss-region'
|
||||
: region.region_type === 'api'
|
||||
? ' slide-preview-api-region'
|
||||
: region.region_type === 'html'
|
||||
? ' slide-preview-html-region'
|
||||
: ' slide-preview-text-region';
|
||||
@@ -779,6 +1100,96 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderRssRegion(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var config = getCurrentRssConfig(region);
|
||||
var fieldList = getRssFieldList(config.feed_id);
|
||||
var placeholderChips = fieldList.map(function (field) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('');
|
||||
var feedOptions = rssFeeds.map(function (feed) {
|
||||
var selected = Number(feed.id) === Number(config.feed_id) ? ' selected' : '';
|
||||
return '<option value="' + escapeHtml(feed.id) + '"' + selected + '>' + escapeHtml(feed.name || ('Feed ' + feed.id)) + '</option>';
|
||||
}).join('');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary template-editor-toggle" data-toggle-editor-height="' + region.id + '">Expand</button>' +
|
||||
'<span class="chip">RSS</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||
'</div>' +
|
||||
'<div class="row g-3 align-items-end">' +
|
||||
'<div class="col-12 col-md-8">' +
|
||||
'<label class="form-label" for="region_rss_feed_id_' + region.id + '">RSS feed</label>' +
|
||||
'<select name="region_rss_feed_id_' + region.id + '" class="form-select">' +
|
||||
'<option value="">Select a feed</option>' +
|
||||
feedOptions +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div class="col-6 col-md-4">' +
|
||||
'<label class="form-label" for="region_rss_item_number_' + region.id + '">Entry number</label>' +
|
||||
'<input type="number" min="1" step="1" name="region_rss_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(getCurrentTextStyle(region).font_size) + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<div class="muted slide-image-file">Use placeholders like {{title}} or {{link}}. Available placeholders:</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderApiRegion(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var config = getCurrentApiConfig(region);
|
||||
var fieldList = getApiFieldList(config.source_id);
|
||||
var placeholderChips = fieldList.map(function (field) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('');
|
||||
var sourceOptions = apiSources.map(function (source) {
|
||||
var selected = Number(source.id) === Number(config.source_id) ? ' selected' : '';
|
||||
return '<option value="' + escapeHtml(source.id) + '"' + selected + '>' + escapeHtml(source.name || ('Source ' + source.id)) + '</option>';
|
||||
}).join('');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary template-editor-toggle" data-toggle-editor-height="' + region.id + '">Expand</button>' +
|
||||
'<span class="chip">API</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||
'</div>' +
|
||||
'<div class="row g-3 align-items-end">' +
|
||||
'<div class="col-12 col-md-8">' +
|
||||
'<label class="form-label" for="region_api_source_id_' + region.id + '">API source</label>' +
|
||||
'<select name="region_api_source_id_' + region.id + '" class="form-select">' +
|
||||
'<option value="">Select a source</option>' +
|
||||
sourceOptions +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div class="col-6 col-md-4">' +
|
||||
'<label class="form-label" for="region_api_item_number_' + region.id + '">Item number</label>' +
|
||||
'<input type="number" min="1" step="1" name="region_api_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(getCurrentTextStyle(region).font_size) + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<div class="muted slide-image-file">Use placeholders like {{title}} or {{description}}. Available placeholders:</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderWebpageRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
return '' +
|
||||
@@ -796,6 +1207,29 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderRtmpRegion(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var url = String(current.value || '').trim();
|
||||
var disableAudio = current.disable_audio === undefined ? true : Boolean(current.disable_audio);
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">RTMP</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<label style="display:block;">RTMP URL' +
|
||||
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(url) + '" placeholder="rtmp://example.com/live/stream" />' +
|
||||
'</label>' +
|
||||
'<label class="form-check m-0">' +
|
||||
'<input class="form-check-input" type="checkbox" name="region_disable_audio_' + region.id + '" value="1"' + (disableAudio ? ' checked' : '') + ' />' +
|
||||
'<span class="form-check-label">Disable audio</span>' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">Player controls stay hidden and the stream is not interactive.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderHtmlRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
return '' +
|
||||
@@ -851,6 +1285,15 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (region.region_type === 'webpage') {
|
||||
return renderWebpageRegion(region);
|
||||
}
|
||||
if (region.region_type === 'rtmp') {
|
||||
return renderRtmpRegion(region);
|
||||
}
|
||||
if (region.region_type === 'rss') {
|
||||
return renderRssRegion(region);
|
||||
}
|
||||
if (region.region_type === 'api') {
|
||||
return renderApiRegion(region);
|
||||
}
|
||||
if (region.region_type === 'html') {
|
||||
return renderHtmlRegion(region);
|
||||
}
|
||||
@@ -869,8 +1312,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'undo',
|
||||
'redo',
|
||||
'|',
|
||||
'heading',
|
||||
'|',
|
||||
'fontSizeInput',
|
||||
'fontFamily',
|
||||
'fontColor',
|
||||
@@ -904,7 +1345,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
FontFamily,
|
||||
FontSize,
|
||||
FontSizeInputUI,
|
||||
Heading,
|
||||
HorizontalLine,
|
||||
Indent,
|
||||
IndentBlock,
|
||||
@@ -922,29 +1362,19 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
options: [20, 24, 28, 32, 36, 40, 44],
|
||||
supportAllValues: true
|
||||
},
|
||||
heading: {
|
||||
options: [
|
||||
{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
|
||||
{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
|
||||
{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
|
||||
{ model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
|
||||
{ model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' }
|
||||
]
|
||||
},
|
||||
initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : ''))
|
||||
};
|
||||
|
||||
ClassicEditor.create(source || holder, editorConfig).then(function (editor) {
|
||||
editorInstances.set(regionId, editor);
|
||||
var fontSizeHidden = getEditorFontSizeHiddenInput(regionId);
|
||||
var fontSizeCommand = editor.commands.get('fontSize');
|
||||
if (hidden) {
|
||||
hidden.value = editor.getData();
|
||||
}
|
||||
if (fontSizeHidden && fontSizeCommand) {
|
||||
fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value;
|
||||
syncEditorFontSizeHidden(regionId, editor);
|
||||
if (fontSizeCommand) {
|
||||
fontSizeCommand.on('change:value', function () {
|
||||
fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value;
|
||||
syncEditorFontSizeHidden(regionId, editor);
|
||||
});
|
||||
}
|
||||
editor.model.document.on('change:data', function () {
|
||||
@@ -990,6 +1420,42 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
requestPreviewRender();
|
||||
});
|
||||
});
|
||||
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
|
||||
input.addEventListener('input', function () {
|
||||
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
|
||||
if (input.name.indexOf('region_rss_') === 0) {
|
||||
var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value;
|
||||
updateRssPlaceholderChips(regionId, rssFeed);
|
||||
}
|
||||
if (input.name.indexOf('region_api_') === 0) {
|
||||
var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value;
|
||||
updateApiPlaceholderChips(regionId, apiSource);
|
||||
}
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
input.addEventListener('change', function () {
|
||||
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
|
||||
if (input.name.indexOf('region_rss_') === 0) {
|
||||
var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value;
|
||||
updateRssPlaceholderChips(regionId, rssFeed);
|
||||
}
|
||||
if (input.name.indexOf('region_api_') === 0) {
|
||||
var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value;
|
||||
updateApiPlaceholderChips(regionId, apiSource);
|
||||
}
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
});
|
||||
|
||||
if (existingTemplateId) {
|
||||
templateSelectorLock.arm();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
templateSelectorLock.arm();
|
||||
});
|
||||
@@ -1001,6 +1467,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
var slideFormUrl = new URL(slideForm.action, window.location.href);
|
||||
var isCreateForm = slideFormUrl.pathname === '/slides';
|
||||
var submitterValue = event.submitter && event.submitter.name === 'save_action'
|
||||
? String(event.submitter.value || '').trim().toLowerCase()
|
||||
: '';
|
||||
@@ -1011,6 +1479,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (!editor || !hidden) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
syncEditorFontSizeHidden(regionId, editor);
|
||||
hidden.value = editor.getData();
|
||||
return Promise.resolve();
|
||||
});
|
||||
@@ -1043,6 +1512,20 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreateForm) {
|
||||
try {
|
||||
if (response.url) {
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Fall through and show a toast if the redirect URL cannot be resolved.
|
||||
}
|
||||
slideForm.dataset.dirty = 'false';
|
||||
showToast('Saved slide.', 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
var responseText = await response.text();
|
||||
var savedMessage = '';
|
||||
try {
|
||||
@@ -1057,6 +1540,13 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
|
||||
showToast(savedMessage || 'Saved slide.', 'success');
|
||||
slideForm.dataset.dirty = 'false';
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
showToast(error && error.message ? error.message : 'Unable to save slide.', variant);
|
||||
} else {
|
||||
window.alert(error && error.message ? error.message : 'Unable to save slide.');
|
||||
}
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
@@ -1105,3 +1595,4 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
function connect() {
|
||||
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/admin/dashboard');
|
||||
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard');
|
||||
updateSidebarStatus('unknown', 'Connecting to player feed');
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
@@ -98,4 +98,4 @@
|
||||
}
|
||||
|
||||
connectDashboardSocket();
|
||||
}());
|
||||
}());
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
}
|
||||
|
||||
function getRegionChipLabel(regionType) {
|
||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : 'Text';
|
||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rss' ? 'RSS' : 'Text';
|
||||
}
|
||||
|
||||
function populateRegionCard(card, region) {
|
||||
|
||||
@@ -12,6 +12,62 @@
|
||||
return value === undefined || value === null || value === '' ? fallback : value;
|
||||
}
|
||||
|
||||
function normalizeLockRatio(value) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (!/^\d+\s*:\s*\d+$/.test(raw)) {
|
||||
return '';
|
||||
}
|
||||
return raw.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function parseLockRatio(value) {
|
||||
var normalized = normalizeLockRatio(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = normalized.split(':');
|
||||
var width = Number(parts[0]);
|
||||
var height = Number(parts[1]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: normalized,
|
||||
ratio: width / height
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultRegionSize(regionType, lockRatio) {
|
||||
var ratio = parseLockRatio(lockRatio);
|
||||
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
|
||||
|
||||
if (ratio) {
|
||||
if (ratio.ratio >= 1) {
|
||||
var baseWidth = locked ? 420 : 300;
|
||||
return {
|
||||
width: Math.max(12, Math.round(baseWidth)),
|
||||
height: Math.max(12, Math.round(baseWidth / ratio.ratio))
|
||||
};
|
||||
}
|
||||
|
||||
var baseHeight = locked ? 300 : 240;
|
||||
return {
|
||||
width: Math.max(12, Math.round(baseHeight * ratio.ratio)),
|
||||
height: Math.max(12, Math.round(baseHeight))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: locked ? 420 : 300,
|
||||
height: locked ? 240 : 120
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@@ -34,6 +90,7 @@
|
||||
label: name,
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
font_family: card.querySelector('[name="font_family[]"]').value,
|
||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
|
||||
@@ -52,6 +109,7 @@
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
|
||||
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
|
||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
|
||||
@@ -112,6 +170,9 @@
|
||||
clampRegion: clampRegion,
|
||||
getOverlayRect: getOverlayRect,
|
||||
toCanvasPoint: toCanvasPoint,
|
||||
canvasRectToPixels: canvasRectToPixels
|
||||
canvasRectToPixels: canvasRectToPixels,
|
||||
normalizeLockRatio: normalizeLockRatio,
|
||||
parseLockRatio: parseLockRatio,
|
||||
getDefaultRegionSize: getDefaultRegionSize
|
||||
};
|
||||
}());
|
||||
|
||||
@@ -59,6 +59,44 @@
|
||||
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function normalizeLockRatio(value) {
|
||||
return utils.normalizeLockRatio ? utils.normalizeLockRatio(value) : String(value || '').trim().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function parseLockRatio(value) {
|
||||
if (utils.parseLockRatio) {
|
||||
return utils.parseLockRatio(value);
|
||||
}
|
||||
var normalized = normalizeLockRatio(value);
|
||||
if (!normalized || !/^\d+:\d+$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
var parts = normalized.split(':');
|
||||
var width = Number(parts[0]);
|
||||
var height = Number(parts[1]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { label: normalized, ratio: width / height };
|
||||
}
|
||||
|
||||
function getDefaultRegionSize(regionType, lockRatio) {
|
||||
if (utils.getDefaultRegionSize) {
|
||||
return utils.getDefaultRegionSize(regionType, lockRatio);
|
||||
}
|
||||
var ratio = parseLockRatio(lockRatio);
|
||||
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss';
|
||||
if (ratio) {
|
||||
if (ratio.ratio >= 1) {
|
||||
var baseWidth = locked ? 420 : 300;
|
||||
return { width: Math.max(12, Math.round(baseWidth)), height: Math.max(12, Math.round(baseWidth / ratio.ratio)) };
|
||||
}
|
||||
var baseHeight = locked ? 300 : 240;
|
||||
return { width: Math.max(12, Math.round(baseHeight * ratio.ratio)), height: Math.max(12, Math.round(baseHeight)) };
|
||||
}
|
||||
return { width: locked ? 420 : 300, height: locked ? 240 : 120 };
|
||||
}
|
||||
|
||||
function getCards() {
|
||||
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
|
||||
}
|
||||
@@ -126,6 +164,7 @@
|
||||
label: getRegionName(card),
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
font_family: card.querySelector('[name="font_family[]"]').value,
|
||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
|
||||
@@ -148,6 +187,7 @@
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
|
||||
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
|
||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
|
||||
@@ -229,8 +269,50 @@
|
||||
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
|
||||
}
|
||||
|
||||
function updateRegionLockBadge(card) {
|
||||
var lockChip = card.querySelector('[data-region-lock-chip]');
|
||||
if (!lockChip) {
|
||||
return;
|
||||
}
|
||||
var lockRatio = normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value);
|
||||
if (!lockRatio) {
|
||||
lockChip.hidden = true;
|
||||
lockChip.textContent = '';
|
||||
return;
|
||||
}
|
||||
lockChip.hidden = false;
|
||||
lockChip.textContent = 'Locked ' + lockRatio;
|
||||
}
|
||||
|
||||
function getRegionLockRatio(card) {
|
||||
return normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value);
|
||||
}
|
||||
|
||||
function isRegionLocked(card) {
|
||||
return Boolean(getRegionLockRatio(card));
|
||||
}
|
||||
|
||||
function syncLockedDimensions(card, changedField) {
|
||||
var lockRatio = getRegionLockRatio(card);
|
||||
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
||||
if (!aspect) {
|
||||
return;
|
||||
}
|
||||
|
||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||
var heightInput = card.querySelector('[name="region_height[]"]');
|
||||
var width = Math.max(1, Number(widthInput.value || 0));
|
||||
var height = Math.max(1, Number(heightInput.value || 0));
|
||||
|
||||
if (changedField === 'width') {
|
||||
heightInput.value = Math.max(1, Math.round(width / aspect.ratio));
|
||||
} else if (changedField === 'height') {
|
||||
widthInput.value = Math.max(1, Math.round(height * aspect.ratio));
|
||||
}
|
||||
}
|
||||
|
||||
function getRegionChipLabel(regionType) {
|
||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : 'Text';
|
||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
|
||||
}
|
||||
|
||||
function populateRegionCard(card, region) {
|
||||
@@ -252,7 +334,7 @@
|
||||
nameInput.value = region.region_key || region.label || '';
|
||||
}
|
||||
if (fontFamilyInput) {
|
||||
fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial');
|
||||
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
|
||||
}
|
||||
if (regionTypeInput) {
|
||||
regionTypeInput.value = region.region_type || 'text';
|
||||
@@ -263,11 +345,16 @@
|
||||
if (regionLabelInput) {
|
||||
regionLabelInput.value = region.label || region.region_key || '';
|
||||
}
|
||||
var regionLockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||
if (regionLockRatioInput) {
|
||||
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio);
|
||||
}
|
||||
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
|
||||
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
|
||||
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
|
||||
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
|
||||
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
|
||||
function updateRegionLabel(card) {
|
||||
@@ -293,6 +380,9 @@
|
||||
}
|
||||
populateRegionCard(card, region);
|
||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||
var heightInput = card.querySelector('[name="region_height[]"]');
|
||||
nameInput.addEventListener('input', function () {
|
||||
syncRegionIdentity(card, nameInput.value);
|
||||
updateRegionLabel(card);
|
||||
@@ -300,6 +390,30 @@
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
});
|
||||
if (lockRatioInput) {
|
||||
lockRatioInput.addEventListener('input', function () {
|
||||
updateRegionLockBadge(card);
|
||||
requestOverlayRender();
|
||||
});
|
||||
}
|
||||
if (widthInput) {
|
||||
widthInput.addEventListener('input', function () {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'width');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
requestOverlayRender();
|
||||
});
|
||||
}
|
||||
if (heightInput) {
|
||||
heightInput.addEventListener('input', function () {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'height');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
requestOverlayRender();
|
||||
});
|
||||
}
|
||||
card.addEventListener('click', function (event) {
|
||||
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
|
||||
return;
|
||||
@@ -423,15 +537,17 @@
|
||||
function createDefaultRegion(type) {
|
||||
var count = getCards().length + 1;
|
||||
var name = 'region_' + count;
|
||||
var size = getDefaultRegionSize(type, '');
|
||||
return {
|
||||
region_key: name,
|
||||
label: name,
|
||||
region_type: type,
|
||||
font_family: type === 'text' || type === 'html' ? 'Arial' : '',
|
||||
font_family: type === 'text' || type === 'html' || type === 'rss' || type === 'api' ? 'Arial' : '',
|
||||
x: 80,
|
||||
y: 80,
|
||||
width: type === 'image' || type === 'webpage' || type === 'html' ? 420 : 300,
|
||||
height: type === 'image' || type === 'webpage' || type === 'html' ? 240 : 120,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
lock_ratio: '',
|
||||
z_index: 1
|
||||
};
|
||||
}
|
||||
@@ -499,15 +615,69 @@
|
||||
function resizeFromHandle(index, dir, event) {
|
||||
var startPoint = toCanvasPoint(event);
|
||||
var startRegion = readCard(cardAt(index));
|
||||
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
|
||||
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
||||
|
||||
function fitFromWidth(width) {
|
||||
var nextWidth = Math.max(12, Math.round(width));
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: aspect ? Math.max(12, Math.round(nextWidth / aspect.ratio)) : startRegion.height
|
||||
};
|
||||
}
|
||||
|
||||
function fitFromHeight(height) {
|
||||
var nextHeight = Math.max(12, Math.round(height));
|
||||
return {
|
||||
width: aspect ? Math.max(12, Math.round(nextHeight * aspect.ratio)) : startRegion.width,
|
||||
height: nextHeight
|
||||
};
|
||||
}
|
||||
|
||||
function moveHandler(moveEvent) {
|
||||
var currentPoint = toCanvasPoint(moveEvent);
|
||||
var dx = currentPoint.x - startPoint.x;
|
||||
var dy = currentPoint.y - startPoint.y;
|
||||
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
|
||||
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
|
||||
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
|
||||
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
|
||||
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
|
||||
if (aspect) {
|
||||
if (dir === 'e') {
|
||||
var eastSize = fitFromWidth(startRegion.width + dx);
|
||||
next.width = eastSize.width;
|
||||
next.height = eastSize.height;
|
||||
next.y = startRegion.y + Math.round((startRegion.height - next.height) / 2);
|
||||
} else if (dir === 'w') {
|
||||
var westSize = fitFromWidth(startRegion.width - dx);
|
||||
next.width = westSize.width;
|
||||
next.height = westSize.height;
|
||||
next.x = startRegion.x + startRegion.width - next.width;
|
||||
next.y = startRegion.y + Math.round((startRegion.height - next.height) / 2);
|
||||
} else if (dir === 'n') {
|
||||
var northSize = fitFromHeight(startRegion.height - dy);
|
||||
next.width = northSize.width;
|
||||
next.height = northSize.height;
|
||||
next.x = startRegion.x + Math.round((startRegion.width - next.width) / 2);
|
||||
next.y = startRegion.y + startRegion.height - next.height;
|
||||
} else if (dir === 's') {
|
||||
var southSize = fitFromHeight(startRegion.height + dy);
|
||||
next.width = southSize.width;
|
||||
next.height = southSize.height;
|
||||
next.x = startRegion.x + Math.round((startRegion.width - next.width) / 2);
|
||||
} else {
|
||||
var useWidth = Math.abs(dx) >= Math.abs(dy * aspect.ratio);
|
||||
var cornerSize = useWidth ? fitFromWidth(dir.indexOf('w') !== -1 ? startRegion.width - dx : startRegion.width + dx) : fitFromHeight(dir.indexOf('n') !== -1 ? startRegion.height - dy : startRegion.height + dy);
|
||||
next.width = cornerSize.width;
|
||||
next.height = cornerSize.height;
|
||||
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + startRegion.width - next.width; }
|
||||
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + startRegion.height - next.height; }
|
||||
if (dir.indexOf('e') !== -1) { next.x = startRegion.x; }
|
||||
if (dir.indexOf('s') !== -1) { next.y = startRegion.y; }
|
||||
}
|
||||
} else {
|
||||
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
|
||||
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
|
||||
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
|
||||
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
|
||||
}
|
||||
if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; }
|
||||
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
|
||||
next = clampRegion(next);
|
||||
@@ -586,6 +756,12 @@
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
getCards().forEach(function (card) {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'width');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
});
|
||||
syncCanvasSizeSelection();
|
||||
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
|
||||
});
|
||||
@@ -594,6 +770,12 @@
|
||||
if (!validateRegionNames()) {
|
||||
return;
|
||||
}
|
||||
getCards().forEach(function (card) {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'width');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
});
|
||||
syncCanvasSizeSelection();
|
||||
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,66 @@
|
||||
(function () {
|
||||
var storageKey = 'lte-theme';
|
||||
var theme = 'auto';
|
||||
var ckeditorThemeStyleId = 'ckeditor-dark-theme-overrides';
|
||||
|
||||
function getPreferredTheme() {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function getOrCreateCkeditorThemeStyleElement() {
|
||||
var styleElement = document.getElementById(ckeditorThemeStyleId);
|
||||
|
||||
if (styleElement) {
|
||||
return styleElement;
|
||||
}
|
||||
|
||||
styleElement = document.createElement('style');
|
||||
styleElement.id = ckeditorThemeStyleId;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
return styleElement;
|
||||
}
|
||||
|
||||
function syncCkeditorTheme(currentTheme) {
|
||||
var styleElement = getOrCreateCkeditorThemeStyleElement();
|
||||
|
||||
if (currentTheme !== 'dark') {
|
||||
styleElement.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
styleElement.textContent = [
|
||||
'.ck.ck-dropdown__panel,',
|
||||
'.ck.ck-list__panel,',
|
||||
'.ck.ck-list,',
|
||||
'.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.ck-list .ck-list-item-button {',
|
||||
' background: transparent !important;',
|
||||
' background-color: transparent !important;',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck.ck-list .ck-list-item-button:hover {',
|
||||
' background: var(--bs-secondary-bg) !important;',
|
||||
' background-color: var(--bs-secondary-bg) !important;',
|
||||
'}',
|
||||
'.ck.ck-color-grid,',
|
||||
'.ck.ck-color-grid__tile {',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck.ck-color-grid__tile {',
|
||||
' border-color: var(--bs-border-color) !important;',
|
||||
'}'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(storageKey);
|
||||
@@ -13,4 +73,5 @@
|
||||
|
||||
document.documentElement.dataset.bsTheme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
syncCkeditorTheme(theme === 'auto' ? getPreferredTheme() : theme);
|
||||
}());
|
||||
@@ -30,12 +30,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var nextVariant = String(variant || 'success').trim().toLowerCase();
|
||||
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 ? 'success' : nextVariant));
|
||||
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
|
||||
}
|
||||
|
||||
function getMessageVariant(message, fallbackVariant) {
|
||||
@@ -43,7 +43,10 @@
|
||||
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';
|
||||
}
|
||||
return String(fallbackVariant || 'success').trim().toLowerCase() || 'success';
|
||||
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) {
|
||||
@@ -114,7 +117,7 @@
|
||||
// ignore URL cleanup failures
|
||||
}
|
||||
|
||||
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success');
|
||||
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);
|
||||
|
||||
@@ -2,11 +2,11 @@ const { renderView } = require('../../view');
|
||||
|
||||
function normalizeReturnUrl(value) {
|
||||
const url = String(value || '').trim();
|
||||
if (!url || url === '/admin/account' || url.indexOf('/admin/account?') === 0) {
|
||||
return '/admin';
|
||||
if (!url || url === '/account' || url.indexOf('/account?') === 0) {
|
||||
return '/dashboard';
|
||||
}
|
||||
if (!/^\/admin(?:\/|\?|$)/.test(url)) {
|
||||
return '/admin';
|
||||
if (!/^\/(?:account|dashboard)(?:\/|\?|$)/.test(url)) {
|
||||
return '/dashboard';
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
module.exports = function registerAdminPagesRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const buildDashboardState = deps.buildDashboardState;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||
return function (req, res, next) {
|
||||
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
||||
return requirePermission(permissionKey)(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/admin', requirePermission('dashboard.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await buildDashboardState(pool);
|
||||
res.send(pages.renderDashboardPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/clients', requirePermission('clients.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await buildDashboardState(pool);
|
||||
res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await buildDashboardState(pool);
|
||||
if (req.query.edit) {
|
||||
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
}
|
||||
res.send(pages.renderScreensPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/screens/:id/edit', requirePermission('screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const screen = await common.fetchScreenById(pool, Number(req.params.id));
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
if (req.query.edit) {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
}
|
||||
res.send(pages.renderPlaylistsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/playlists/new', requirePermission('playlists.create'), function (req, res) {
|
||||
res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/admin/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,422 +0,0 @@
|
||||
module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const rbacData = deps.rbacData;
|
||||
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
|
||||
const readArrayField = deps.readArrayField;
|
||||
const normalizePermissionKeys = deps.normalizePermissionKeys;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function slugifyRoleKey(name) {
|
||||
const value = String(name || '').trim().toLowerCase();
|
||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return slug || 'role';
|
||||
}
|
||||
|
||||
function normalizeSelectedIds(values) {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) {
|
||||
return Number(value);
|
||||
}).filter(function (value) {
|
||||
return Number.isInteger(value) && value > 0;
|
||||
})));
|
||||
}
|
||||
|
||||
function getActionLabel(actionKey) {
|
||||
const normalizedActionKey = String(actionKey || '').trim().toLowerCase();
|
||||
if (normalizedActionKey === 'edit') {
|
||||
return 'Update';
|
||||
}
|
||||
if (normalizedActionKey === 'allow') {
|
||||
return 'Allow';
|
||||
}
|
||||
if (!normalizedActionKey) {
|
||||
return 'Read';
|
||||
}
|
||||
return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1);
|
||||
}
|
||||
|
||||
async function createUniqueRoleKey(baseName) {
|
||||
const baseKey = slugifyRoleKey(baseName);
|
||||
let candidate = baseKey;
|
||||
let suffix = 2;
|
||||
|
||||
while (true) {
|
||||
const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]);
|
||||
if (!rows.length) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = `${baseKey}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function mapPermissionsForView(permissionRows, selectedPermissionKeys) {
|
||||
const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys));
|
||||
const permissionDefinitions = new Map(permissions.map(function (permission) {
|
||||
return [String(permission.key || '').trim(), permission];
|
||||
}));
|
||||
return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) {
|
||||
const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null;
|
||||
return Object.assign({}, permission, {
|
||||
resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
resourceName: definition ? definition.name : permission.section_name,
|
||||
categoryName: definition ? definition.sectionName : permission.section_name,
|
||||
sectionOrder: definition ? definition.sectionOrder : 999,
|
||||
actionKey: definition ? definition.actionKey : 'read',
|
||||
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
|
||||
isSelected: selectedKeys.has(String(permission.permission_key || '').trim())
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapUsersForView(userRows, selectedUserIds) {
|
||||
const selectedIds = new Set(normalizeSelectedIds(selectedUserIds));
|
||||
return (Array.isArray(userRows) ? userRows : []).map(function (user) {
|
||||
return Object.assign({}, user, {
|
||||
isSelected: selectedIds.has(Number(user.id))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildPermissionGroups(permissionRows) {
|
||||
const groups = [];
|
||||
const groupIndex = new Map();
|
||||
|
||||
(Array.isArray(permissionRows) ? permissionRows : []).forEach(function (permission) {
|
||||
const sectionKey = String(permission.resourceKey || permission.sectionKey || permission.sectionName || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
if (!groupIndex.has(sectionKey)) {
|
||||
const group = {
|
||||
id: sectionKey || 'permissions',
|
||||
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
|
||||
categoryName: String(permission.categoryName || '').trim(),
|
||||
sectionOrder: Number(permission.sectionOrder) || 999,
|
||||
permissions: []
|
||||
};
|
||||
groupIndex.set(sectionKey, group);
|
||||
groups.push(group);
|
||||
}
|
||||
groupIndex.get(sectionKey).permissions.push(permission);
|
||||
});
|
||||
|
||||
groups.forEach(function (group) {
|
||||
group.permissions.sort(function (left, right) {
|
||||
const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 };
|
||||
const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99;
|
||||
const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.name || '').localeCompare(String(right.name || ''));
|
||||
});
|
||||
});
|
||||
|
||||
groups.sort(function (left, right) {
|
||||
const leftOrder = Number(left.sectionOrder) || 999;
|
||||
const rightOrder = Number(right.sectionOrder) || 999;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.title || '').localeCompare(String(right.title || ''));
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
return {
|
||||
formValues: {
|
||||
name: String(formValues && formValues.name || '').trim(),
|
||||
description: String(formValues && formValues.description || '').trim()
|
||||
},
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRoleViewModel(roleId) {
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id);
|
||||
const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id);
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
return {
|
||||
role: Object.assign({}, role, {
|
||||
permissionKeys: selectedPermissionKeys,
|
||||
permissionCount: Number(role.permission_count) || 0,
|
||||
userCount: Number(role.user_count) || 0
|
||||
}),
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
|
||||
users: mapUsersForView(users, selectedUserIds)
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/admin/rbac', requirePermission('rbac.read'), async function (req, res, next) {
|
||||
try {
|
||||
const roles = await rbacData.fetchRoles(pool);
|
||||
res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
|
||||
buildRoleCreateViewModel({
|
||||
name: String(req.query.name || '').trim(),
|
||||
description: String(req.query.description || '').trim()
|
||||
}, []).then(function (viewModel) {
|
||||
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
|
||||
}).catch(function (error) {
|
||||
next(error);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/rbac', requirePermission('rbac.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
|
||||
? req.body['permission_keys[]']
|
||||
: req.body.permission_keys
|
||||
? [].concat(req.body.permission_keys)
|
||||
: []);
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
|
||||
if (!name) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
if (selectedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
const roleKey = await createUniqueRoleKey(name);
|
||||
const actorId = getAuditUserId(req);
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[roleKey, name, description || null, actorId, actorId]
|
||||
);
|
||||
if (selectedPermissionKeys.length) {
|
||||
await rbacData.syncRolePermissions(connection, Number(result.insertId), selectedPermissionKeys);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/admin/rbac?message=' + encodeURIComponent('Role created.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/rbac/:id/edit', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const viewModel = await loadRoleViewModel(roleId);
|
||||
if (!viewModel) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
|
||||
const users = Array.isArray(viewModel.users)
|
||||
? viewModel.users.filter(function (user) {
|
||||
return Number(user && user.id) !== currentUserId;
|
||||
})
|
||||
: [];
|
||||
|
||||
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/rbac/:id', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
|
||||
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
|
||||
const selectedPermissionKeys = shouldSyncPermissions
|
||||
? readArrayField(req.body, ['permission_keys[]', 'permission_keys'])
|
||||
: [];
|
||||
const selectedUserIds = shouldSyncUsers
|
||||
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
||||
: [];
|
||||
const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : [];
|
||||
const normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : [];
|
||||
|
||||
if (!name) {
|
||||
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
|
||||
}
|
||||
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
let availableUsers = [];
|
||||
if (shouldSyncUsers) {
|
||||
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
}
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
}));
|
||||
if (shouldSyncUsers && normalizedUserIds.some(function (userId) {
|
||||
return !validUserIds.has(userId);
|
||||
})) {
|
||||
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
|
||||
[name, description || null, getAuditUserId(req), roleId]
|
||||
);
|
||||
if (shouldSyncPermissions) {
|
||||
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
|
||||
}
|
||||
if (shouldSyncUsers) {
|
||||
await rbacData.syncRoleUsers(connection, roleId, normalizedUserIds);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/rbac/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const selectedPermissionKeys = Array.isArray(req.body['permission_keys[]'])
|
||||
? req.body['permission_keys[]']
|
||||
: req.body.permission_keys
|
||||
? [].concat(req.body.permission_keys)
|
||||
: [];
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
const normalizedPermissionKeys = normalizePermissionKeys(selectedPermissionKeys);
|
||||
if (normalizedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
|
||||
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/rbac/:id/users', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const selectedUserIds = Array.isArray(req.body['user_ids[]'])
|
||||
? req.body['user_ids[]']
|
||||
: req.body.user_ids
|
||||
? [].concat(req.body.user_ids)
|
||||
: [];
|
||||
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
||||
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
}));
|
||||
|
||||
if (normalizedUserIds.some(function (userId) {
|
||||
return !validUserIds.has(userId);
|
||||
})) {
|
||||
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds);
|
||||
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
if (String(role.role_key || '') === 'administrators') {
|
||||
return res.redirect('/admin/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
|
||||
}
|
||||
if (Number(role.user_count) > 0) {
|
||||
return res.redirect('/admin/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM roles WHERE id = ?', [roleId]);
|
||||
res.redirect('/admin/rbac?message=' + encodeURIComponent('Role deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,289 +0,0 @@
|
||||
module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
async function fetchRoleOptions() {
|
||||
return rbacData.fetchRoles(pool);
|
||||
}
|
||||
|
||||
function mapRolesForForm(roles, selectedRoleIds) {
|
||||
const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
}));
|
||||
|
||||
return (Array.isArray(roles) ? roles : []).map(function (role) {
|
||||
return Object.assign({}, role, {
|
||||
isSelected: selectedIds.has(Number(role.id))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function validateRoleIds(roleIds) {
|
||||
const availableRoles = await fetchRoleOptions();
|
||||
const validRoleIds = new Set(availableRoles.map(function (role) {
|
||||
return Number(role.id);
|
||||
}));
|
||||
const normalizedRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
})));
|
||||
|
||||
if (!normalizedRoleIds.length) {
|
||||
return { ok: false, message: 'Select at least one role.' };
|
||||
}
|
||||
|
||||
if (normalizedRoleIds.some(function (roleId) {
|
||||
return !validRoleIds.has(roleId);
|
||||
})) {
|
||||
return { ok: false, message: 'One or more selected roles are invalid.' };
|
||||
}
|
||||
|
||||
return { ok: true, roleIds: normalizedRoleIds };
|
||||
}
|
||||
|
||||
app.get('/admin/users', requirePermission('users.read'), async function (req, res, next) {
|
||||
try {
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
const mappedUsers = users.map(function (user) {
|
||||
return Object.assign({}, user, {
|
||||
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
});
|
||||
});
|
||||
res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/users/new', requirePermission('users.create'), function (req, res) {
|
||||
fetchRoleOptions().then(function (roles) {
|
||||
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
|
||||
}).catch(function (error) {
|
||||
res.status(500).send(error.message || 'Unable to load roles.');
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/admin/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
|
||||
const user = await rbacData.fetchUserWithRoles(pool, userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const roles = await fetchRoleOptions();
|
||||
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
|
||||
isCurrentUser: false,
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users', requirePermission('users.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const username = String(req.body.username || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
const formValues = {
|
||||
username: username,
|
||||
name: name
|
||||
};
|
||||
|
||||
async function renderValidationError(message) {
|
||||
const roles = await fetchRoleOptions();
|
||||
return res.status(400).send(pages.renderUsersAddPage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning'));
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return renderValidationError('Name is required.');
|
||||
}
|
||||
if (!username) {
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return renderValidationError('Password must be at least 8 characters.');
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return renderValidationError('Passwords do not match.');
|
||||
}
|
||||
if (!roleCheck.ok) {
|
||||
return renderValidationError(roleCheck.message);
|
||||
}
|
||||
|
||||
const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
|
||||
if (existingRows.length) {
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.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, actorId, actorId]
|
||||
);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
await connection.commit();
|
||||
res.redirect('/admin/users?message=' + encodeURIComponent('User created.'));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
if (!roleCheck.ok) {
|
||||
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
||||
}
|
||||
|
||||
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
||||
res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const name = String(req.body.name || '').trim();
|
||||
const username = String(req.body.username || '').trim();
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (!name) {
|
||||
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
|
||||
}
|
||||
if (!username) {
|
||||
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
|
||||
}
|
||||
|
||||
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
|
||||
if (existingRows.length) {
|
||||
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
res.redirect('/admin/users?message=' + encodeURIComponent('Username updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/password', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return res.redirect('/admin/users?message=' + encodeURIComponent('Password must be at least 8 characters.'));
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return res.redirect('/admin/users?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
||||
);
|
||||
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]);
|
||||
res.redirect('/admin/users?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/admin/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
|
||||
}
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
|
||||
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
|
||||
return res.redirect('/admin/users?message=' + encodeURIComponent('At least one user must remain.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
res.redirect('/admin/users?message=' + encodeURIComponent('User deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
module.exports = function registerAdminAccountRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
@@ -8,30 +9,34 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
|
||||
const createUserSession = deps.createUserSession;
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
|
||||
app.get('/admin/account', function (req, res) {
|
||||
app.get('/account', function (req, res) {
|
||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', req.query.return_url ? String(req.query.return_url) : ''));
|
||||
});
|
||||
|
||||
app.post('/admin/account/name', async function (req, res, next) {
|
||||
app.post('/account/name', async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) {
|
||||
return res.status(400).send('Name is required.');
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'users', name, req.currentUser.id)) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('That name already exists.'));
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('UPDATE users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
res.redirect('/admin/account?message=' + encodeURIComponent('Name updated.'));
|
||||
res.redirect('/account?message=' + encodeURIComponent('Name updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/account/password', async function (req, res, next) {
|
||||
app.post('/account/password', async function (req, res, next) {
|
||||
try {
|
||||
const currentPassword = String(req.body.current_password || '');
|
||||
const newPassword = String(req.body.new_password || '');
|
||||
@@ -61,9 +66,9 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
|
||||
|
||||
const token = await createUserSession(pool, user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.redirect('/admin/account?message=' + encodeURIComponent('Password updated.'));
|
||||
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -6,7 +6,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
||||
const withClientNameReservation = deps.withClientNameReservation;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
app.post('/admin/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
||||
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const slug = String(req.params.slug || '').trim();
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
@@ -152,4 +152,4 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -56,7 +56,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}, {});
|
||||
}
|
||||
|
||||
app.get('/admin/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
@@ -65,31 +65,66 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/slides/new', requirePermission('slides.create'), async function (req, res, next) {
|
||||
app.get('/slides/new', requirePermission('slides.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
|
||||
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/slides/:id/edit', requirePermission('slides.update'), async function (req, res, next) {
|
||||
app.get('/slides/:id/edit', requirePermission('slides.update'), 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 = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
|
||||
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const payload = await common.buildSlidePayload(pool, req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
|
||||
return res.status(400).send('A slide with that title already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO slides (title, body, template_id, content_json, media_path, media_type, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
@@ -101,9 +136,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
localUploadDir: deps.uploadDir,
|
||||
nextUploadRefs: collectUploadReferencesFromPayload(payload)
|
||||
});
|
||||
redirectAfterSave(req, res, '/admin/slides/' + result.insertId + '/edit', {
|
||||
closeUrl: '/admin/slides',
|
||||
newUrl: '/admin/slides/new',
|
||||
redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', {
|
||||
closeUrl: '/slides',
|
||||
newUrl: '/slides/new',
|
||||
message: 'Slide created.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -111,7 +146,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
||||
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
@@ -121,6 +156,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
|
||||
const existingUploadRefs = collectUploadReferencesFromSlide(slide);
|
||||
const payload = await common.buildSlidePayload(pool, req, slide);
|
||||
if (await common.fetchDuplicateName(pool, 'slides', payload.title, slide.id, 'title')) {
|
||||
return res.status(400).send('A slide with that title already exists.');
|
||||
}
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query(
|
||||
@@ -138,9 +176,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
screenSlideCounts: screenSlideCounts
|
||||
});
|
||||
await broadcastDashboardState();
|
||||
redirectAfterSave(req, res, '/admin/slides/' + slide.id + '/edit', {
|
||||
closeUrl: '/admin/slides',
|
||||
newUrl: '/admin/slides/new',
|
||||
redirectAfterSave(req, res, '/slides/' + slide.id + '/edit', {
|
||||
closeUrl: '/slides',
|
||||
newUrl: '/slides/new',
|
||||
message: 'Slide updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -148,7 +186,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
|
||||
app.post('/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
@@ -156,7 +194,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
const blockMessage = await getSlideDeleteBlockMessage(pool, slide);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/admin/slides?message=' + encodeURIComponent(blockMessage));
|
||||
return res.redirect('/slides?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
|
||||
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
|
||||
@@ -172,13 +210,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
screenSlideCounts: screenSlideCounts
|
||||
});
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/slides?message=' + encodeURIComponent('Slide deleted.'));
|
||||
res.redirect('/slides?message=' + encodeURIComponent('Slide deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
@@ -187,7 +225,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/templates/new', requirePermission('templates.create'), async function (req, res, next) {
|
||||
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchCanvasSizesData(pool);
|
||||
res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
|
||||
@@ -196,9 +234,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
|
||||
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const payload = await common.buildTemplatePayload(pool, req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name)) {
|
||||
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO slide_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
@@ -207,8 +248,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
@@ -217,9 +258,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
localUploadDir: deps.uploadDir,
|
||||
nextUploadRefs: collectUploadReferencesFromPayload(payload)
|
||||
});
|
||||
redirectAfterSave(req, res, '/admin/templates/' + result.insertId + '/edit', {
|
||||
closeUrl: '/admin/templates',
|
||||
newUrl: '/admin/templates/new',
|
||||
redirectAfterSave(req, res, '/templates/' + result.insertId + '/edit', {
|
||||
closeUrl: '/templates',
|
||||
newUrl: '/templates/new',
|
||||
message: 'Template created.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -227,7 +268,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/templates/:id/edit', requirePermission('templates.update'), async function (req, res, next) {
|
||||
app.get('/templates/:id/edit', requirePermission('templates.update'), async function (req, res, next) {
|
||||
try {
|
||||
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
||||
if (!template) {
|
||||
@@ -240,7 +281,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
|
||||
app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
||||
if (!template) {
|
||||
@@ -248,6 +289,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
|
||||
const payload = await common.buildTemplatePayload(pool, req, template);
|
||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
|
||||
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||
const actorId = getAuditUserId(req);
|
||||
@@ -259,8 +303,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
@@ -271,9 +315,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
nextUploadRefs: nextUploadRefs
|
||||
});
|
||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||
redirectAfterSave(req, res, '/admin/templates/' + template.id + '/edit', {
|
||||
closeUrl: '/admin/templates',
|
||||
newUrl: '/admin/templates/new',
|
||||
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
|
||||
closeUrl: '/templates',
|
||||
newUrl: '/templates/new',
|
||||
message: 'Template updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -281,7 +325,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
|
||||
app.post('/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
||||
if (!template) {
|
||||
@@ -289,7 +333,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
const blockMessage = await getTemplateDeleteBlockMessage(pool, template);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/admin/templates?message=' + encodeURIComponent(blockMessage));
|
||||
return res.redirect('/templates?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
const uploadRefs = collectUploadReferencesFromTemplate(template);
|
||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||
@@ -303,13 +347,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
previousUploadRefs: uploadRefs
|
||||
});
|
||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||
res.redirect('/admin/templates?message=' + encodeURIComponent('Template deleted.'));
|
||||
res.redirect('/templates?message=' + encodeURIComponent('Template deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
@@ -318,21 +362,24 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
|
||||
app.get('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
|
||||
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/admin/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
try {
|
||||
const payload = common.buildCanvasSizePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'canvas_sizes', payload.name)) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height)) {
|
||||
return res.redirect('/admin/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/admin/canvas-sizes', {
|
||||
closeUrl: '/admin/canvas-sizes',
|
||||
newUrl: '/admin/canvas-sizes/new',
|
||||
const [result] = await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/canvas-sizes/' + result.insertId + '/edit', {
|
||||
closeUrl: '/canvas-sizes',
|
||||
newUrl: '/canvas-sizes/new',
|
||||
message: 'Canvas size created.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -340,7 +387,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/canvas-sizes/:id/edit', requirePermission('canvas-sizes.update'), async function (req, res, next) {
|
||||
app.get('/canvas-sizes/:id/edit', requirePermission('canvas-sizes.update'), async function (req, res, next) {
|
||||
try {
|
||||
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
||||
if (!canvasSize) {
|
||||
@@ -352,20 +399,23 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/canvas-sizes/:id', requirePermission('canvas-sizes.update'), async function (req, res, next) {
|
||||
app.post('/canvas-sizes/:id', requirePermission('canvas-sizes.update'), async function (req, res, next) {
|
||||
try {
|
||||
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
const payload = common.buildCanvasSizePayload(req, canvasSize);
|
||||
if (await common.fetchDuplicateName(pool, 'canvas_sizes', payload.name, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||
return res.redirect('/admin/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
}
|
||||
await pool.query('UPDATE canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
||||
redirectAfterSave(req, res, '/admin/canvas-sizes', {
|
||||
closeUrl: '/admin/canvas-sizes',
|
||||
newUrl: '/admin/canvas-sizes/new',
|
||||
redirectAfterSave(req, res, '/canvas-sizes', {
|
||||
closeUrl: '/canvas-sizes',
|
||||
newUrl: '/canvas-sizes/new',
|
||||
message: 'Canvas size updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -373,7 +423,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) {
|
||||
app.post('/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
||||
if (!canvasSize) {
|
||||
@@ -381,13 +431,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
const blockMessage = await getCanvasSizeDeleteBlockMessage(pool, canvasSize);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent(blockMessage));
|
||||
return res.redirect('/canvas-sizes?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('UPDATE slide_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
|
||||
await pool.query('DELETE FROM canvas_sizes WHERE id = ?', [canvasSize.id]);
|
||||
res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
|
||||
res.redirect('/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,532 @@
|
||||
module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
const formatDashboardDate = deps.formatDashboardDate || function (value) {
|
||||
return value ? String(value) : '';
|
||||
};
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const fetchRssFeedItems = deps.fetchRssFeedItems || (common && common.fetchRssFeedItems) || null;
|
||||
const replaceRssFeedItems = deps.replaceRssFeedItems || (common && common.replaceRssFeedItems) || null;
|
||||
const fetchApiSourceResponse = common && common.fetchApiSourceResponse ? common.fetchApiSourceResponse : null;
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
async function refreshApiSourceInBackground(apiSourceId, apiUrl, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let responseDetails = null;
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
responseDetails = await loadApiSourceResponse(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 refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let updatedItems = [];
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
updatedItems = await loadRssFeedItems(feedUrl, itemLimit);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load feed items.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE rss_feeds SET modified_by = ? WHERE id = ?',
|
||||
[actorId, rssFeedId]
|
||||
);
|
||||
if (replaceRssFeedItems) {
|
||||
await replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||
}
|
||||
await connection.commit();
|
||||
|
||||
if (pullError) {
|
||||
console.error('[admin-data-sources] 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();
|
||||
}
|
||||
}
|
||||
|
||||
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
|
||||
throw new Error('registerAdminDataSourceRoutes requires the data source route dependencies.');
|
||||
}
|
||||
|
||||
function formatRecurringKey(sourceType, id) {
|
||||
return sourceType + '-refresh:' + Number(id);
|
||||
}
|
||||
|
||||
function buildRecurringTitle(sourceType) {
|
||||
return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh';
|
||||
}
|
||||
|
||||
function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: formatRecurringKey(sourceType, id),
|
||||
title: buildRecurringTitle(sourceType),
|
||||
category: 'data-source',
|
||||
intervalMs: require('../../lib/background-task-queue').normalizeIntervalMs(intervalValue, intervalUnit),
|
||||
metadata: {
|
||||
sourceType: sourceType,
|
||||
sourceId: Number(id),
|
||||
sourceName: name
|
||||
},
|
||||
run: run
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecurringRefresh(sourceType, id) {
|
||||
backgroundTaskQueue.removeRecurringTask(formatRecurringKey(sourceType, id));
|
||||
}
|
||||
|
||||
function getTaskStatusById(taskId) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.getTaskById !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = backgroundTaskQueue.getTaskById(taskId);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
status: task.status,
|
||||
finishedAt: task.finishedAt || '',
|
||||
errorMessage: task.errorMessage || ''
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRssFeedItems(feedUrl, itemLimit) {
|
||||
if (typeof fetchRssFeedItems === 'function') {
|
||||
return fetchRssFeedItems(feedUrl, itemLimit);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadApiSourceResponse(apiUrl) {
|
||||
if (typeof fetchApiSourceResponse === 'function') {
|
||||
return fetchApiSourceResponse(apiUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
responseJson: null,
|
||||
responseStatus: null,
|
||||
responseContentType: null
|
||||
};
|
||||
}
|
||||
|
||||
function sendRefreshTaskState(req, res, sourceType, sourceId) {
|
||||
const taskId = Number(req.query.refresh_task_id);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return res.status(400).json({ error: 'Missing refresh task id.' });
|
||||
}
|
||||
|
||||
const task = getTaskStatusById(taskId);
|
||||
const expectedKey = sourceType + '-refresh:' + Number(sourceId);
|
||||
if (!task || task.key !== expectedKey) {
|
||||
return res.status(404).json({ error: 'Refresh task not found.' });
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
}
|
||||
|
||||
app.get('/data-sources', function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['rss-feeds.read', 'api-sources.read'])) {
|
||||
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
|
||||
return res.redirect('/data-sources/rss-feeds');
|
||||
}
|
||||
return res.redirect('/data-sources/api-sources');
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchApiSourcesData(pool);
|
||||
res.send(pages.renderApiSourcesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
|
||||
res.send(pages.renderApiSourceFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildApiSourcePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name)) {
|
||||
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO api_sources (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_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshApiSourceInBackground(result.insertId, payload.apiUrl, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + result.insertId,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'API source created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
res.send(pages.renderApiSourceEditPage(Object.assign({}, apiSource, {
|
||||
apiUrl: apiSource.api_url,
|
||||
updateIntervalValue: apiSource.update_interval_value,
|
||||
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
|
||||
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
|
||||
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
|
||||
lastPullError: apiSource.last_pull_error || '',
|
||||
lastResponseStatus: apiSource.last_response_status,
|
||||
lastResponseContentType: apiSource.last_response_content_type,
|
||||
lastResponseJson: apiSource.last_response_json || ''
|
||||
}), {
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
lastPullError: apiSource.last_pull_error || ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res) {
|
||||
sendRefreshTaskState(req, res, 'api-source', Number(req.params.id));
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const payload = common.buildApiSourcePayload(req, apiSource);
|
||||
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE api_sources SET 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 = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshApiSourceInBackground(apiSource.id, payload.apiUrl, actorId);
|
||||
});
|
||||
const message = 'API source updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + apiSource.id,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM api_sources WHERE id = ?', [apiSource.id]);
|
||||
await connection.commit();
|
||||
removeRecurringRefresh('api-source', apiSource.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchRssFeedsData(pool);
|
||||
res.send(pages.renderRssFeedsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
|
||||
res.send(pages.renderRssFeedFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildRssFeedPayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name)) {
|
||||
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + result.insertId,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'RSS feed created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
|
||||
: [];
|
||||
|
||||
res.send(pages.renderRssFeedEditPage(Object.assign({}, rssFeed, {
|
||||
feedUrl: rssFeed.feed_url,
|
||||
updateIntervalValue: rssFeed.update_interval_value,
|
||||
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
|
||||
itemLimit: rssFeed.item_limit
|
||||
}), {
|
||||
pulledItems: pulledItems,
|
||||
pullError: ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res) {
|
||||
sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id));
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const payload = common.buildRssFeedPayload(req, rssFeed);
|
||||
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name, rssFeed.id)) {
|
||||
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const message = 'RSS feed updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + rssFeed.id,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM rss_feeds WHERE id = ?', [rssFeed.id]);
|
||||
await connection.commit();
|
||||
removeRecurringRefresh('rss-feed', rssFeed.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -30,7 +30,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
app.post('/admin/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
|
||||
app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||
@@ -72,18 +72,21 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists', requirePermission('playlists.create'), async function (req, res, next) {
|
||||
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'playlists', name)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/admin/playlists', {
|
||||
closeUrl: '/admin/playlists',
|
||||
newUrl: '/admin/playlists/new',
|
||||
const [result] = await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist created.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -91,7 +94,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
@@ -103,6 +106,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'playlists', name, playlist.id)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
|
||||
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
|
||||
|
||||
@@ -239,9 +245,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||
await broadcastDashboardState();
|
||||
redirectAfterSave(req, res, '/admin/playlists?edit=' + playlist.id, {
|
||||
closeUrl: '/admin/playlists',
|
||||
newUrl: '/admin/playlists/new',
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -256,7 +262,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -264,16 +270,16 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/admin/playlists?message=' + encodeURIComponent(blockMessage));
|
||||
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('DELETE FROM playlists WHERE id = ?', [playlist.id]);
|
||||
res.redirect('/admin/playlists?message=' + encodeURIComponent('Playlist deleted.'));
|
||||
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -302,13 +308,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -326,13 +332,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
|
||||
@@ -358,7 +364,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
|
||||
await connection.rollback();
|
||||
return res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
|
||||
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
|
||||
}
|
||||
|
||||
const currentSlide = orderedSlides[currentIndex];
|
||||
@@ -370,7 +376,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
next(error);
|
||||
@@ -379,7 +385,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -416,7 +422,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -490,13 +496,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -505,13 +511,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
await pool.query('DELETE FROM playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/screens/new', requirePermission('screens.create'), async function (req, res, next) {
|
||||
app.get('/screens/new', requirePermission('screens.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchScreenEditData(pool);
|
||||
res.send(pages.renderScreenFormPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
@@ -520,20 +526,23 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/screens', requirePermission('screens.create'), async function (req, res, next) {
|
||||
app.post('/screens', requirePermission('screens.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'screens', name)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/admin/screens', {
|
||||
closeUrl: '/admin/screens',
|
||||
newUrl: '/admin/screens/new',
|
||||
const [result] = await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
|
||||
closeUrl: '/screens',
|
||||
newUrl: '/screens/new',
|
||||
message: 'Screen created.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -541,7 +550,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/screens/:id', requirePermission('screens.update'), async function (req, res, next) {
|
||||
app.post('/screens/:id', requirePermission('screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) {
|
||||
@@ -551,6 +560,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'screens', name, screen.id)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
@@ -566,9 +578,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`
|
||||
});
|
||||
}
|
||||
redirectAfterSave(req, res, '/admin/screens?edit=' + screen.id, {
|
||||
closeUrl: '/admin/screens',
|
||||
newUrl: '/admin/screens/new',
|
||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||
closeUrl: '/screens',
|
||||
newUrl: '/screens/new',
|
||||
message: 'Screen updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -576,7 +588,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/screens/:id/delete', requirePermission('screens.delete'), async function (req, res, next) {
|
||||
app.post('/screens/:id/delete', requirePermission('screens.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const screen = await common.fetchScreenById(pool, Number(req.params.id));
|
||||
if (!screen) {
|
||||
@@ -584,12 +596,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, getScreenConnections);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/admin/screens?message=' + encodeURIComponent(blockMessage));
|
||||
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('DELETE FROM screens WHERE id = ?', [screen.id]);
|
||||
res.redirect('/admin/screens?message=' + encodeURIComponent('Screen deleted.'));
|
||||
res.redirect('/screens?message=' + encodeURIComponent('Screen deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = function registerAdminPagesRoutes(app, deps) {
|
||||
require('../signage/dashboard/routes')(app, deps);
|
||||
require('../signage/clients/routes')(app, deps);
|
||||
require('../signage/screens/routes')(app, deps);
|
||||
require('../signage/playlists/routes')(app, deps);
|
||||
require('../signage/slides/routes')(app, deps);
|
||||
require('../signage/canvas-sizes/routes')(app, deps);
|
||||
require('../signage/templates/routes')(app, deps);
|
||||
};
|
||||
@@ -0,0 +1,432 @@
|
||||
module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const rbacData = deps.rbacData;
|
||||
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
|
||||
const readArrayField = deps.readArrayField;
|
||||
const normalizePermissionKeys = deps.normalizePermissionKeys;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function slugifyRoleKey(name) {
|
||||
const value = String(name || '').trim().toLowerCase();
|
||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return slug || 'role';
|
||||
}
|
||||
|
||||
function normalizeSelectedIds(values) {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) {
|
||||
return Number(value);
|
||||
}).filter(function (value) {
|
||||
return Number.isInteger(value) && value > 0;
|
||||
})));
|
||||
}
|
||||
|
||||
function getActionLabel(actionKey) {
|
||||
const normalizedActionKey = String(actionKey || '').trim().toLowerCase();
|
||||
if (normalizedActionKey === 'edit') {
|
||||
return 'Update';
|
||||
}
|
||||
if (normalizedActionKey === 'allow') {
|
||||
return 'Allow';
|
||||
}
|
||||
if (!normalizedActionKey) {
|
||||
return 'Read';
|
||||
}
|
||||
return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1);
|
||||
}
|
||||
|
||||
async function createUniqueRoleKey(baseName) {
|
||||
const baseKey = slugifyRoleKey(baseName);
|
||||
let candidate = baseKey;
|
||||
let suffix = 2;
|
||||
|
||||
while (true) {
|
||||
const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]);
|
||||
if (!rows.length) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = `${baseKey}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function mapPermissionsForView(permissionRows, selectedPermissionKeys) {
|
||||
const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys));
|
||||
const permissionDefinitions = new Map(permissions.map(function (permission) {
|
||||
return [String(permission.key || '').trim(), permission];
|
||||
}));
|
||||
return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) {
|
||||
const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null;
|
||||
return Object.assign({}, permission, {
|
||||
resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
resourceName: definition ? definition.name : permission.section_name,
|
||||
categoryName: definition ? definition.sectionName : permission.section_name,
|
||||
sectionOrder: definition ? definition.sectionOrder : 999,
|
||||
actionKey: definition ? definition.actionKey : 'read',
|
||||
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
|
||||
isSelected: selectedKeys.has(String(permission.permission_key || '').trim())
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapUsersForView(userRows, selectedUserIds) {
|
||||
const selectedIds = new Set(normalizeSelectedIds(selectedUserIds));
|
||||
return (Array.isArray(userRows) ? userRows : []).map(function (user) {
|
||||
return Object.assign({}, user, {
|
||||
isSelected: selectedIds.has(Number(user.id))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildPermissionGroups(permissionRows) {
|
||||
const groups = [];
|
||||
const groupIndex = new Map();
|
||||
|
||||
(Array.isArray(permissionRows) ? permissionRows : []).forEach(function (permission) {
|
||||
const sectionKey = String(permission.resourceKey || permission.sectionKey || permission.sectionName || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
if (!groupIndex.has(sectionKey)) {
|
||||
const group = {
|
||||
id: sectionKey || 'permissions',
|
||||
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
|
||||
categoryName: String(permission.categoryName || '').trim(),
|
||||
sectionOrder: Number(permission.sectionOrder) || 999,
|
||||
permissions: []
|
||||
};
|
||||
groupIndex.set(sectionKey, group);
|
||||
groups.push(group);
|
||||
}
|
||||
groupIndex.get(sectionKey).permissions.push(permission);
|
||||
});
|
||||
|
||||
groups.forEach(function (group) {
|
||||
group.permissions.sort(function (left, right) {
|
||||
const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 };
|
||||
const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99;
|
||||
const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.name || '').localeCompare(String(right.name || ''));
|
||||
});
|
||||
});
|
||||
|
||||
groups.sort(function (left, right) {
|
||||
const leftOrder = Number(left.sectionOrder) || 999;
|
||||
const rightOrder = Number(right.sectionOrder) || 999;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.title || '').localeCompare(String(right.title || ''));
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
return {
|
||||
formValues: {
|
||||
name: String(formValues && formValues.name || '').trim(),
|
||||
description: String(formValues && formValues.description || '').trim()
|
||||
},
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRoleViewModel(roleId) {
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id);
|
||||
const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id);
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
return {
|
||||
role: Object.assign({}, role, {
|
||||
permissionKeys: selectedPermissionKeys,
|
||||
permissionCount: Number(role.permission_count) || 0,
|
||||
userCount: Number(role.user_count) || 0
|
||||
}),
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
|
||||
users: mapUsersForView(users, selectedUserIds)
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
|
||||
try {
|
||||
const roles = await rbacData.fetchRoles(pool);
|
||||
res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
|
||||
buildRoleCreateViewModel({
|
||||
name: String(req.query.name || '').trim(),
|
||||
description: String(req.query.description || '').trim()
|
||||
}, []).then(function (viewModel) {
|
||||
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
|
||||
}).catch(function (error) {
|
||||
next(error);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/rbac', requirePermission('rbac.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
|
||||
? req.body['permission_keys[]']
|
||||
: req.body.permission_keys
|
||||
? [].concat(req.body.permission_keys)
|
||||
: []);
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
|
||||
if (!name) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'roles', name)) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
if (selectedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
const roleKey = await createUniqueRoleKey(name);
|
||||
const actorId = getAuditUserId(req);
|
||||
const connection = await pool.getConnection();
|
||||
let insertedRoleId = null;
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[roleKey, name, description || null, actorId, actorId]
|
||||
);
|
||||
insertedRoleId = Number(result.insertId);
|
||||
if (selectedPermissionKeys.length) {
|
||||
await rbacData.syncRolePermissions(connection, insertedRoleId, selectedPermissionKeys);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/rbac/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/rbac/:id/edit', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const viewModel = await loadRoleViewModel(roleId);
|
||||
if (!viewModel) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
|
||||
const users = Array.isArray(viewModel.users)
|
||||
? viewModel.users.filter(function (user) {
|
||||
return Number(user && user.id) !== currentUserId;
|
||||
})
|
||||
: [];
|
||||
|
||||
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
|
||||
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
|
||||
const selectedPermissionKeys = shouldSyncPermissions
|
||||
? readArrayField(req.body, ['permission_keys[]', 'permission_keys'])
|
||||
: [];
|
||||
const selectedUserIds = shouldSyncUsers
|
||||
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
||||
: [];
|
||||
const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : [];
|
||||
const normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : [];
|
||||
|
||||
if (!name) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'roles', name, roleId)) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.'));
|
||||
}
|
||||
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
let availableUsers = [];
|
||||
if (shouldSyncUsers) {
|
||||
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
}
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
}));
|
||||
if (shouldSyncUsers && normalizedUserIds.some(function (userId) {
|
||||
return !validUserIds.has(userId);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
|
||||
[name, description || null, getAuditUserId(req), roleId]
|
||||
);
|
||||
if (shouldSyncPermissions) {
|
||||
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
|
||||
}
|
||||
if (shouldSyncUsers) {
|
||||
await rbacData.syncRoleUsers(connection, roleId, normalizedUserIds);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const selectedPermissionKeys = Array.isArray(req.body['permission_keys[]'])
|
||||
? req.body['permission_keys[]']
|
||||
: req.body.permission_keys
|
||||
? [].concat(req.body.permission_keys)
|
||||
: [];
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
const normalizedPermissionKeys = normalizePermissionKeys(selectedPermissionKeys);
|
||||
if (normalizedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
|
||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id/users', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const selectedUserIds = Array.isArray(req.body['user_ids[]'])
|
||||
? req.body['user_ids[]']
|
||||
: req.body.user_ids
|
||||
? [].concat(req.body.user_ids)
|
||||
: [];
|
||||
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
||||
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
}));
|
||||
|
||||
if (normalizedUserIds.some(function (userId) {
|
||||
return !validUserIds.has(userId);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds);
|
||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
if (String(role.role_key || '') === 'administrators') {
|
||||
return res.redirect('/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
|
||||
}
|
||||
if (Number(role.user_count) > 0) {
|
||||
return res.redirect('/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM roles WHERE id = ?', [roleId]);
|
||||
res.redirect('/rbac?message=' + encodeURIComponent('Role deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,303 @@
|
||||
module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
async function fetchRoleOptions() {
|
||||
return rbacData.fetchRoles(pool);
|
||||
}
|
||||
|
||||
function mapRolesForForm(roles, selectedRoleIds) {
|
||||
const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
}));
|
||||
|
||||
return (Array.isArray(roles) ? roles : []).map(function (role) {
|
||||
return Object.assign({}, role, {
|
||||
isSelected: selectedIds.has(Number(role.id))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function validateRoleIds(roleIds) {
|
||||
const availableRoles = await fetchRoleOptions();
|
||||
const validRoleIds = new Set(availableRoles.map(function (role) {
|
||||
return Number(role.id);
|
||||
}));
|
||||
const normalizedRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
})));
|
||||
|
||||
if (!normalizedRoleIds.length) {
|
||||
return { ok: false, message: 'Select at least one role.' };
|
||||
}
|
||||
|
||||
if (normalizedRoleIds.some(function (roleId) {
|
||||
return !validRoleIds.has(roleId);
|
||||
})) {
|
||||
return { ok: false, message: 'One or more selected roles are invalid.' };
|
||||
}
|
||||
|
||||
return { ok: true, roleIds: normalizedRoleIds };
|
||||
}
|
||||
|
||||
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
|
||||
try {
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
const mappedUsers = users.map(function (user) {
|
||||
return Object.assign({}, user, {
|
||||
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
});
|
||||
});
|
||||
res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/users/new', requirePermission('users.create'), function (req, res) {
|
||||
fetchRoleOptions().then(function (roles) {
|
||||
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
|
||||
}).catch(function (error) {
|
||||
res.status(500).send(error.message || 'Unable to load roles.');
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
|
||||
const user = await rbacData.fetchUserWithRoles(pool, userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const roles = await fetchRoleOptions();
|
||||
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
|
||||
isCurrentUser: false,
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users', requirePermission('users.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const username = String(req.body.username || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
const formValues = {
|
||||
username: username,
|
||||
name: name
|
||||
};
|
||||
|
||||
async function renderValidationError(message) {
|
||||
const roles = await fetchRoleOptions();
|
||||
return res.status(400).send(pages.renderUsersAddPage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning'));
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return renderValidationError('Name is required.');
|
||||
}
|
||||
if (!username) {
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return renderValidationError('Password must be at least 8 characters.');
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return renderValidationError('Passwords do not match.');
|
||||
}
|
||||
if (!roleCheck.ok) {
|
||||
return renderValidationError(roleCheck.message);
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'users', name)) {
|
||||
return renderValidationError('That name already exists.');
|
||||
}
|
||||
|
||||
const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
|
||||
if (existingRows.length) {
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.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, actorId, actorId]
|
||||
);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
await connection.commit();
|
||||
res.redirect('/users?message=' + encodeURIComponent('User created.'));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
if (!roleCheck.ok) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
||||
}
|
||||
|
||||
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
||||
res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const name = String(req.body.name || '').trim();
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (!name) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
|
||||
}
|
||||
|
||||
const [userRows] = await pool.query('SELECT id, username FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!userRows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const username = String(req.body.username || userRows[0].username || '').trim();
|
||||
if (!username) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'users', name, userId)) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That name already exists.'));
|
||||
}
|
||||
|
||||
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
|
||||
if (existingRows.length) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/password', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Password must be at least 8 characters.'));
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
||||
);
|
||||
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]);
|
||||
res.redirect('/users?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
|
||||
}
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
|
||||
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('At least one user must remain.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
res.redirect('/users?message=' + encodeURIComponent('User deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -10,12 +10,12 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
const sessionCookieName = deps.sessionCookieName;
|
||||
|
||||
app.get('/', function (req, res) {
|
||||
res.redirect(req.currentUser ? '/admin' : '/login');
|
||||
res.redirect(req.currentUser ? '/dashboard' : '/login');
|
||||
});
|
||||
|
||||
app.get('/login', function (req, res) {
|
||||
if (req.currentUser) {
|
||||
return res.redirect('/admin');
|
||||
return res.redirect('/dashboard');
|
||||
}
|
||||
res.send(pages.renderLoginPage(req.query.message ? String(req.query.message) : ''));
|
||||
});
|
||||
@@ -36,7 +36,7 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
|
||||
const token = await createUserSession(pool, user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.redirect('/admin');
|
||||
res.redirect('/dashboard');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderLoginPage(message) {
|
||||
return renderView('login', {
|
||||
return renderView('auth/login', {
|
||||
title: 'Sign in',
|
||||
authShell: true,
|
||||
bodyClass: 'login-page-body',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function buildDefaultApiSource() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
lastPulledAt: null,
|
||||
lastPullError: '',
|
||||
lastResponseStatus: null,
|
||||
lastResponseContentType: '',
|
||||
lastResponseJson: ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderApiSourceFormPage(apiSource, mode, message, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
|
||||
|
||||
return renderView(isEdit ? 'data-sources/api-sources/edit' : 'data-sources/api-sources/add', {
|
||||
title: isEdit ? 'Edit API source' : 'Add API source',
|
||||
active: 'api-sources',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
apiSource: viewApiSource,
|
||||
lastResponseJson: viewApiSource.lastResponseJson || '',
|
||||
lastPullError: viewApiSource.lastPullError || ''
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
const renderApiSourceFormPage = require('./add');
|
||||
|
||||
module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) {
|
||||
const viewData = Object.assign({
|
||||
lastResponseJson: '',
|
||||
lastPullError: ''
|
||||
}, data || {});
|
||||
|
||||
return renderApiSourceFormPage(Object.assign({}, apiSource, {
|
||||
lastResponseJson: viewData.lastResponseJson,
|
||||
lastPullError: viewData.lastPullError
|
||||
}), 'edit', message, currentUser);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function formatIntervalLabel(interval, unit) {
|
||||
const value = Math.max(1, Number(interval) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
||||
}
|
||||
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
||||
}
|
||||
|
||||
function formatLastPullLabel(apiSource, formatDashboardDate) {
|
||||
if (!apiSource.last_pulled_at) {
|
||||
return 'Never';
|
||||
}
|
||||
|
||||
const timestamp = typeof formatDashboardDate === 'function'
|
||||
? formatDashboardDate(apiSource.last_pulled_at)
|
||||
: String(apiSource.last_pulled_at);
|
||||
return timestamp || 'Never';
|
||||
}
|
||||
|
||||
module.exports = function renderApiSourcesPage(data, message, currentUser, formatDashboardDate) {
|
||||
const apiSources = (data.apiSources || []).map(function (apiSource) {
|
||||
return Object.assign({}, apiSource, {
|
||||
intervalLabel: formatIntervalLabel(apiSource.update_interval_value, apiSource.update_interval_unit),
|
||||
lastPullLabel: formatLastPullLabel(apiSource, formatDashboardDate),
|
||||
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at)
|
||||
});
|
||||
});
|
||||
|
||||
return renderView('data-sources/api-sources/list', {
|
||||
title: 'API sources',
|
||||
active: 'api-sources',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
apiSources: apiSources
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
module.exports = require('../admin/data-sources');
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function buildDefaultRssFeed() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
feedUrl: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
itemLimit: 1
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderRssFeedFormPage(rssFeed, mode, message, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
|
||||
|
||||
return renderView(isEdit ? 'data-sources/rss-feeds/edit' : 'data-sources/rss-feeds/add', {
|
||||
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
|
||||
active: 'rss-feeds',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
rssFeed: viewRssFeed,
|
||||
pulledItems: viewRssFeed.pulledItems || [],
|
||||
pullError: viewRssFeed.pullError || ''
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
const renderRssFeedFormPage = require('./add');
|
||||
|
||||
module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) {
|
||||
const viewData = Object.assign({
|
||||
pulledItems: [],
|
||||
pullError: ''
|
||||
}, data || {});
|
||||
|
||||
return renderRssFeedFormPage(Object.assign({}, rssFeed, {
|
||||
pulledItems: viewData.pulledItems,
|
||||
pullError: viewData.pullError
|
||||
}), 'edit', message, currentUser);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function formatIntervalLabel(interval, unit) {
|
||||
const value = Math.max(1, Number(interval) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
||||
}
|
||||
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
||||
}
|
||||
|
||||
function formatItemLabel(count) {
|
||||
const value = Math.max(1, Number(count) || 0);
|
||||
return value === 1 ? 'Latest item' : `Latest ${value} items`;
|
||||
}
|
||||
|
||||
module.exports = function renderRssFeedsPage(data, message, currentUser) {
|
||||
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
|
||||
return Object.assign({}, rssFeed, {
|
||||
intervalLabel: formatIntervalLabel(rssFeed.update_interval_value, rssFeed.update_interval_unit),
|
||||
itemLabel: formatItemLabel(rssFeed.item_limit)
|
||||
});
|
||||
});
|
||||
|
||||
return renderView('data-sources/rss-feeds/list', {
|
||||
title: 'RSS feeds',
|
||||
active: 'rss-feeds',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
rssFeeds: rssFeeds
|
||||
});
|
||||
};
|
||||
@@ -42,7 +42,7 @@ 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', {
|
||||
return renderView('error/error', {
|
||||
title: String(errorOptions.title || copy.title || 'Error').trim(),
|
||||
active: '',
|
||||
messageVariant: 'primary',
|
||||
@@ -51,7 +51,7 @@ module.exports = function renderErrorPage(options, currentUser) {
|
||||
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 || '/admin').trim() || '/admin',
|
||||
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',
|
||||
|
||||
@@ -10,6 +10,12 @@ module.exports = {
|
||||
renderPlaylistFormPage: require('./playlists/add'),
|
||||
renderPlaylistEditPage: require('./playlists/edit'),
|
||||
renderPlaylistSlideConfigPage: require('./playlists/slide-config'),
|
||||
renderApiSourcesPage: require('./data-sources/api-sources/list'),
|
||||
renderApiSourceFormPage: require('./data-sources/api-sources/add'),
|
||||
renderApiSourceEditPage: require('./data-sources/api-sources/edit'),
|
||||
renderRssFeedsPage: require('./data-sources/rss-feeds/list'),
|
||||
renderRssFeedFormPage: require('./data-sources/rss-feeds/add'),
|
||||
renderRssFeedEditPage: require('./data-sources/rss-feeds/edit'),
|
||||
renderScreensPage: require('./screens/list'),
|
||||
renderScreenFormPage: require('./screens/add'),
|
||||
renderScreenEditPage: require('./screens/edit'),
|
||||
@@ -21,6 +27,7 @@ module.exports = {
|
||||
renderCanvasSizesPage: require('./canvas-sizes/list'),
|
||||
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
|
||||
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
|
||||
renderBackgroundTasksPage: require('./settings/background-tasks-page'),
|
||||
renderErrorPage: require('./error'),
|
||||
renderRbacPage: require('./rbac/list'),
|
||||
renderRbacAddPage: require('./rbac/add'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user