Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbd517abbb | ||
|
|
403a928b9e | ||
|
|
7bb34f40fe | ||
|
|
ea72747822 | ||
|
|
a5bf8e6f7f | ||
|
|
203d0bfc01 | ||
|
|
1bdc122995 | ||
|
|
2d748458d0 | ||
|
|
bb8cd98e61 | ||
|
|
aafe112c36 | ||
|
|
1f1ad5d61f |
@@ -1,5 +1,6 @@
|
|||||||
*
|
*
|
||||||
!package.json
|
!package.json
|
||||||
|
!package-lock.json
|
||||||
!build/
|
!build/
|
||||||
!build/**
|
!build/**
|
||||||
!src/
|
!src/
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
- Treat `package.json` as the source of truth for the application version.
|
- Treat `package.json` as the source of truth for the application version.
|
||||||
- Keep `package.json`, `build/package.player.json`, and `build/package.web.json` on the same version number.
|
- Keep `package.json`, `build/package.player.json`, and `build/package.web.json` on the same version number.
|
||||||
|
- Keep dependency versions and package metadata in `package.json`, `package-lock.json`, `build/package.player.json`, and `build/package.web.json` aligned unless a dependency is intentionally omitted from a specific bundle.
|
||||||
|
- When changing bundled library versions, update the About page source data from the same package metadata or vendored asset banner rather than hardcoding a fresh literal.
|
||||||
- When the app version changes, update `CHANGELOG.md` in the same change.
|
- When the app version changes, update `CHANGELOG.md` in the same change.
|
||||||
- Keep database migration versions aligned with the release they actually belong to.
|
- Keep database migration versions aligned with the release they actually belong to.
|
||||||
- If only part of a migration batch belongs to a newer release, split that batch into a separate migration entry instead of relabeling the earlier release.
|
- If only part of a migration batch belongs to a newer release, split that batch into a separate migration entry instead of relabeling the earlier release.
|
||||||
|
|||||||
@@ -7,17 +7,18 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push-existing-registry:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: web
|
- name: web
|
||||||
image: git.lzstealth.com/lzstealth/pulse-signage-web
|
repository: pulse-signage-web
|
||||||
dockerfile: ./build/Dockerfile
|
dockerfile: ./build/Dockerfile
|
||||||
- name: player
|
- name: player
|
||||||
image: git.lzstealth.com/lzstealth/pulse-signage-player
|
repository: pulse-signage-player
|
||||||
dockerfile: ./build/Dockerfile.player
|
dockerfile: ./build/Dockerfile.player
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -27,7 +28,7 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to container registry
|
- name: Log in to existing package registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.lzstealth.com
|
registry: git.lzstealth.com
|
||||||
@@ -38,7 +39,8 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ matrix.image }}
|
images: |
|
||||||
|
git.lzstealth.com/lzstealth/${{ matrix.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
|
|||||||
+118
@@ -2,6 +2,124 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 2.8.4 - 2026-08-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added local caching for API and RSS image placeholders under `player-cache/remote-images` for offline player playback.
|
||||||
|
- Added reconciliation of cached remote images so files no longer referenced by slides are removed.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed popup preview authentication for background thumbnail capture.
|
||||||
|
- Fixed thumbnails so they use the same popup-preview canvas and resolve API/RSS placeholders, fonts, styles, and cached images correctly.
|
||||||
|
- Fixed manual scheduled-task run notifications so they use task-neutral wording.
|
||||||
|
|
||||||
|
## 2.8.3 - 2026-08-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added API, RSS, Timetable, and Time / Date placeholder help panels with shared transform and date-token documentation.
|
||||||
|
- Added render-time API and RSS image placeholders with proportional sizing and preview-only bounding boxes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- API and RSS regions now preserve authored content when no data source is selected and remain blank when a selected source has no authored content.
|
||||||
|
- Normal WYSIWYG image insertion remains upload-backed and separate from image placeholder transforms.
|
||||||
|
|
||||||
|
## 2.8.2 - 2026-08-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Standardized update audit events on from/to changes and added readable table diffs for nested JSON, arrays, null values, and empty strings.
|
||||||
|
- Standardized internal `src/data` imports on the `#src` alias.
|
||||||
|
|
||||||
|
## 2.8.1 - 2026-08-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prevented startup and runtime duplicate-key writes from consuming auto-increment values in permission, player, onboarding, settings, and relationship tables.
|
||||||
|
- Prevented partial timetable schemas from being incorrectly treated as fully migrated during schema version detection.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed the built-in administrator role key to `super-admin`, while allowing its display name and description to be edited without being overwritten on restart.
|
||||||
|
|
||||||
|
## 2.8.0 - 2026-08-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Filtered audit-log CSV export with a dedicated `audit-log.export` permission.
|
||||||
|
- Canvas Sizes as an individual Content audit category.
|
||||||
|
- Audit events for slide, template, playlist, and screen changes.
|
||||||
|
- Audit events for System Settings changes.
|
||||||
|
- Administration audit events for user and role management.
|
||||||
|
- Audit logging enablement, category selection, and request metadata controls.
|
||||||
|
- The extensible audit event storage, retention setting, dedicated audit-log permission, and paginated viewer foundation.
|
||||||
|
- A Defaults settings section for player and announcement defaults.
|
||||||
|
- A configurable maximum active session limit that removes the oldest sessions first.
|
||||||
|
- IP address and user-agent metadata to active sessions.
|
||||||
|
- The option for users to sign out their other active sessions from My Account.
|
||||||
|
- Persistent administrator-controlled account locking and unlocking.
|
||||||
|
- Database-backed login rate limiting with configurable attempts, lockout duration, and tracking scope.
|
||||||
|
- A permissions-gated System Settings page for announcement icon suggestions, media upload limits and MIME types, session lifetime, and password-change policies.
|
||||||
|
- Configurable forced password changes for newly created users and administrator password resets.
|
||||||
|
- The database foundation for key-based application settings, including typed defaults and validation.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated the API and database documentation and added the Docker publish status badge to the project README.
|
||||||
|
- Renamed the audit export permission to `audit-log.allow`.
|
||||||
|
- Made Content and Data Sources audit categories opt-in and excluded automatic data-source refreshes from audit logging.
|
||||||
|
- Split Content audit logging into individual Slides, Templates, Playlists, Screens, and Announcements categories.
|
||||||
|
- Renamed the System Settings audit category key from `settings` to `system-settings`.
|
||||||
|
- Split audit administration events into separate Users and Roles categories.
|
||||||
|
- Split RSS and API data-source refresh defaults.
|
||||||
|
- Made the default announcement duration use a value and unit selector, matching announcement forms.
|
||||||
|
- Replaced password strength presets with customizable length, category, and character requirements.
|
||||||
|
- Session expiration now uses the configured system setting, and user and role administration is grouped under the Settings area.
|
||||||
|
- Renamed the system settings permissions to the `system-settings.*` namespace and migrated existing role assignments.
|
||||||
|
- Added numeric auto-increment identifiers to every table and retained natural or relationship keys as unique constraints.
|
||||||
|
|
||||||
|
## 2.7.6 - 2026-08-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The announcement icon picker now supports searching the full Bootstrap Icons catalog while still showing the curated suggestion set by default.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The announcement Play/Stop button now refreshes after saving screen-group changes, so Play stays disabled until the announcement actually has targets again.
|
||||||
|
|
||||||
|
## 2.7.5 - 2026-08-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The About page now reads bundled library versions from package metadata and the vendored AdminLTE stylesheet.
|
||||||
|
- AdminLTE is now reported as 4.3.1.
|
||||||
|
- Animate.css is now reported as 4.1.1.
|
||||||
|
- Bootstrap Icons is now reported as 1.13.1.
|
||||||
|
- Cropper.js is now reported as 1.6.2.
|
||||||
|
- Express is now reported as 5.2.1.
|
||||||
|
- Handlebars is now reported as 4.7.8.
|
||||||
|
- hls.js is now reported as 1.7.0.
|
||||||
|
- Multer is now reported as 2.2.0.
|
||||||
|
- MySQL2 is now reported as 3.23.3.
|
||||||
|
- Sharp is now reported as 0.35.3.
|
||||||
|
- TinyMCE is now reported from the vendored package metadata.
|
||||||
|
- ws is now reported as 8.21.3.
|
||||||
|
- The runtime and container images now target Node.js 26.
|
||||||
|
|
||||||
|
## 2.7.4 - 2026-08-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A new About page.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refreshed the vendored AdminLTE assets to 4.3.1.
|
||||||
|
|
||||||
## 2.7.3 - 2026-08-15
|
## 2.7.3 - 2026-08-15
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Pulse Signage
|
# Pulse Signage
|
||||||
|
|
||||||
|
[](https://git.lzstealth.com/lzstealth/pulse-signage/actions?workflow=docker-publish.yml)
|
||||||
|
|
||||||
Pulse Signage is a self-hosted digital signage platform for teams that want clear, reliable control over the content on every screen.
|
Pulse Signage is a self-hosted digital signage platform for teams that want clear, reliable control over the content on every screen.
|
||||||
|
|
||||||
It gives you one place to publish playlists, slides, announcements, and live updates without handing the workflow to a third-party service.
|
It gives you one place to publish playlists, slides, announcements, and live updates without handing the workflow to a third-party service.
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24-alpine
|
FROM node:26-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ RUN apk add --no-cache chromium nss freetype harfbuzz ttf-freefont
|
|||||||
|
|
||||||
COPY build/package.web.json ./package.json
|
COPY build/package.web.json ./package.json
|
||||||
RUN npm install --omit=dev --no-audit --no-fund
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
COPY package-lock.json ./package-lock.json
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24-alpine
|
FROM node:26-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ RUN apk add --no-cache ffmpeg
|
|||||||
|
|
||||||
COPY build/package.player.json ./package.json
|
COPY build/package.player.json ./package.json
|
||||||
RUN npm install --omit=dev --no-audit --no-fund
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
COPY package-lock.json ./package-lock.json
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.7.3",
|
"version": "2.8.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=26.0.0"
|
||||||
|
},
|
||||||
"main": "src/common.js",
|
"main": "src/common.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node -r dotenv/config src/player.js",
|
"start": "node -r dotenv/config src/player.js",
|
||||||
@@ -10,10 +13,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.21.2",
|
"express": "^5.2.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"hls.js": "^1.5.15",
|
"hls.js": "^1.7.0",
|
||||||
"mysql2": "^3.14.3",
|
"mysql2": "^3.23.3",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.7.3",
|
"version": "2.8.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=26.0.0"
|
||||||
|
},
|
||||||
"main": "src/common.js",
|
"main": "src/common.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node -r dotenv/config src/web.js",
|
"start": "node -r dotenv/config src/web.js",
|
||||||
"start:web": "node -r dotenv/config src/web.js"
|
"start:web": "node -r dotenv/config src/web.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^137.0.0",
|
"@sparticuz/chromium": "^149.0.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.21.2",
|
"express": "^5.2.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"mysql2": "^3.14.3",
|
"mysql2": "^3.23.3",
|
||||||
"puppeteer-core": "^24.16.0",
|
"puppeteer-core": "^25.7.0",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,10 +17,9 @@ PLAYER_PUBLIC_URL="http://localhost:8081"
|
|||||||
PLAYER_INTERNAL_URL="http://player:8081"
|
PLAYER_INTERNAL_URL="http://player:8081"
|
||||||
|
|
||||||
# Web app bootstrap settings
|
# Web app bootstrap settings
|
||||||
SESSION_MAX_AGE_DAYS=14
|
|
||||||
DEFAULT_ADMIN_USERNAME="admin"
|
DEFAULT_ADMIN_USERNAME="admin"
|
||||||
DEFAULT_ADMIN_NAME="Admin"
|
DEFAULT_ADMIN_NAME="Admin"
|
||||||
DEFAULT_ADMIN_PASSWORD="password123"
|
DEFAULT_ADMIN_PASSWORD="password123!"
|
||||||
|
|
||||||
# Bridge settings for the player-bridge service
|
# Bridge settings for the player-bridge service
|
||||||
WEB_INTERNAL_URL="http://web:8080"
|
WEB_INTERNAL_URL="http://web:8080"
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ Key configuration:
|
|||||||
|
|
||||||
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
|
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
|
||||||
- `PULSE_SIGNAGE_SHARED_SECRET`
|
- `PULSE_SIGNAGE_SHARED_SECRET`
|
||||||
- `SESSION_MAX_AGE_DAYS`
|
|
||||||
- `DEFAULT_ADMIN_USERNAME`
|
- `DEFAULT_ADMIN_USERNAME`
|
||||||
- `DEFAULT_ADMIN_NAME`
|
- `DEFAULT_ADMIN_NAME`
|
||||||
- `DEFAULT_ADMIN_PASSWORD`
|
- `DEFAULT_ADMIN_PASSWORD`
|
||||||
@@ -122,7 +121,6 @@ Important values:
|
|||||||
- `PLAYER_INTERNAL_URL` - internal URL the web app uses for local player calls
|
- `PLAYER_INTERNAL_URL` - internal URL the web app uses for local player calls
|
||||||
- `BRIDGE_INTERNAL_URL` - bridge URL the web app uses for player snapshot and command forwarding
|
- `BRIDGE_INTERNAL_URL` - bridge URL the web app uses for player snapshot and command forwarding
|
||||||
- `WEB_INTERNAL_URL` - internal URL the bridge uses to call the web app directly
|
- `WEB_INTERNAL_URL` - internal URL the bridge uses to call the web app directly
|
||||||
- `SESSION_MAX_AGE_DAYS` - dashboard session lifetime
|
|
||||||
- `DEFAULT_ADMIN_*` - bootstrap admin account values
|
- `DEFAULT_ADMIN_*` - bootstrap admin account values
|
||||||
- `PASSWORD_HASH_ITERATIONS` - password hashing cost
|
- `PASSWORD_HASH_ITERATIONS` - password hashing cost
|
||||||
- `MYSQL_ROOT_PASSWORD` - root password for the local MySQL container
|
- `MYSQL_ROOT_PASSWORD` - root password for the local MySQL container
|
||||||
@@ -167,7 +165,6 @@ Leave it blank only if you intentionally want to run without request signing in
|
|||||||
| `DB_USER` | web, player, bridge, mysql | Database user. |
|
| `DB_USER` | web, player, bridge, mysql | Database user. |
|
||||||
| `DB_PASSWORD` | web, player, bridge, mysql | Database password. |
|
| `DB_PASSWORD` | web, player, bridge, mysql | Database password. |
|
||||||
| `MYSQL_ROOT_PASSWORD` | mysql | Root password for the local MySQL container. |
|
| `MYSQL_ROOT_PASSWORD` | mysql | Root password for the local MySQL container. |
|
||||||
| `SESSION_MAX_AGE_DAYS` | web | Session cookie lifetime. |
|
|
||||||
| `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. |
|
| `DEFAULT_ADMIN_USERNAME` | web | Bootstrap admin username. |
|
||||||
| `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. |
|
| `DEFAULT_ADMIN_NAME` | web | Bootstrap admin display name. |
|
||||||
| `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. |
|
| `DEFAULT_ADMIN_PASSWORD` | web | Bootstrap admin password. |
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ services:
|
|||||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||||
BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090}
|
BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090}
|
||||||
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
|
|
||||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
|
||||||
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
|
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin}
|
||||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-password123}
|
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-password123}
|
||||||
|
|||||||
+5
-6
@@ -286,6 +286,8 @@ Response fields:
|
|||||||
- `id`
|
- `id`
|
||||||
- `name`
|
- `name`
|
||||||
- `fade_between_slides`
|
- `fade_between_slides`
|
||||||
|
- `skip_unavailable_rtmp`
|
||||||
|
- `canvas_id`
|
||||||
|
|
||||||
### Slide
|
### Slide
|
||||||
|
|
||||||
@@ -293,12 +295,9 @@ Response fields:
|
|||||||
- `title`
|
- `title`
|
||||||
- `body`
|
- `body`
|
||||||
- `duration_seconds`
|
- `duration_seconds`
|
||||||
- `schedule_mode`
|
- `use_video_duration`
|
||||||
- `schedule_start_datetime`
|
- `disable_audio`
|
||||||
- `schedule_end_datetime`
|
- `scheduleRules`
|
||||||
- `schedule_start_time`
|
|
||||||
- `schedule_end_time`
|
|
||||||
- `schedule_days_json`
|
|
||||||
- `media_url`
|
- `media_url`
|
||||||
- `media_type`
|
- `media_type`
|
||||||
- `kind`
|
- `kind`
|
||||||
|
|||||||
+40
-22
@@ -3,7 +3,7 @@
|
|||||||
This app creates and maintains its schema at startup through `src/db/index.js`.
|
This app creates and maintains its schema at startup through `src/db/index.js`.
|
||||||
The sections below summarize the current tables and their purpose.
|
The sections below summarize the current tables and their purpose.
|
||||||
|
|
||||||
Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_at` and `modified_at` when a table supports auditing.
|
Tables generally use a numeric auto-increment `id` primary key. The relationship table `d_announcement_screens` intentionally uses the composite `(announcement_id, screen_id)` primary key instead. Natural and relationship keys remain as unique constraints where needed. Timestamps are stored as `created_at` and `modified_at` when a table supports auditing.
|
||||||
|
|
||||||
## Admin
|
## Admin
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
|||||||
|
|
||||||
### `a_users`
|
### `a_users`
|
||||||
|
|
||||||
- `id`, `name`, `username`, `password_hash`, `password_salt`, `password_iterations`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
- `id`, `name`, `username`, `password_hash`, `password_salt`, `password_iterations`, `must_change_password`, `account_locked`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||||
- `username` is unique.
|
- `username` is unique.
|
||||||
|
|
||||||
### `a_roles`
|
### `a_roles`
|
||||||
@@ -32,24 +32,24 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
|||||||
|
|
||||||
### `a_role_permissions`
|
### `a_role_permissions`
|
||||||
|
|
||||||
- `role_id`, `permission_id`, `created_at`, `modified_at`
|
- `id`, `role_id`, `permission_id`, `created_at`, `modified_at`
|
||||||
- Foreign keys:
|
- Foreign keys:
|
||||||
- `role_id` -> `a_roles.id`
|
- `role_id` -> `a_roles.id`
|
||||||
- `permission_id` -> `a_permissions.id`
|
- `permission_id` -> `a_permissions.id`
|
||||||
- Composite primary key: `(role_id, permission_id)`
|
- Unique key: `(role_id, permission_id)`
|
||||||
|
|
||||||
### `a_user_roles`
|
### `a_user_roles`
|
||||||
|
|
||||||
- `user_id`, `role_id`, `created_at`, `modified_at`
|
- `id`, `user_id`, `role_id`, `created_at`, `modified_at`
|
||||||
- Foreign keys:
|
- Foreign keys:
|
||||||
- `user_id` -> `a_users.id`
|
- `user_id` -> `a_users.id`
|
||||||
- `role_id` -> `a_roles.id`
|
- `role_id` -> `a_roles.id`
|
||||||
- Composite primary key: `(user_id, role_id)`
|
- Unique key: `(user_id, role_id)`
|
||||||
|
|
||||||
### `a_sessions`
|
### `a_sessions`
|
||||||
|
|
||||||
- `session_hash`, `user_id`, `expires_at`, `created_at`, `created_by`, `last_used_at`, `modified_by`
|
- `id`, `session_hash`, `user_id`, `ip_address`, `user_agent`, `expires_at`, `created_at`, `created_by`, `last_used_at`, `modified_by`
|
||||||
- `session_hash` is the primary key.
|
- `session_hash` is unique.
|
||||||
- Foreign key:
|
- Foreign key:
|
||||||
- `user_id` -> `a_users.id`
|
- `user_id` -> `a_users.id`
|
||||||
|
|
||||||
@@ -116,11 +116,6 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
|||||||
- `d_screens` - screen records and playlist assignment.
|
- `d_screens` - screen records and playlist assignment.
|
||||||
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
|
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
|
||||||
|
|
||||||
## Announcements
|
|
||||||
|
|
||||||
- `d_announcements` - announcement content and display metadata.
|
|
||||||
- `d_announcement_screens` - announcement-to-screen assignments.
|
|
||||||
|
|
||||||
### `d_players`
|
### `d_players`
|
||||||
|
|
||||||
- `id`, `identifier`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at`
|
- `id`, `identifier`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at`
|
||||||
@@ -138,11 +133,20 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
|||||||
|
|
||||||
### `d_onboarding_devices`
|
### `d_onboarding_devices`
|
||||||
|
|
||||||
- `device_id`, `client_name`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
- `id`, `device_id`, `client_name`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||||
- `device_id` is the primary key.
|
- `device_id` is unique.
|
||||||
- Foreign key:
|
- Foreign key:
|
||||||
- `screen_id` -> `d_screens.id` with `ON DELETE SET NULL`
|
- `screen_id` -> `d_screens.id` with `ON DELETE SET NULL`
|
||||||
|
|
||||||
|
## Onboarding
|
||||||
|
|
||||||
|
- The onboarding flow uses `d_onboarding_devices` to bind a device to a screen and persist the client name.
|
||||||
|
|
||||||
|
## Announcements
|
||||||
|
|
||||||
|
- `d_announcements` - announcement content and display metadata.
|
||||||
|
- `d_announcement_screens` - announcement-to-screen assignments.
|
||||||
|
|
||||||
### `d_announcements`
|
### `d_announcements`
|
||||||
|
|
||||||
- `id`, `message`, `short_label`, `announcement_type`, `color_key`, `icon_key`, `duration_seconds`, `expires_at`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
- `id`, `message`, `short_label`, `announcement_type`, `color_key`, `icon_key`, `duration_seconds`, `expires_at`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||||
@@ -154,11 +158,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
|||||||
- Foreign keys:
|
- Foreign keys:
|
||||||
- `announcement_id` -> `d_announcements.id` with `ON DELETE CASCADE`
|
- `announcement_id` -> `d_announcements.id` with `ON DELETE CASCADE`
|
||||||
- `screen_id` -> `d_screens.id` with `ON DELETE CASCADE`
|
- `screen_id` -> `d_screens.id` with `ON DELETE CASCADE`
|
||||||
- Composite primary key: `(announcement_id, screen_id)`
|
- Unique key: `(announcement_id, screen_id)`
|
||||||
|
|
||||||
## Onboarding
|
|
||||||
|
|
||||||
- The onboarding flow uses `d_onboarding_devices` to bind a device to a screen and persist the client name.
|
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
@@ -201,6 +201,8 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
|||||||
|
|
||||||
- `o_background_tasks` - queue and history for background jobs.
|
- `o_background_tasks` - queue and history for background jobs.
|
||||||
- `o_app_state` - generic app state and version markers stored as key/value pairs.
|
- `o_app_state` - generic app state and version markers stored as key/value pairs.
|
||||||
|
- `o_app_settings` - administrator-configurable application settings stored by key.
|
||||||
|
- `o_audit_events` - retained audit events for administrator activity and system changes.
|
||||||
|
|
||||||
### `o_background_tasks`
|
### `o_background_tasks`
|
||||||
|
|
||||||
@@ -209,10 +211,21 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
|||||||
|
|
||||||
### `o_app_state`
|
### `o_app_state`
|
||||||
|
|
||||||
- `state_key`, `state_value`, `created_at`, `modified_at`
|
- `id`, `state_key`, `state_value`, `created_at`, `modified_at`
|
||||||
- `state_key` is the primary key.
|
- `state_key` is unique.
|
||||||
- `schema_version` is stored here so startup can detect the previously recorded schema version before deciding whether migrations need to run.
|
- `schema_version` is stored here so startup can detect the previously recorded schema version before deciding whether migrations need to run.
|
||||||
|
|
||||||
|
### `o_app_settings`
|
||||||
|
|
||||||
|
- `id`, `setting_key`, `setting_value`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||||
|
- `setting_key` is unique.
|
||||||
|
- Values are stored as JSON and validated against the application setting definitions in `src/data/app-settings.js`.
|
||||||
|
|
||||||
|
### `o_audit_events`
|
||||||
|
|
||||||
|
- `id`, `occurred_at`, `category`, `event_type`, `actor_user_id`, `target_type`, `target_id`, `target_label`, `ip_address`, `user_agent`, `details_json`
|
||||||
|
- Indexed by occurrence time, category and event type, actor, and target.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
|
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
|
||||||
@@ -269,14 +282,19 @@ erDiagram
|
|||||||
}
|
}
|
||||||
O_APP_STATE {
|
O_APP_STATE {
|
||||||
}
|
}
|
||||||
|
O_APP_SETTINGS {
|
||||||
|
}
|
||||||
O_BACKGROUND_TASKS {
|
O_BACKGROUND_TASKS {
|
||||||
}
|
}
|
||||||
|
O_AUDIT_EVENTS {
|
||||||
|
}
|
||||||
|
|
||||||
A_USERS ||--o{ A_USER_ROLES : has
|
A_USERS ||--o{ A_USER_ROLES : has
|
||||||
A_ROLES ||--o{ A_USER_ROLES : assigned_to
|
A_ROLES ||--o{ A_USER_ROLES : assigned_to
|
||||||
A_ROLES ||--o{ A_ROLE_PERMISSIONS : has
|
A_ROLES ||--o{ A_ROLE_PERMISSIONS : has
|
||||||
A_PERMISSIONS ||--o{ A_ROLE_PERMISSIONS : granted_to
|
A_PERMISSIONS ||--o{ A_ROLE_PERMISSIONS : granted_to
|
||||||
A_USERS ||--o{ A_SESSIONS : owns
|
A_USERS ||--o{ A_SESSIONS : owns
|
||||||
|
A_USERS ||--o{ O_AUDIT_EVENTS : acts
|
||||||
|
|
||||||
C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by
|
C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by
|
||||||
C_CANVAS_SIZES ||--o{ C_PLAYLISTS : used_by
|
C_CANVAS_SIZES ||--o{ C_PLAYLISTS : used_by
|
||||||
|
|||||||
Generated
+570
-984
File diff suppressed because it is too large
Load Diff
+11
-8
@@ -1,8 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.7.3",
|
"version": "2.8.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=26.0.0"
|
||||||
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.lzstealth.com/LZStealth/pulse-signage.git"
|
"url": "https://git.lzstealth.com/LZStealth/pulse-signage.git"
|
||||||
@@ -17,19 +20,19 @@
|
|||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^137.0.0",
|
"@sparticuz/chromium": "^149.0.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"bootstrap-icons": "1.11.3",
|
"bootstrap-icons": "1.13.1",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.21.2",
|
"express": "^5.2.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"hls.js": "^1.5.15",
|
"hls.js": "^1.7.0",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"mysql2": "^3.14.3",
|
"mysql2": "^3.23.3",
|
||||||
"puppeteer-core": "^24.16.0",
|
"puppeteer-core": "^25.7.0",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.10"
|
"nodemon": "^3.1.10"
|
||||||
|
|||||||
+37
-3
@@ -7,21 +7,55 @@ const PASSWORD_KEY_LENGTH = 32;
|
|||||||
const PASSWORD_DIGEST = 'sha256';
|
const PASSWORD_DIGEST = 'sha256';
|
||||||
const SESSION_BYTES = 32;
|
const SESSION_BYTES = 32;
|
||||||
|
|
||||||
function validatePasswordStrength(password) {
|
function validatePasswordStrength(password, options) {
|
||||||
const value = String(password || '');
|
const value = String(password || '');
|
||||||
|
const requirements = options && options.policy
|
||||||
|
? getPasswordPolicyPreset(options.policy)
|
||||||
|
: {
|
||||||
|
minimumLength: Number(options && options.minimumLength) || 10,
|
||||||
|
minimumCategories: Number(options && options.minimumCategories) || 3,
|
||||||
|
requireLowercase: Boolean(options && options.requireLowercase),
|
||||||
|
requireUppercase: Boolean(options && options.requireUppercase),
|
||||||
|
requireNumber: Boolean(options && options.requireNumber),
|
||||||
|
requireSymbol: Boolean(options && options.requireSymbol)
|
||||||
|
};
|
||||||
const hasLowercase = /[a-z]/.test(value);
|
const hasLowercase = /[a-z]/.test(value);
|
||||||
const hasUppercase = /[A-Z]/.test(value);
|
const hasUppercase = /[A-Z]/.test(value);
|
||||||
const hasNumber = /[0-9]/.test(value);
|
const hasNumber = /[0-9]/.test(value);
|
||||||
const hasSymbol = /[^A-Za-z0-9]/.test(value);
|
const hasSymbol = /[^A-Za-z0-9]/.test(value);
|
||||||
const categoryCount = [hasLowercase, hasUppercase, hasNumber, hasSymbol].filter(Boolean).length;
|
const categoryCount = [hasLowercase, hasUppercase, hasNumber, hasSymbol].filter(Boolean).length;
|
||||||
|
|
||||||
if (value.length < 10 || categoryCount < 3) {
|
const missingRequiredCategory = requirements.requireLowercase && !hasLowercase
|
||||||
return 'Password must be at least 10 characters and include 3 of: uppercase, lowercase, number, and symbol.';
|
|| requirements.requireUppercase && !hasUppercase
|
||||||
|
|| requirements.requireNumber && !hasNumber
|
||||||
|
|| requirements.requireSymbol && !hasSymbol;
|
||||||
|
if (value.length < requirements.minimumLength || categoryCount < requirements.minimumCategories || missingRequiredCategory) {
|
||||||
|
if (missingRequiredCategory) {
|
||||||
|
const requiredCategories = [];
|
||||||
|
if (requirements.requireLowercase) requiredCategories.push('lowercase');
|
||||||
|
if (requirements.requireUppercase) requiredCategories.push('uppercase');
|
||||||
|
if (requirements.requireNumber) requiredCategories.push('number');
|
||||||
|
if (requirements.requireSymbol) requiredCategories.push('symbol');
|
||||||
|
return `Password must be at least ${requirements.minimumLength} characters and include ${requiredCategories.join(', ')}.`;
|
||||||
|
}
|
||||||
|
if (requirements.minimumCategories === 4) {
|
||||||
|
return `Password must be at least ${requirements.minimumLength} characters and include uppercase, lowercase, number, and symbol.`;
|
||||||
|
}
|
||||||
|
return `Password must be at least ${requirements.minimumLength} characters and include ${requirements.minimumCategories} of: uppercase, lowercase, number, and symbol.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPasswordPolicyPreset(policy) {
|
||||||
|
const normalizedPolicy = String(policy || 'standard').trim().toLowerCase();
|
||||||
|
return normalizedPolicy === 'strict'
|
||||||
|
? { minimumLength: 14, minimumCategories: 4 }
|
||||||
|
: normalizedPolicy === 'strong'
|
||||||
|
? { minimumLength: 12, minimumCategories: 3 }
|
||||||
|
: { minimumLength: 10, minimumCategories: 3 };
|
||||||
|
}
|
||||||
|
|
||||||
function hashPassword(password, salt) {
|
function hashPassword(password, salt) {
|
||||||
const safePassword = String(password || '');
|
const safePassword = String(password || '');
|
||||||
const safeSalt = salt || crypto.randomBytes(16).toString('hex');
|
const safeSalt = salt || crypto.randomBytes(16).toString('hex');
|
||||||
|
|||||||
+1
-2
@@ -27,7 +27,7 @@ const dbBootstrap = require('#src/db/bootstrap');
|
|||||||
const data = require('#src/data');
|
const data = require('#src/data');
|
||||||
const player = require('#src/player/render');
|
const player = require('#src/player/render');
|
||||||
const listQuery = require('#src/web/lib/list-query');
|
const listQuery = require('#src/web/lib/list-query');
|
||||||
const { fetchPlaylistCanvasId, fetchPlaylistCanvasSignature } = require('#src/web/lib/helpers');
|
const { fetchPlaylistCanvasId } = require('#src/web/lib/helpers');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createPool: dbCommon.createPool,
|
createPool: dbCommon.createPool,
|
||||||
@@ -63,7 +63,6 @@ module.exports = {
|
|||||||
getSortDirectionQuery: listQuery.getSortDirectionQuery,
|
getSortDirectionQuery: listQuery.getSortDirectionQuery,
|
||||||
fetchPlaylistById: data.fetchPlaylistById,
|
fetchPlaylistById: data.fetchPlaylistById,
|
||||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
|
||||||
normalizeDisplayMode: data.normalizeDisplayMode,
|
normalizeDisplayMode: data.normalizeDisplayMode,
|
||||||
fetchTimetablesData: data.fetchTimetablesData,
|
fetchTimetablesData: data.fetchTimetablesData,
|
||||||
fetchTimetableGroupsPage: data.fetchTimetableGroupsPage,
|
fetchTimetableGroupsPage: data.fetchTimetableGroupsPage,
|
||||||
|
|||||||
@@ -1,55 +1,69 @@
|
|||||||
const ANNOUNCEMENT_ICON_OPTIONS = [
|
const fs = require('fs');
|
||||||
{ value: 'megaphone-fill', label: 'Megaphone' },
|
const path = require('path');
|
||||||
{ value: 'megaphone', label: 'Megaphone outline' },
|
|
||||||
{ value: 'bell-fill', label: 'Bell' },
|
const BOOTSTRAP_ICON_CSS_PATH = path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons', 'css', 'bootstrap-icons.min.css');
|
||||||
{ value: 'bell', label: 'Bell outline' },
|
|
||||||
{ value: 'exclamation-triangle-fill', label: 'Warning' },
|
const DEFAULT_ANNOUNCEMENT_ICON_KEYS = [
|
||||||
{ value: 'exclamation-triangle', label: 'Warning outline' },
|
'megaphone-fill', 'megaphone', 'bell-fill', 'bell',
|
||||||
{ value: 'info-circle-fill', label: 'Info' },
|
'exclamation-triangle-fill', 'exclamation-triangle', 'info-circle-fill', 'info-circle',
|
||||||
{ value: 'info-circle', label: 'Info outline' },
|
'check-circle-fill', 'check-circle', 'lightbulb-fill', 'lightbulb',
|
||||||
{ value: 'check-circle-fill', label: 'Success' },
|
'calendar-event-fill', 'calendar-event', 'clock-fill', 'clock',
|
||||||
{ value: 'check-circle', label: 'Success outline' },
|
'wifi-off', 'wifi', 'hdd-network', 'hdd-network-fill',
|
||||||
{ value: 'lightbulb-fill', label: 'Idea' },
|
'speaker-fill', 'speaker', 'shield-fill', 'shield',
|
||||||
{ value: 'lightbulb', label: 'Idea outline' },
|
'collection-play-fill', 'collection-play', 'broadcast', 'broadcast-pin',
|
||||||
{ value: 'calendar-event-fill', label: 'Calendar' },
|
'plug-fill', 'plug', 'lightning-charge-fill', 'lightning-charge',
|
||||||
{ value: 'calendar-event', label: 'Calendar outline' },
|
'car-front-fill', 'car-front', 'lamp-fill', 'lamp',
|
||||||
{ value: 'clock-fill', label: 'Clock' },
|
'envelope-fill', 'envelope', 'people-fill', 'people',
|
||||||
{ value: 'clock', label: 'Clock outline' },
|
'browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari',
|
||||||
{ value: 'wifi-off', label: 'Wi-Fi Offline' },
|
'cone', 'cone-striped', 'cup-straw', 'fire'
|
||||||
{ value: 'wifi', label: 'Wi-Fi' },
|
|
||||||
{ value: 'hdd-network', label: 'Network' },
|
|
||||||
{ value: 'hdd-network-fill', label: 'Network fill' },
|
|
||||||
{ value: 'speaker-fill', label: 'Speaker' },
|
|
||||||
{ value: 'speaker', label: 'Speaker outline' },
|
|
||||||
{ value: 'shield-fill', label: 'Shield' },
|
|
||||||
{ value: 'shield', label: 'Shield outline' },
|
|
||||||
{ value: 'collection-play-fill', label: 'Playlist' },
|
|
||||||
{ value: 'collection-play', label: 'Playlist outline' },
|
|
||||||
{ value: 'broadcast', label: 'Broadcast' },
|
|
||||||
{ value: 'broadcast-pin', label: 'Broadcast pin' },
|
|
||||||
{ value: 'plug-fill', label: 'Plug' },
|
|
||||||
{ value: 'plug', label: 'Plug outline' },
|
|
||||||
{ value: 'lightning-charge-fill', label: 'Urgent' },
|
|
||||||
{ value: 'lightning-charge', label: 'Urgent outline' },
|
|
||||||
{ value: 'car-front-fill', label: 'Car Front' },
|
|
||||||
{ value: 'car-front', label: 'Car Front outline' },
|
|
||||||
{ value: 'lamp-fill', label: 'Lamp' },
|
|
||||||
{ value: 'lamp', label: 'Lamp outline' },
|
|
||||||
{ value: 'envelope-fill', label: 'Message' },
|
|
||||||
{ value: 'envelope', label: 'Message outline' },
|
|
||||||
{ value: 'people-fill', label: 'Audience' },
|
|
||||||
{ value: 'people', label: 'Audience outline' },
|
|
||||||
{ value: 'browser-chrome', label: 'Browser Chrome' },
|
|
||||||
{ value: 'browser-edge', label: 'Browser Edge' },
|
|
||||||
{ value: 'browser-firefox', label: 'Browser Firefox' },
|
|
||||||
{ value: 'browser-safari', label: 'Browser Safari' },
|
|
||||||
{ value: 'cone', label: 'Cone' },
|
|
||||||
{ value: 'cone-striped', label: 'Cone striped' },
|
|
||||||
{ value: 'cup-straw', label: 'Cup straw' },
|
|
||||||
{ value: 'fire', label: 'Fire' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const ANNOUNCEMENT_ICON_KEYS = ANNOUNCEMENT_ICON_OPTIONS.map(function (option) {
|
function humanizeBootstrapIconLabel(iconKey) {
|
||||||
|
return String(iconKey || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[-_]+/g, ' ')
|
||||||
|
.replace(/\b\w/g, function (character) {
|
||||||
|
return character.toUpperCase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBootstrapIconCatalog() {
|
||||||
|
try {
|
||||||
|
const css = fs.readFileSync(BOOTSTRAP_ICON_CSS_PATH, 'utf8');
|
||||||
|
const keys = Array.from(new Set((css.match(/\.bi-([a-z0-9-]+)::?before/g) || []).map(function (match) {
|
||||||
|
return String(match || '')
|
||||||
|
.replace(/^\.bi-/, '')
|
||||||
|
.replace(/::?before$/, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}).filter(Boolean)));
|
||||||
|
|
||||||
|
return keys.map(function (value) {
|
||||||
|
return {
|
||||||
|
value: value,
|
||||||
|
label: humanizeBootstrapIconLabel(value)
|
||||||
|
};
|
||||||
|
}).sort(function (left, right) {
|
||||||
|
return left.value.localeCompare(right.value);
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
return DEFAULT_ANNOUNCEMENT_ICON_KEYS.map(function (value) {
|
||||||
|
return { value: value, label: humanizeBootstrapIconLabel(value) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANNOUNCEMENT_ICON_CATALOG = loadBootstrapIconCatalog();
|
||||||
|
const ANNOUNCEMENT_ICON_OPTIONS = DEFAULT_ANNOUNCEMENT_ICON_KEYS.map(function (value) {
|
||||||
|
const catalogOption = ANNOUNCEMENT_ICON_CATALOG.find(function (option) {
|
||||||
|
return option.value === value;
|
||||||
|
});
|
||||||
|
return catalogOption || { value: value, label: humanizeBootstrapIconLabel(value) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const ANNOUNCEMENT_ICON_KEYS = DEFAULT_ANNOUNCEMENT_ICON_KEYS.slice();
|
||||||
|
|
||||||
|
const ANNOUNCEMENT_ICON_CATALOG_KEYS = ANNOUNCEMENT_ICON_CATALOG.map(function (option) {
|
||||||
return option.value;
|
return option.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,17 +72,27 @@ const ANNOUNCEMENT_ICON_LABELS = ANNOUNCEMENT_ICON_OPTIONS.reduce(function (labe
|
|||||||
return labels;
|
return labels;
|
||||||
}, Object.create(null));
|
}, Object.create(null));
|
||||||
|
|
||||||
|
ANNOUNCEMENT_ICON_CATALOG.forEach(function (option) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(ANNOUNCEMENT_ICON_LABELS, option.value)) {
|
||||||
|
ANNOUNCEMENT_ICON_LABELS[option.value] = option.label;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const DEFAULT_ANNOUNCEMENT_ICON = 'megaphone-fill';
|
const DEFAULT_ANNOUNCEMENT_ICON = 'megaphone-fill';
|
||||||
|
|
||||||
function normalizeAnnouncementIcon(value) {
|
function normalizeAnnouncementIcon(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
return ANNOUNCEMENT_ICON_KEYS.includes(normalized) ? normalized : DEFAULT_ANNOUNCEMENT_ICON;
|
return ANNOUNCEMENT_ICON_CATALOG_KEYS.includes(normalized) ? normalized : DEFAULT_ANNOUNCEMENT_ICON;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
DEFAULT_ANNOUNCEMENT_ICON_KEYS,
|
||||||
ANNOUNCEMENT_ICON_OPTIONS,
|
ANNOUNCEMENT_ICON_OPTIONS,
|
||||||
ANNOUNCEMENT_ICON_KEYS,
|
ANNOUNCEMENT_ICON_KEYS,
|
||||||
|
ANNOUNCEMENT_ICON_CATALOG,
|
||||||
|
ANNOUNCEMENT_ICON_CATALOG_KEYS,
|
||||||
ANNOUNCEMENT_ICON_LABELS,
|
ANNOUNCEMENT_ICON_LABELS,
|
||||||
DEFAULT_ANNOUNCEMENT_ICON,
|
DEFAULT_ANNOUNCEMENT_ICON,
|
||||||
|
humanizeBootstrapIconLabel,
|
||||||
normalizeAnnouncementIcon
|
normalizeAnnouncementIcon
|
||||||
};
|
};
|
||||||
@@ -22,10 +22,6 @@ function normalizeAuthMethod(value) {
|
|||||||
return ['basic', 'bearer', 'api_key_header'].includes(method) ? method : 'none';
|
return ['basic', 'bearer', 'api_key_header'].includes(method) ? method : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemsPath(source) {
|
|
||||||
return String(source && (source.items_path || source.itemsPath) || '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAuthHeaders(source) {
|
function buildAuthHeaders(source) {
|
||||||
const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod));
|
const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod));
|
||||||
const headers = {};
|
const headers = {};
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('./announcement-icons');
|
||||||
|
|
||||||
|
const SETTING_DEFINITIONS = [
|
||||||
|
{ key: 'app.name', type: 'string', defaultValue: 'Pulse Signage' },
|
||||||
|
{ key: 'locale.timezone', type: 'string', defaultValue: 'Europe/London' },
|
||||||
|
{ key: 'locale.language', type: 'string', defaultValue: 'en' },
|
||||||
|
{ key: 'ui.theme', type: 'enum', values: ['dark', 'light', 'auto'], defaultValue: 'dark' },
|
||||||
|
{ key: 'security.session_lifetime_days', type: 'integer', min: 1, defaultValue: 14 },
|
||||||
|
{ key: 'security.allow_user_session_revocation', type: 'boolean', defaultValue: true },
|
||||||
|
{ key: 'security.max_active_sessions', type: 'integer', min: 0, defaultValue: 0 },
|
||||||
|
{ key: 'security.password_min_length', type: 'integer', min: 8, defaultValue: 10 },
|
||||||
|
{ key: 'security.password_min_categories', type: 'integer', min: 1, defaultValue: 3 },
|
||||||
|
{ key: 'security.password_require_lowercase', type: 'boolean', defaultValue: false },
|
||||||
|
{ key: 'security.password_require_uppercase', type: 'boolean', defaultValue: false },
|
||||||
|
{ key: 'security.password_require_number', type: 'boolean', defaultValue: false },
|
||||||
|
{ key: 'security.password_require_symbol', type: 'boolean', defaultValue: false },
|
||||||
|
{ key: 'security.require_password_change_for_new_users', type: 'boolean', defaultValue: true },
|
||||||
|
{ key: 'security.require_password_change_after_admin_reset', type: 'boolean', defaultValue: true },
|
||||||
|
{ key: 'security.login_max_attempts', type: 'integer', min: 1, defaultValue: 5 },
|
||||||
|
{ key: 'security.login_lockout_minutes', type: 'integer', min: 1, defaultValue: 15 },
|
||||||
|
{ key: 'security.login_rate_limit_scope', type: 'enum', values: ['both', 'username', 'ip'], defaultValue: 'both' },
|
||||||
|
{ key: 'audit.enabled', type: 'boolean', defaultValue: true },
|
||||||
|
{ key: 'audit.categories', type: 'string_array', defaultValue: ['authentication', 'security', 'sessions', 'users', 'roles', 'system-settings'] },
|
||||||
|
{ key: 'audit.include_request_metadata', type: 'boolean', defaultValue: true },
|
||||||
|
{ key: 'audit.retention_days', type: 'integer', min: 0, defaultValue: 180 },
|
||||||
|
{ key: 'uploads.image_max_bytes', type: 'integer', min: 1, defaultValue: 100 * 1024 * 1024 },
|
||||||
|
{ key: 'uploads.video_max_bytes', type: 'integer', min: 1, defaultValue: 1024 * 1024 * 1024 },
|
||||||
|
{ key: 'uploads.wysiwyg_image_max_bytes', type: 'integer', min: 1, defaultValue: 2 * 1024 * 1024 },
|
||||||
|
{ key: 'uploads.allowed_mime_types', type: 'string_array', defaultValue: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'video/mp4', 'video/webm', 'video/ogg'] },
|
||||||
|
{ key: 'uploads.cleanup_days', type: 'integer', min: 0, defaultValue: 30 },
|
||||||
|
{ key: 'uploads.optimize_images', type: 'boolean', defaultValue: true },
|
||||||
|
{ key: 'announcements.default_icon', type: 'string', defaultValue: 'megaphone-fill' },
|
||||||
|
{ key: 'announcements.default_duration_value', type: 'integer', min: 1, defaultValue: 10 },
|
||||||
|
{ key: 'announcements.default_duration_unit', type: 'enum', values: ['seconds', 'minutes'], defaultValue: 'seconds' },
|
||||||
|
{ key: 'announcements.suggested_icons', type: 'string_array', defaultValue: DEFAULT_ANNOUNCEMENT_ICON_KEYS.slice() },
|
||||||
|
{ key: 'player.default_slide_duration_seconds', type: 'integer', min: 1, defaultValue: 10 },
|
||||||
|
{ key: 'player.default_fade_between_slides', type: 'boolean', defaultValue: true },
|
||||||
|
{ key: 'player.skip_unavailable_rtmp', type: 'boolean', defaultValue: true }
|
||||||
|
,{ key: 'data-sources.rss_default_interval_value', type: 'integer', min: 1, defaultValue: 60 }
|
||||||
|
,{ key: 'data-sources.rss_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' }
|
||||||
|
,{ key: 'data-sources.api_default_interval_value', type: 'integer', min: 1, defaultValue: 60 }
|
||||||
|
,{ key: 'data-sources.api_default_interval_unit', type: 'enum', values: ['seconds', 'minutes', 'hours'], defaultValue: 'minutes' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFINITIONS_BY_KEY = new Map(SETTING_DEFINITIONS.map(function (definition) {
|
||||||
|
return [definition.key, definition];
|
||||||
|
}));
|
||||||
|
|
||||||
|
function cloneValue(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.slice();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppSettingDefinitions() {
|
||||||
|
return SETTING_DEFINITIONS.map(function (definition) {
|
||||||
|
return Object.assign({}, definition, {
|
||||||
|
values: definition.values ? definition.values.slice() : undefined,
|
||||||
|
defaultValue: cloneValue(definition.defaultValue)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultAppSettings() {
|
||||||
|
return SETTING_DEFINITIONS.reduce(function (settings, definition) {
|
||||||
|
settings[definition.key] = cloneValue(definition.defaultValue);
|
||||||
|
return settings;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettingValue(key, value) {
|
||||||
|
const definition = DEFINITIONS_BY_KEY.get(String(key || '').trim());
|
||||||
|
if (!definition) {
|
||||||
|
throw new Error('Unknown application setting: ' + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'string') {
|
||||||
|
return String(value == null ? '' : value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'integer') {
|
||||||
|
const normalized = Number(value);
|
||||||
|
if (!Number.isInteger(normalized) || normalized < definition.min) {
|
||||||
|
throw new Error('Invalid value for application setting: ' + definition.key);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'boolean') {
|
||||||
|
if (value === true || value === 1 || value === '1' || value === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (value === false || value === 0 || value === '0' || value === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new Error('Invalid value for application setting: ' + definition.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'enum') {
|
||||||
|
const normalized = String(value == null ? '' : value).trim();
|
||||||
|
if (!definition.values.includes(normalized)) {
|
||||||
|
throw new Error('Invalid value for application setting: ' + definition.key);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.type === 'string_array') {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error('Invalid value for application setting: ' + definition.key);
|
||||||
|
}
|
||||||
|
return Array.from(new Set(value.map(function (item) {
|
||||||
|
return String(item || '').trim();
|
||||||
|
}).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported application setting type: ' + definition.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAppSettings(settings) {
|
||||||
|
const input = settings && typeof settings === 'object' ? settings : {};
|
||||||
|
return SETTING_DEFINITIONS.reduce(function (normalized, definition) {
|
||||||
|
const value = Object.prototype.hasOwnProperty.call(input, definition.key)
|
||||||
|
? input[definition.key]
|
||||||
|
: definition.defaultValue;
|
||||||
|
normalized[definition.key] = normalizeSettingValue(definition.key, value);
|
||||||
|
return normalized;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredValue(value) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (_error) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAppSettings(pool) {
|
||||||
|
const [rows] = await pool.query('SELECT id, setting_key, setting_value FROM o_app_settings ORDER BY setting_key');
|
||||||
|
const storedSettings = {};
|
||||||
|
(rows || []).forEach(function (row) {
|
||||||
|
const key = String(row && row.setting_key || '').trim();
|
||||||
|
if (DEFINITIONS_BY_KEY.has(key)) {
|
||||||
|
storedSettings[key] = parseStoredValue(row.setting_value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return normalizeAppSettings(Object.assign({}, getDefaultAppSettings(), storedSettings));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAppSettings(pool, settings, modifiedBy) {
|
||||||
|
const inputSettings = settings && typeof settings === 'object' ? settings : {};
|
||||||
|
const normalizedSettings = normalizeAppSettings(inputSettings);
|
||||||
|
const connection = typeof pool.getConnection === 'function' ? await pool.getConnection() : pool;
|
||||||
|
const shouldRelease = connection !== pool;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof connection.beginTransaction === 'function') {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
}
|
||||||
|
for (const definition of SETTING_DEFINITIONS) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(inputSettings, definition.key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await connection.query(
|
||||||
|
`UPDATE o_app_settings
|
||||||
|
SET setting_value = ?, modified_by = ?
|
||||||
|
WHERE setting_key = ?`,
|
||||||
|
[JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, definition.key]
|
||||||
|
);
|
||||||
|
await connection.query(
|
||||||
|
`INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by)
|
||||||
|
SELECT ?, ?, ?, ?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM o_app_settings WHERE setting_key = ?
|
||||||
|
)`,
|
||||||
|
[definition.key, JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, modifiedBy || null, definition.key]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof connection.commit === 'function') {
|
||||||
|
await connection.commit();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof connection.rollback === 'function') {
|
||||||
|
await connection.rollback();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (shouldRelease && typeof connection.release === 'function') {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAppSettingDefinitions,
|
||||||
|
getDefaultAppSettings,
|
||||||
|
normalizeSettingValue,
|
||||||
|
normalizeAppSettings,
|
||||||
|
fetchAppSettings,
|
||||||
|
saveAppSettings
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
const AUDIT_EVENT_CATEGORIES = Object.freeze({
|
||||||
|
AUTHENTICATION: 'authentication',
|
||||||
|
SECURITY: 'security',
|
||||||
|
SESSIONS: 'sessions',
|
||||||
|
USERS: 'users',
|
||||||
|
ROLES: 'roles',
|
||||||
|
SETTINGS: 'system-settings',
|
||||||
|
SLIDES: 'slides',
|
||||||
|
TEMPLATES: 'templates',
|
||||||
|
PLAYLISTS: 'playlists',
|
||||||
|
SCREENS: 'screens',
|
||||||
|
ANNOUNCEMENTS: 'announcements',
|
||||||
|
CANVAS_SIZES: 'canvas-sizes',
|
||||||
|
API_SOURCES: 'api-sources',
|
||||||
|
RSS_FEEDS: 'rss-feeds',
|
||||||
|
TIMETABLES: 'timetables'
|
||||||
|
});
|
||||||
|
const AUDIT_CATEGORY_KEYS = Object.freeze(Object.values(AUDIT_EVENT_CATEGORIES));
|
||||||
|
const AUDIT_CATEGORY_LABELS = Object.freeze({
|
||||||
|
authentication: 'Authentication',
|
||||||
|
security: 'Security',
|
||||||
|
sessions: 'Sessions',
|
||||||
|
users: 'Users',
|
||||||
|
roles: 'Roles',
|
||||||
|
'system-settings': 'System Settings',
|
||||||
|
slides: 'Slides',
|
||||||
|
templates: 'Templates',
|
||||||
|
playlists: 'Playlists',
|
||||||
|
screens: 'Screens',
|
||||||
|
announcements: 'Announcements',
|
||||||
|
'canvas-sizes': 'Canvas Sizes',
|
||||||
|
'api-sources': 'API Sources',
|
||||||
|
'rss-feeds': 'RSS Feeds',
|
||||||
|
timetables: 'Timetables'
|
||||||
|
});
|
||||||
|
const { fetchAppSettings } = require('./app-settings');
|
||||||
|
|
||||||
|
function normalizeDetails(details) {
|
||||||
|
if (details === undefined || details === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.stringify(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuditChanges(previousValues, nextValues) {
|
||||||
|
const previous = previousValues && typeof previousValues === 'object' ? previousValues : {};
|
||||||
|
const next = nextValues && typeof nextValues === 'object' ? nextValues : {};
|
||||||
|
const changes = {};
|
||||||
|
const keys = new Set(Object.keys(previous).concat(Object.keys(next)));
|
||||||
|
|
||||||
|
keys.forEach(function (key) {
|
||||||
|
if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) {
|
||||||
|
changes[key] = { from: previous[key], to: next[key] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestMetadata(req) {
|
||||||
|
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||||
|
return {
|
||||||
|
ipAddress: forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || '').trim() || null,
|
||||||
|
userAgent: String(req && req.headers && req.headers['user-agent'] || '').trim() || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordAuditEvent(pool, event) {
|
||||||
|
const input = event && typeof event === 'object' ? event : {};
|
||||||
|
const category = String(input.category || '').trim().toLowerCase();
|
||||||
|
const eventType = String(input.eventType || '').trim().toLowerCase();
|
||||||
|
if (!category || !eventType) {
|
||||||
|
throw new Error('Audit events require a category and event type.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO o_audit_events
|
||||||
|
(category, event_type, actor_user_id, target_type, target_id, target_label, ip_address, user_agent, details_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
category,
|
||||||
|
eventType,
|
||||||
|
Number.isInteger(Number(input.actorUserId)) && Number(input.actorUserId) > 0 ? Number(input.actorUserId) : null,
|
||||||
|
String(input.targetType || '').trim() || null,
|
||||||
|
String(input.targetId || '').trim() || null,
|
||||||
|
String(input.targetLabel || '').trim() || null,
|
||||||
|
String(input.ipAddress || '').trim() || null,
|
||||||
|
String(input.userAgent || '').trim() || null,
|
||||||
|
normalizeDetails(input.details)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordRequestAuditEvent(pool, req, event) {
|
||||||
|
try {
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
const category = String(event && event.category || '').trim().toLowerCase();
|
||||||
|
const enabledCategories = Array.isArray(settings['audit.categories']) ? settings['audit.categories'] : AUDIT_CATEGORY_KEYS;
|
||||||
|
if (!settings['audit.enabled'] || !enabledCategories.includes(category)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const metadata = settings['audit.include_request_metadata'] ? getRequestMetadata(req) : {};
|
||||||
|
await recordAuditEvent(pool, Object.assign({}, event, metadata));
|
||||||
|
} catch (error) {
|
||||||
|
// Auditing must not turn a successful login or administration action into a failed request.
|
||||||
|
console.error('Unable to record audit event:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
AUDIT_EVENT_CATEGORIES,
|
||||||
|
AUDIT_CATEGORY_KEYS,
|
||||||
|
AUDIT_CATEGORY_LABELS,
|
||||||
|
getRequestMetadata,
|
||||||
|
buildAuditChanges,
|
||||||
|
recordAuditEvent,
|
||||||
|
recordRequestAuditEvent
|
||||||
|
};
|
||||||
+25
-14
@@ -92,15 +92,19 @@ async function upsertPlayerRegistration(pool, options) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE d_players
|
||||||
|
SET public_base_url = ?, internal_base_url = ?, last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE identifier = ?`,
|
||||||
|
[publicBaseUrl || null, internalBaseUrl || null, identifier]
|
||||||
|
);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
|
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
|
||||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
SELECT ?, ?, ?, CURRENT_TIMESTAMP
|
||||||
ON DUPLICATE KEY UPDATE
|
WHERE NOT EXISTS (
|
||||||
public_base_url = VALUES(public_base_url),
|
SELECT 1 FROM d_players WHERE identifier = ?
|
||||||
internal_base_url = VALUES(internal_base_url),
|
)`,
|
||||||
last_seen_at = CURRENT_TIMESTAMP,
|
[identifier, publicBaseUrl || null, internalBaseUrl || null, identifier]
|
||||||
modified_at = CURRENT_TIMESTAMP`,
|
|
||||||
[identifier, publicBaseUrl || null, internalBaseUrl || null]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return resolvePlayerRegistration(pool, identifier);
|
return resolvePlayerRegistration(pool, identifier);
|
||||||
@@ -115,15 +119,22 @@ async function recordPlayerHeartbeat(pool, options) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE d_players
|
||||||
|
SET public_base_url = COALESCE(?, public_base_url),
|
||||||
|
internal_base_url = COALESCE(?, internal_base_url),
|
||||||
|
last_seen_at = CURRENT_TIMESTAMP,
|
||||||
|
modified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE identifier = ?`,
|
||||||
|
[publicBaseUrl || null, internalBaseUrl || null, identifier]
|
||||||
|
);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
|
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
|
||||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
SELECT ?, ?, ?, CURRENT_TIMESTAMP
|
||||||
ON DUPLICATE KEY UPDATE
|
WHERE NOT EXISTS (
|
||||||
public_base_url = COALESCE(VALUES(public_base_url), public_base_url),
|
SELECT 1 FROM d_players WHERE identifier = ?
|
||||||
internal_base_url = COALESCE(VALUES(internal_base_url), internal_base_url),
|
)`,
|
||||||
last_seen_at = CURRENT_TIMESTAMP,
|
[identifier, publicBaseUrl || null, internalBaseUrl || null, identifier]
|
||||||
modified_at = CURRENT_TIMESTAMP`,
|
|
||||||
[identifier, publicBaseUrl || null, internalBaseUrl || null]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return resolvePlayerRegistration(pool, identifier);
|
return resolvePlayerRegistration(pool, identifier);
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
|
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
|
||||||
const QR_PNG_WIDTH = 2048;
|
const QR_PNG_WIDTH = 2048;
|
||||||
const QR_STYLING_SCRIPT_PATH = path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js');
|
|
||||||
const SYSTEM_CHROMIUM_PATHS = [
|
|
||||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
|
||||||
process.env.CHROMIUM_PATH,
|
|
||||||
'/usr/bin/chromium',
|
|
||||||
'/usr/bin/chromium-browser',
|
|
||||||
'/usr/local/bin/chromium',
|
|
||||||
'/snap/bin/chromium'
|
|
||||||
].filter(Boolean);
|
|
||||||
let qrBrowserPromise = null;
|
|
||||||
|
|
||||||
function escapeXml(value) {
|
function escapeXml(value) {
|
||||||
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, function (character) {
|
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, function (character) {
|
||||||
@@ -340,81 +329,6 @@ function buildQrStylingOptions(source) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getQrBrowser() {
|
|
||||||
if (qrBrowserPromise) {
|
|
||||||
return qrBrowserPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
qrBrowserPromise = (async function () {
|
|
||||||
const puppeteer = require('puppeteer-core');
|
|
||||||
const chromiumModule = require('@sparticuz/chromium');
|
|
||||||
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
|
||||||
? chromiumModule
|
|
||||||
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
|
||||||
? chromiumModule.default
|
|
||||||
: chromiumModule;
|
|
||||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
|
||||||
return fs.existsSync(candidate);
|
|
||||||
}) || '';
|
|
||||||
const usingSystemChromium = Boolean(executablePath);
|
|
||||||
|
|
||||||
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
|
|
||||||
executablePath = await chromium.executablePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
|
||||||
throw new Error('Chromium executable was not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return puppeteer.launch({
|
|
||||||
args: usingSystemChromium
|
|
||||||
? [
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
'--disable-gpu'
|
|
||||||
]
|
|
||||||
: puppeteer.defaultArgs({
|
|
||||||
args: chromium && chromium.args ? chromium.args : [],
|
|
||||||
headless: 'shell'
|
|
||||||
}),
|
|
||||||
defaultViewport: usingSystemChromium
|
|
||||||
? { width: QR_PNG_WIDTH, height: QR_PNG_WIDTH, deviceScaleFactor: 1 }
|
|
||||||
: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
|
|
||||||
executablePath: executablePath,
|
|
||||||
headless: usingSystemChromium ? true : 'shell'
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
return qrBrowserPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function blobToDataUrl(blob) {
|
|
||||||
if (!blob) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof blob === 'string') {
|
|
||||||
return blob;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof blob.arrayBuffer === 'function') {
|
|
||||||
const buffer = await blob.arrayBuffer();
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let binary = '';
|
|
||||||
for (let index = 0; index < bytes.length; index += 1) {
|
|
||||||
binary += String.fromCharCode(bytes[index]);
|
|
||||||
}
|
|
||||||
return 'data:' + String(blob.type || 'image/png') + ';base64,' + Buffer.from(binary, 'binary').toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof blob.text === 'function') {
|
|
||||||
return blob.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createStyledQrCodeDataUrl(value) {
|
async function createStyledQrCodeDataUrl(value) {
|
||||||
const source = value && typeof value === 'object' ? value : { value: value };
|
const source = value && typeof value === 'object' ? value : { value: value };
|
||||||
const options = buildQrStylingOptions(source);
|
const options = buildQrStylingOptions(source);
|
||||||
@@ -435,10 +349,6 @@ async function createStyledQrCodeSvg(value) {
|
|||||||
return renderStyledQrRawData(options, 'svg');
|
return renderStyledQrRawData(options, 'svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createQrCodeDataUrlPlain(value) {
|
|
||||||
return createStyledQrCodeDataUrl(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createQrCodeSvg(value) {
|
async function createQrCodeSvg(value) {
|
||||||
return createStyledQrCodeSvg(value);
|
return createStyledQrCodeSvg(value);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-10
@@ -93,14 +93,6 @@ function sanitizeRichText(html) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePlainText(value) {
|
|
||||||
return String(value === undefined || value === null ? '' : value)
|
|
||||||
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
|
|
||||||
.replace(/<[^>]*>/g, '')
|
|
||||||
.replace(/ /gi, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripEditorOnlyMarkup(value) {
|
function stripEditorOnlyMarkup(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '')
|
.replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '')
|
||||||
@@ -426,7 +418,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
|
|||||||
content[region.region_key] = {
|
content[region.region_key] = {
|
||||||
type: 'rss',
|
type: 'rss',
|
||||||
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
|
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
|
||||||
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId),
|
feed_id: feedId === undefined || feedId === null ? (current.feed_id || null) : (feedId === '' ? null : Number(feedId)),
|
||||||
item_number: Math.min(itemCount, Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1),
|
item_number: Math.min(itemCount, Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1),
|
||||||
variable_name: 'item',
|
variable_name: 'item',
|
||||||
font_family: style.font_family,
|
font_family: style.font_family,
|
||||||
@@ -444,7 +436,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
|
|||||||
content[region.region_key] = {
|
content[region.region_key] = {
|
||||||
type: 'api',
|
type: 'api',
|
||||||
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
|
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
|
||||||
source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId),
|
source_id: sourceId === undefined || sourceId === null ? (current.source_id || null) : (sourceId === '' ? null : Number(sourceId)),
|
||||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||||
items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(),
|
items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(),
|
||||||
variable_name: 'item',
|
variable_name: 'item',
|
||||||
|
|||||||
@@ -194,43 +194,6 @@ function extractTemplateRegions(body) {
|
|||||||
return regions;
|
return regions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractGenericRegionContent(region, body, filesByField, existingContent) {
|
|
||||||
const content = {};
|
|
||||||
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
|
|
||||||
const suffix = '_' + region.id;
|
|
||||||
|
|
||||||
Object.keys(body || {}).forEach((key) => {
|
|
||||||
if (!key.startsWith('region_') || !key.endsWith(suffix)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const field = key.slice('region_'.length, -suffix.length);
|
|
||||||
if (!field || field === 'type' || field === 'key' || field === 'name' || field === 'label') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
content[field] = body[key];
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(filesByField || {}).forEach((fieldName) => {
|
|
||||||
if (!fieldName.startsWith('region_') || !fieldName.endsWith(suffix)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const field = fieldName.slice('region_'.length, -suffix.length);
|
|
||||||
if (!field) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
content[field] = `/media/uploads/${filesByField[fieldName].filename}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(current).forEach((key) => {
|
|
||||||
if (content[key] === undefined) {
|
|
||||||
content[key] = current[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
content.type = region.region_type;
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilesByField(files) {
|
function getFilesByField(files) {
|
||||||
const map = {};
|
const map = {};
|
||||||
(files || []).forEach((file) => {
|
(files || []).forEach((file) => {
|
||||||
|
|||||||
Vendored
+33
-10
@@ -15,11 +15,19 @@ async function bootstrapDatabase(pool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const permission of PERMISSIONS) {
|
for (const permission of PERMISSIONS) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE a_permissions
|
||||||
|
SET name = ?, section_name = ?, description = ?, modified_by = ?
|
||||||
|
WHERE permission_key = ?`,
|
||||||
|
[permission.name, permission.sectionName, permission.description || null, null, permission.key]
|
||||||
|
);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
|
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
SELECT ?, ?, ?, ?, ?, ?
|
||||||
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
|
WHERE NOT EXISTS (
|
||||||
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
|
SELECT 1 FROM a_permissions WHERE permission_key = ?
|
||||||
|
)`,
|
||||||
|
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null, permission.key]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,22 +43,37 @@ async function bootstrapDatabase(pool) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query('UPDATE a_users SET name = username WHERE name IS NULL OR name = ""');
|
const [legacyRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', ['administrators']);
|
||||||
|
const [currentRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||||
|
if (legacyRoleRows.length && !currentRoleRows.length) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE a_roles
|
||||||
|
SET role_key = ?, name = ?, description = ?, modified_by = ?
|
||||||
|
WHERE role_key = ?`,
|
||||||
|
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, 'administrators']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO a_roles (role_key, name, description, created_by, modified_by)
|
`INSERT INTO a_roles (role_key, name, description, created_by, modified_by)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
SELECT ?, ?, ?, ?, ?
|
||||||
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
|
WHERE NOT EXISTS (
|
||||||
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
|
SELECT 1 FROM a_roles WHERE role_key = ?
|
||||||
|
)`,
|
||||||
|
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null, DEFAULT_ROLE.key]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||||
const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null;
|
const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null;
|
||||||
if (defaultRoleId) {
|
if (defaultRoleId) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
|
`INSERT INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
|
||||||
SELECT ?, id, NULL, NULL FROM a_permissions`,
|
SELECT ?, permissions.id, NULL, NULL
|
||||||
[defaultRoleId]
|
FROM a_permissions permissions
|
||||||
|
LEFT JOIN a_role_permissions existing
|
||||||
|
ON existing.role_id = ? AND existing.permission_id = permissions.id
|
||||||
|
WHERE existing.id IS NULL`,
|
||||||
|
[defaultRoleId, defaultRoleId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-5
@@ -184,13 +184,14 @@ async function ensureSchema(pool, options) {
|
|||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS d_announcement_screens (
|
CREATE TABLE IF NOT EXISTS d_announcement_screens (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
announcement_id INT NOT NULL,
|
announcement_id INT NOT NULL,
|
||||||
screen_id INT NOT NULL,
|
screen_id INT NOT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL,
|
modified_by INT NULL,
|
||||||
PRIMARY KEY (announcement_id, screen_id),
|
UNIQUE KEY uq_announcement_screens_pair (announcement_id, screen_id),
|
||||||
INDEX idx_announcement_screens_screen_id (screen_id),
|
INDEX idx_announcement_screens_screen_id (screen_id),
|
||||||
CONSTRAINT fk_announcement_screens_announcement FOREIGN KEY (announcement_id) REFERENCES d_announcements(id) ON DELETE CASCADE,
|
CONSTRAINT fk_announcement_screens_announcement FOREIGN KEY (announcement_id) REFERENCES d_announcements(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_announcement_screens_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE CASCADE
|
CONSTRAINT fk_announcement_screens_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE CASCADE
|
||||||
@@ -278,7 +279,8 @@ async function ensureSchema(pool, options) {
|
|||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS d_onboarding_devices (
|
CREATE TABLE IF NOT EXISTS d_onboarding_devices (
|
||||||
device_id VARCHAR(128) PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
device_id VARCHAR(128) NOT NULL UNIQUE,
|
||||||
client_name VARCHAR(255) NULL,
|
client_name VARCHAR(255) NULL,
|
||||||
screen_id INT NULL,
|
screen_id INT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -297,6 +299,8 @@ async function ensureSchema(pool, options) {
|
|||||||
password_hash CHAR(64) NOT NULL,
|
password_hash CHAR(64) NOT NULL,
|
||||||
password_salt VARCHAR(64) NOT NULL,
|
password_salt VARCHAR(64) NOT NULL,
|
||||||
password_iterations INT NOT NULL,
|
password_iterations INT NOT NULL,
|
||||||
|
must_change_password TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
account_locked TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
@@ -333,13 +337,14 @@ async function ensureSchema(pool, options) {
|
|||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS a_role_permissions (
|
CREATE TABLE IF NOT EXISTS a_role_permissions (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
role_id INT NOT NULL,
|
role_id INT NOT NULL,
|
||||||
permission_id INT NOT NULL,
|
permission_id INT NOT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL,
|
modified_by INT NULL,
|
||||||
PRIMARY KEY (role_id, permission_id),
|
UNIQUE KEY uq_role_permissions_pair (role_id, permission_id),
|
||||||
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE,
|
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES a_permissions(id) ON DELETE CASCADE
|
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES a_permissions(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
@@ -347,13 +352,14 @@ async function ensureSchema(pool, options) {
|
|||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS a_user_roles (
|
CREATE TABLE IF NOT EXISTS a_user_roles (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
role_id INT NOT NULL,
|
role_id INT NOT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL,
|
modified_by INT NULL,
|
||||||
PRIMARY KEY (user_id, role_id),
|
UNIQUE KEY uq_user_roles_pair (user_id, role_id),
|
||||||
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
|
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE
|
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
@@ -361,8 +367,11 @@ async function ensureSchema(pool, options) {
|
|||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS a_sessions (
|
CREATE TABLE IF NOT EXISTS a_sessions (
|
||||||
session_hash CHAR(64) PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
session_hash CHAR(64) NOT NULL UNIQUE,
|
||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
|
ip_address VARCHAR(255) NULL,
|
||||||
|
user_agent VARCHAR(512) NULL,
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
@@ -372,6 +381,18 @@ async function ensureSchema(pool, options) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS a_login_attempts (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
rate_key VARCHAR(600) NOT NULL UNIQUE,
|
||||||
|
failed_count INT NOT NULL DEFAULT 0,
|
||||||
|
last_failed_at DATETIME NULL,
|
||||||
|
locked_until DATETIME NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS o_background_tasks (
|
CREATE TABLE IF NOT EXISTS o_background_tasks (
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -393,6 +414,18 @@ async function ensureSchema(pool, options) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS o_app_settings (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
setting_key VARCHAR(191) NOT NULL UNIQUE,
|
||||||
|
setting_value JSON NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
await runMigrations(pool, Object.assign({}, options, { currentVersion: currentVersion }));
|
await runMigrations(pool, Object.assign({}, options, { currentVersion: currentVersion }));
|
||||||
await recordSchemaVersion(pool, appVersion);
|
await recordSchemaVersion(pool, appVersion);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+117
-4
@@ -385,6 +385,113 @@ const VERSIONED_MIGRATIONS = [
|
|||||||
await pool.query('DROP TABLE i_schedule_groups');
|
await pool.query('DROP TABLE i_schedule_groups');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '2.8.0',
|
||||||
|
label: 'v2.8.0 combined application schema',
|
||||||
|
run: async function (pool) {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS o_app_settings (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
setting_key VARCHAR(191) NOT NULL UNIQUE,
|
||||||
|
setting_value JSON NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INT NULL,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
modified_by INT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS o_app_state (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
state_key VARCHAR(191) NOT NULL UNIQUE,
|
||||||
|
state_value MEDIUMTEXT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
const numericIdTables = [
|
||||||
|
['d_announcement_screens', 'uq_announcement_screens_pair', '(announcement_id, screen_id)'],
|
||||||
|
['d_onboarding_devices', 'uq_onboarding_devices_device_id', '(device_id)'],
|
||||||
|
['a_role_permissions', 'uq_role_permissions_pair', '(role_id, permission_id)'],
|
||||||
|
['a_user_roles', 'uq_user_roles_pair', '(user_id, role_id)'],
|
||||||
|
['a_sessions', 'uq_sessions_hash', '(session_hash)'],
|
||||||
|
['o_app_state', 'uq_app_state_key', '(state_key)']
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [tableName, uniqueKeyName, uniqueColumns] of numericIdTables) {
|
||||||
|
if (!(await tableExists(pool, tableName)) || await columnExists(pool, tableName, 'id')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await pool.query('ALTER TABLE ' + tableName + ' DROP PRIMARY KEY, ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST, ADD UNIQUE KEY ' + uniqueKeyName + ' ' + uniqueColumns);
|
||||||
|
}
|
||||||
|
await ensureColumn(pool, 'a_users', 'must_change_password', 'TINYINT(1) NOT NULL DEFAULT 0', 'password_iterations');
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS a_login_attempts (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
rate_key VARCHAR(600) NOT NULL UNIQUE,
|
||||||
|
failed_count INT NOT NULL DEFAULT 0,
|
||||||
|
last_failed_at DATETIME NULL,
|
||||||
|
locked_until DATETIME NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await ensureColumn(pool, 'a_users', 'account_locked', 'TINYINT(1) NOT NULL DEFAULT 0', 'must_change_password');
|
||||||
|
await ensureColumn(pool, 'a_sessions', 'ip_address', 'VARCHAR(255) NULL', 'user_id');
|
||||||
|
await ensureColumn(pool, 'a_sessions', 'user_agent', 'VARCHAR(512) NULL', 'ip_address');
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS o_audit_events (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
category VARCHAR(64) NOT NULL,
|
||||||
|
event_type VARCHAR(128) NOT NULL,
|
||||||
|
actor_user_id INT NULL,
|
||||||
|
target_type VARCHAR(64) NULL,
|
||||||
|
target_id VARCHAR(191) NULL,
|
||||||
|
target_label VARCHAR(255) NULL,
|
||||||
|
ip_address VARCHAR(255) NULL,
|
||||||
|
user_agent VARCHAR(512) NULL,
|
||||||
|
details_json JSON NULL,
|
||||||
|
INDEX idx_audit_events_occurred_at (occurred_at),
|
||||||
|
INDEX idx_audit_events_category_type (category, event_type),
|
||||||
|
INDEX idx_audit_events_actor (actor_user_id),
|
||||||
|
INDEX idx_audit_events_target (target_type, target_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by)
|
||||||
|
VALUES (?, ?, NULL, NULL) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`,
|
||||||
|
['audit.retention_days', JSON.stringify(30)]
|
||||||
|
);
|
||||||
|
const settings = [
|
||||||
|
['audit.enabled', true],
|
||||||
|
['audit.categories', ['authentication', 'security', 'sessions', 'users', 'roles', 'system-settings']],
|
||||||
|
['audit.include_request_metadata', true]
|
||||||
|
];
|
||||||
|
for (const [key, value] of settings) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by)
|
||||||
|
VALUES (?, ?, NULL, NULL) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`,
|
||||||
|
[key, JSON.stringify(value)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await pool.query(
|
||||||
|
`INSERT IGNORE INTO a_permissions
|
||||||
|
(permission_key, name, section_name, description, created_by, modified_by)
|
||||||
|
VALUES (?, ?, ?, ?, NULL, NULL)`,
|
||||||
|
['audit-log.allow', 'Audit log', 'Settings', 'Download filtered audit events.']
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
|
||||||
|
SELECT roles.id, permissions.id, NULL, NULL
|
||||||
|
FROM a_roles roles
|
||||||
|
CROSS JOIN a_permissions permissions
|
||||||
|
WHERE roles.role_key = 'administrators'
|
||||||
|
AND permissions.permission_key = 'audit-log.allow'`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -431,7 +538,7 @@ async function detectSchemaVersion(pool) {
|
|||||||
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
|
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
|
||||||
const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries');
|
const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries');
|
||||||
|
|
||||||
if ((timetableGroupsExists || timetableEntriesExists) && !scheduleGroupsExists && !scheduleEntriesExists) {
|
if (timetableGroupsExists && timetableEntriesExists && !scheduleGroupsExists && !scheduleEntriesExists) {
|
||||||
return '2.6.18';
|
return '2.6.18';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,16 +548,22 @@ async function detectSchemaVersion(pool) {
|
|||||||
async function recordSchemaVersion(pool, version) {
|
async function recordSchemaVersion(pool, version) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`CREATE TABLE IF NOT EXISTS ${APP_STATE_TABLE} (
|
`CREATE TABLE IF NOT EXISTS ${APP_STATE_TABLE} (
|
||||||
state_key VARCHAR(191) NOT NULL PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
state_key VARCHAR(191) NOT NULL UNIQUE,
|
||||||
state_value MEDIUMTEXT NULL,
|
state_value MEDIUMTEXT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const stateValue = String(version || appVersion || '0.0.0').trim();
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE state_value = VALUES(state_value)',
|
'UPDATE ' + APP_STATE_TABLE + ' SET state_value = ? WHERE state_key = ?',
|
||||||
[APP_STATE_SCHEMA_VERSION_KEY, String(version || appVersion || '0.0.0').trim()]
|
[stateValue, APP_STATE_SCHEMA_VERSION_KEY]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM ' + APP_STATE_TABLE + ' WHERE state_key = ?)',
|
||||||
|
[APP_STATE_SCHEMA_VERSION_KEY, stateValue, APP_STATE_SCHEMA_VERSION_KEY]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ async function start() {
|
|||||||
const playerPlaylistService = createPlayerPlaylistService({
|
const playerPlaylistService = createPlayerPlaylistService({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
|
mediaDir: config.mediaDir,
|
||||||
snapshotDir: path.join(config.mediaDir, 'player-cache', 'screen-playlists')
|
snapshotDir: path.join(config.mediaDir, 'player-cache', 'screen-playlists')
|
||||||
});
|
});
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ async function start() {
|
|||||||
: createPlayerPlaylistService({
|
: createPlayerPlaylistService({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
|
mediaDir: MEDIA_DIR,
|
||||||
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
|
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
|
||||||
});
|
});
|
||||||
const rtmpStreamService = createRtmpStreamService({
|
const rtmpStreamService = createRtmpStreamService({
|
||||||
|
|||||||
@@ -128,8 +128,18 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
|
`UPDATE d_onboarding_devices
|
||||||
[normalizedDeviceId, normalizedClientName, screen.id]
|
SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE device_id = ?`,
|
||||||
|
[normalizedClientName, screen.id, normalizedDeviceId]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
|
||||||
|
SELECT ?, ?, ?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
|
||||||
|
)`,
|
||||||
|
[normalizedDeviceId, normalizedClientName, screen.id, normalizedDeviceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return getOnboardingStatus(pool, normalizedDeviceId);
|
return getOnboardingStatus(pool, normalizedDeviceId);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ function createPlayerPlaylistService(options) {
|
|||||||
const pool = options && options.pool ? options.pool : null;
|
const pool = options && options.pool ? options.pool : null;
|
||||||
const common = options && options.common ? options.common : null;
|
const common = options && options.common ? options.common : null;
|
||||||
const snapshotDir = options && options.snapshotDir ? options.snapshotDir : null;
|
const snapshotDir = options && options.snapshotDir ? options.snapshotDir : null;
|
||||||
|
const mediaDir = options && options.mediaDir ? path.resolve(String(options.mediaDir)) : null;
|
||||||
|
const remoteImageCacheDir = mediaDir ? path.join(mediaDir, 'player-cache', 'remote-images') : null;
|
||||||
|
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
throw new Error('pool is required');
|
throw new Error('pool is required');
|
||||||
@@ -61,6 +63,105 @@ function createPlayerPlaylistService(options) {
|
|||||||
return createStyledQrCodeDataUrl(value);
|
return createStyledQrCodeDataUrl(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPlaceholderImageExpressions(value, output) {
|
||||||
|
const expressions = output || [];
|
||||||
|
const source = String(value || '');
|
||||||
|
const pattern = /\{\{\s*([^{}]*?\.image\s*\([^{}]*\)[^{}]*?)\s*\}\}/gi;
|
||||||
|
let match = null;
|
||||||
|
while ((match = pattern.exec(source))) {
|
||||||
|
expressions.push(String(match[1] || '').trim());
|
||||||
|
}
|
||||||
|
return expressions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePathValue(value, expression) {
|
||||||
|
const parsed = String(expression || '').replace(/\.image\s*\([^)]*\)\s*$/i, '').trim();
|
||||||
|
return parsed.split('.').reduce(function (current, segment) {
|
||||||
|
return current === undefined || current === null ? '' : current[segment];
|
||||||
|
}, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiItems(responseJson, itemsPath) {
|
||||||
|
let current = responseJson;
|
||||||
|
const pathValue = String(itemsPath || '').trim();
|
||||||
|
if (pathValue) {
|
||||||
|
pathValue.split('.').forEach(function (segment) {
|
||||||
|
current = current === undefined || current === null ? '' : current[segment];
|
||||||
|
});
|
||||||
|
return Array.isArray(current) ? current : [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(responseJson)) return responseJson;
|
||||||
|
if (responseJson && Array.isArray(responseJson.items)) return responseJson.items;
|
||||||
|
if (responseJson && Array.isArray(responseJson.results)) return responseJson.results;
|
||||||
|
if (responseJson && Array.isArray(responseJson.data)) return responseJson.data;
|
||||||
|
return responseJson ? [responseJson] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheRemoteImage(url) {
|
||||||
|
const remoteUrl = String(url || '').trim();
|
||||||
|
if (!remoteImageCacheDir || !/^https?:\/\//i.test(remoteUrl)) return '';
|
||||||
|
const hash = crypto.createHash('sha256').update(remoteUrl).digest('hex');
|
||||||
|
await fs.promises.mkdir(remoteImageCacheDir, { recursive: true });
|
||||||
|
const existing = (await fs.promises.readdir(remoteImageCacheDir)).find(function (name) { return name.startsWith(hash + '.'); });
|
||||||
|
if (existing) return '/media/player-cache/remote-images/' + existing;
|
||||||
|
try {
|
||||||
|
const response = await fetch(remoteUrl, { signal: AbortSignal.timeout(15000) });
|
||||||
|
if (!response.ok || !String(response.headers.get('content-type') || '').toLowerCase().startsWith('image/')) return '';
|
||||||
|
const bytes = Buffer.from(await response.arrayBuffer());
|
||||||
|
if (bytes.length > 10 * 1024 * 1024) return '';
|
||||||
|
const contentType = String(response.headers.get('content-type') || '').toLowerCase();
|
||||||
|
const extension = contentType.includes('svg') ? '.svg' : contentType.includes('png') ? '.png' : contentType.includes('webp') ? '.webp' : contentType.includes('gif') ? '.gif' : '.jpg';
|
||||||
|
const fileName = hash + extension;
|
||||||
|
const filePath = path.join(remoteImageCacheDir, fileName);
|
||||||
|
await fs.promises.writeFile(filePath, bytes);
|
||||||
|
return '/media/player-cache/remote-images/' + fileName;
|
||||||
|
} catch (_error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cachePlaceholderImages(slides, rssFeeds, apiSources) {
|
||||||
|
if (!remoteImageCacheDir) return;
|
||||||
|
const usedPaths = new Set();
|
||||||
|
const allSlideRows = await pool.query('SELECT content_json FROM c_slides').then(function (result) { return result[0] || []; });
|
||||||
|
const allContents = allSlideRows.map(function (row) { return common.parseJsonSafe(row.content_json) || {}; });
|
||||||
|
const sourceContent = allContents.concat((slides || []).map(function (slide) { return slide.content || {}; }));
|
||||||
|
const cacheSource = async function (source, item, expressions) {
|
||||||
|
if (!source || !item) return;
|
||||||
|
for (const expression of expressions) {
|
||||||
|
const remoteUrl = String(resolvePathValue(item, expression) || '').trim();
|
||||||
|
const localPath = await cacheRemoteImage(remoteUrl);
|
||||||
|
if (localPath) {
|
||||||
|
source.imageCache = source.imageCache || {};
|
||||||
|
source.imageCache[remoteUrl] = localPath;
|
||||||
|
usedPaths.add(localPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const content of sourceContent) {
|
||||||
|
for (const key of Object.keys(content || {})) {
|
||||||
|
const region = content[key];
|
||||||
|
if (!region || typeof region !== 'object') continue;
|
||||||
|
const expressions = getPlaceholderImageExpressions(region.value, []);
|
||||||
|
if (!expressions.length) continue;
|
||||||
|
const itemNumber = Math.max(1, Number(region.item_number || 1)) - 1;
|
||||||
|
if (String(region.type || '').toLowerCase() === 'api') {
|
||||||
|
const source = (apiSources || []).find(function (entry) { return Number(entry.id) === Number(region.source_id); });
|
||||||
|
const items = source ? getApiItems(source.responseJson, region.items_path) : [];
|
||||||
|
await cacheSource(source, items[itemNumber], expressions);
|
||||||
|
}
|
||||||
|
if (String(region.type || '').toLowerCase() === 'rss') {
|
||||||
|
const feed = (rssFeeds || []).find(function (entry) { return Number(entry.id) === Number(region.feed_id); });
|
||||||
|
await cacheSource(feed, feed && feed.items && feed.items[itemNumber], expressions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = await fs.promises.readdir(remoteImageCacheDir).catch(function () { return []; });
|
||||||
|
await Promise.all(files.filter(function (file) { return !usedPaths.has('/media/player-cache/remote-images/' + file); }).map(function (file) {
|
||||||
|
return fs.promises.unlink(path.join(remoteImageCacheDir, file)).catch(function () {});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function buildScreenPlaylist(slug) {
|
async function buildScreenPlaylist(slug) {
|
||||||
try {
|
try {
|
||||||
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
|
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
|
||||||
@@ -241,6 +342,8 @@ function createPlayerPlaylistService(options) {
|
|||||||
timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
|
timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await cachePlaceholderImages(slides, rssFeeds, apiSources);
|
||||||
|
|
||||||
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups);
|
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups);
|
||||||
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision };
|
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision };
|
||||||
await writeSnapshot(slug, payload);
|
await writeSnapshot(slug, payload);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function getApiItem(sourceId, itemNumber, itemsPathOverride) {
|
|||||||
return items[index] || null;
|
return items[index] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function substituteApiVariables(html, item) {
|
function substituteApiVariables(html, item, sourceId) {
|
||||||
var source = String(html || '');
|
var source = String(html || '');
|
||||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||||
if (!item || typeof item !== 'object') {
|
if (!item || typeof item !== 'object') {
|
||||||
@@ -67,7 +67,25 @@ function substituteApiVariables(html, item) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression)));
|
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
|
||||||
|
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
|
||||||
|
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
|
||||||
|
var source = getApiSourceById(sourceId);
|
||||||
|
imageSource = source && source.imageCache && source.imageCache[imageSource] ? source.imageCache[imageSource] : imageSource;
|
||||||
|
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
|
||||||
|
var hasImageBounds = imageConfig.width && imageConfig.height;
|
||||||
|
var imageStyle = hasImageBounds
|
||||||
|
? 'display:block;width:100%;height:100%;object-fit:contain;'
|
||||||
|
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
|
||||||
|
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
|
||||||
|
return hasImageBounds
|
||||||
|
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>'
|
||||||
|
: image;
|
||||||
|
}
|
||||||
|
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,10 +138,8 @@ function renderApiRegion(region, regionContent) {
|
|||||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
||||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
||||||
var item = getApiItem(sourceId, itemNumber, itemsPath);
|
var item = getApiItem(sourceId, itemNumber, itemsPath);
|
||||||
var body = item ? substituteApiVariables(content, item) : '';
|
var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== '';
|
||||||
if (!body && item) {
|
var body = hasSource ? (item ? substituteApiVariables(content, item, sourceId) : '') : content;
|
||||||
body = getApiPreviewFallback(item);
|
|
||||||
}
|
|
||||||
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
+27
-15
@@ -1,6 +1,7 @@
|
|||||||
// RSS region helpers for resolving feeds, items, and nested values.
|
// RSS region helpers for resolving feeds, items, and nested values.
|
||||||
|
|
||||||
var registry = window.pulsePlayerRegionTypes;
|
var registry = window.pulsePlayerRegionTypes;
|
||||||
|
var placeholderUtils = window.placeholderUtils || {};
|
||||||
|
|
||||||
function getDefaultStyle() {
|
function getDefaultStyle() {
|
||||||
return {
|
return {
|
||||||
@@ -63,14 +64,34 @@ function resolveRssPath(value, path) {
|
|||||||
return current === undefined || current === null ? '' : current;
|
return current === undefined || current === null ? '' : current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function substituteRssVariables(html, item) {
|
function substituteRssVariables(html, item, feedId) {
|
||||||
var source = String(html || '');
|
var source = String(html || '');
|
||||||
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
|
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||||
if (!item || typeof item !== 'object') {
|
if (!item || typeof item !== 'object') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName;
|
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
|
||||||
return escapeHtml(resolveRssPath(item, key || ''));
|
return '';
|
||||||
|
}
|
||||||
|
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
|
||||||
|
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
|
||||||
|
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
|
||||||
|
var feed = getRssFeedById(feedId);
|
||||||
|
imageSource = feed && feed.imageCache && feed.imageCache[imageSource] ? feed.imageCache[imageSource] : imageSource;
|
||||||
|
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
|
||||||
|
var hasImageBounds = imageConfig.width && imageConfig.height;
|
||||||
|
var imageStyle = hasImageBounds
|
||||||
|
? 'display:block;width:100%;height:100%;object-fit:contain;'
|
||||||
|
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
|
||||||
|
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
|
||||||
|
return hasImageBounds
|
||||||
|
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>'
|
||||||
|
: image;
|
||||||
|
}
|
||||||
|
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,17 +104,8 @@ function renderRssRegion(region, regionContent) {
|
|||||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
|
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
|
||||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
|
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
|
||||||
var item = getRssFeedItem(feedId, itemNumber);
|
var item = getRssFeedItem(feedId, itemNumber);
|
||||||
var body = item ? substituteRssVariables(content, item) : '';
|
var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== '';
|
||||||
if (!body && item) {
|
var body = hasFeed ? (item ? substituteRssVariables(content, item, feedId) : '') : content;
|
||||||
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 contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
+20
-2
@@ -140,6 +140,24 @@ const PERMISSION_SECTIONS = [
|
|||||||
sectionName: 'Settings',
|
sectionName: 'Settings',
|
||||||
order: 40,
|
order: 40,
|
||||||
permissions: [
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'system-settings',
|
||||||
|
order: 70,
|
||||||
|
name: 'System settings',
|
||||||
|
permissions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View global application settings.' },
|
||||||
|
{ key: 'update', name: 'Update', description: 'Update global application settings.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audit-log',
|
||||||
|
order: 60,
|
||||||
|
name: 'Audit log',
|
||||||
|
permissions: [
|
||||||
|
{ key: 'read', name: 'Read', description: 'View security and session audit events.' },
|
||||||
|
{ key: 'allow', name: 'Allow', description: 'Download filtered audit events.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'users',
|
key: 'users',
|
||||||
order: 10,
|
order: 10,
|
||||||
@@ -218,8 +236,8 @@ const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section, sectionIndex)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_ROLE = {
|
const DEFAULT_ROLE = {
|
||||||
key: 'administrators',
|
key: 'super-admin',
|
||||||
name: 'Administrators',
|
name: 'Super Admin',
|
||||||
description: 'Full access to the admin interface.'
|
description: 'Full access to the admin interface.'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+22
-4
@@ -9,6 +9,7 @@ const common = require('./common');
|
|||||||
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword, validatePasswordStrength } = require('#src/auth');
|
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword, validatePasswordStrength } = require('#src/auth');
|
||||||
const pages = require('#src/web/pages');
|
const pages = require('#src/web/pages');
|
||||||
const registerMiddleware = require('#src/web/middleware');
|
const registerMiddleware = require('#src/web/middleware');
|
||||||
|
const registerNotFoundHandler = require('#src/web/middleware/not-found');
|
||||||
const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
|
const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
|
||||||
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks');
|
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks');
|
||||||
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
|
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
|
||||||
@@ -23,6 +24,8 @@ const { rbacData } = require('#src/web/lib/auth');
|
|||||||
const { createPlayerActionService } = require('#src/web/lib/player-actions');
|
const { createPlayerActionService } = require('#src/web/lib/player-actions');
|
||||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||||
const { createSessionService } = require('#src/web/lib/auth');
|
const { createSessionService } = require('#src/web/lib/auth');
|
||||||
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
const { recordRequestAuditEvent } = require('#src/data/audit-log');
|
||||||
const { hasAnyPermission } = require('#src/rbac');
|
const { hasAnyPermission } = require('#src/rbac');
|
||||||
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
|
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
|
||||||
const {
|
const {
|
||||||
@@ -34,7 +37,6 @@ const {
|
|||||||
getAuditUserId,
|
getAuditUserId,
|
||||||
getCanvasSignature,
|
getCanvasSignature,
|
||||||
fetchPlaylistCanvasId,
|
fetchPlaylistCanvasId,
|
||||||
fetchPlaylistCanvasSignature,
|
|
||||||
fetchScreensByPlaylistId,
|
fetchScreensByPlaylistId,
|
||||||
fetchScreensBySlideId,
|
fetchScreensBySlideId,
|
||||||
fetchScreensByTemplateId,
|
fetchScreensByTemplateId,
|
||||||
@@ -92,6 +94,14 @@ async function start() {
|
|||||||
const sessionService = createSessionService({
|
const sessionService = createSessionService({
|
||||||
sessionCookieName: webConfig.sessionCookieName,
|
sessionCookieName: webConfig.sessionCookieName,
|
||||||
sessionMaxAgeMs: webConfig.sessionMaxAgeMs,
|
sessionMaxAgeMs: webConfig.sessionMaxAgeMs,
|
||||||
|
getConfiguredSessionMaxAgeMs: async function () {
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
return Number(settings['security.session_lifetime_days']) * 24 * 60 * 60 * 1000;
|
||||||
|
},
|
||||||
|
getConfiguredMaxActiveSessions: async function () {
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
return Number(settings['security.max_active_sessions']);
|
||||||
|
},
|
||||||
hashSessionToken: hashSessionToken,
|
hashSessionToken: hashSessionToken,
|
||||||
createSessionToken: createSessionToken
|
createSessionToken: createSessionToken
|
||||||
});
|
});
|
||||||
@@ -99,6 +109,7 @@ async function start() {
|
|||||||
const createUserSession = sessionService.createUserSession;
|
const createUserSession = sessionService.createUserSession;
|
||||||
const clearSessionCookie = sessionService.clearSessionCookie;
|
const clearSessionCookie = sessionService.clearSessionCookie;
|
||||||
const setSessionCookie = sessionService.setSessionCookie;
|
const setSessionCookie = sessionService.setSessionCookie;
|
||||||
|
const getSessionMaxAgeMs = sessionService.getSessionMaxAgeMs;
|
||||||
const setAuthMessageCookie = sessionService.setAuthMessageCookie;
|
const setAuthMessageCookie = sessionService.setAuthMessageCookie;
|
||||||
const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie;
|
const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie;
|
||||||
const loadCurrentUser = sessionService.loadCurrentUser;
|
const loadCurrentUser = sessionService.loadCurrentUser;
|
||||||
@@ -119,9 +130,11 @@ async function start() {
|
|||||||
common: common,
|
common: common,
|
||||||
pages: pages,
|
pages: pages,
|
||||||
upload: upload,
|
upload: upload,
|
||||||
|
mediaDir: webConfig.mediaDir,
|
||||||
uploadDir: webConfig.uploadsDir,
|
uploadDir: webConfig.uploadsDir,
|
||||||
createUserSession: createUserSession,
|
createUserSession: createUserSession,
|
||||||
setSessionCookie: setSessionCookie,
|
setSessionCookie: setSessionCookie,
|
||||||
|
getSessionMaxAgeMs: getSessionMaxAgeMs,
|
||||||
clearSessionCookie: clearSessionCookie,
|
clearSessionCookie: clearSessionCookie,
|
||||||
setAuthMessageCookie: setAuthMessageCookie,
|
setAuthMessageCookie: setAuthMessageCookie,
|
||||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||||
@@ -131,6 +144,7 @@ async function start() {
|
|||||||
sessionCookieName: webConfig.sessionCookieName,
|
sessionCookieName: webConfig.sessionCookieName,
|
||||||
formatDashboardDate: formatDashboardDate,
|
formatDashboardDate: formatDashboardDate,
|
||||||
getAuditUserId: getAuditUserId,
|
getAuditUserId: getAuditUserId,
|
||||||
|
recordRequestAuditEvent: recordRequestAuditEvent,
|
||||||
hashPassword: hashPassword,
|
hashPassword: hashPassword,
|
||||||
validatePasswordStrength: validatePasswordStrength,
|
validatePasswordStrength: validatePasswordStrength,
|
||||||
readArrayField: readArrayField,
|
readArrayField: readArrayField,
|
||||||
@@ -140,7 +154,6 @@ async function start() {
|
|||||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
|
||||||
getCanvasSignature: getCanvasSignature,
|
getCanvasSignature: getCanvasSignature,
|
||||||
normalizeScheduleMode: normalizeScheduleMode,
|
normalizeScheduleMode: normalizeScheduleMode,
|
||||||
parseDateTimeLocal: parseDateTimeLocal,
|
parseDateTimeLocal: parseDateTimeLocal,
|
||||||
@@ -157,7 +170,6 @@ async function start() {
|
|||||||
},
|
},
|
||||||
hasAnyPermission: hasAnyPermission,
|
hasAnyPermission: hasAnyPermission,
|
||||||
backgroundTaskQueue: backgroundTaskQueue,
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
mediaDir: webConfig.mediaDir,
|
|
||||||
uploadSyncService: webBootstrap.uploadSyncService,
|
uploadSyncService: webBootstrap.uploadSyncService,
|
||||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||||
@@ -167,9 +179,15 @@ async function start() {
|
|||||||
buildDashboardState: webBootstrap.buildDashboardState
|
buildDashboardState: webBootstrap.buildDashboardState
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerNotFoundHandler(app);
|
||||||
|
|
||||||
app.use(function (error, req, res, _next) {
|
app.use(function (error, req, res, _next) {
|
||||||
console.error(error);
|
|
||||||
const statusCode = Number(error && (error.statusCode || error.status)) || 500;
|
const statusCode = Number(error && (error.statusCode || error.status)) || 500;
|
||||||
|
if (statusCode >= 500) {
|
||||||
|
console.error(error);
|
||||||
|
} else if (statusCode >= 400 && statusCode !== 404) {
|
||||||
|
console.warn(error && error.message ? error.message : 'Request failed.', { statusCode: statusCode });
|
||||||
|
}
|
||||||
const isXhr = String(req.get && req.get('X-Requested-With') || '').toLowerCase() === 'xmlhttprequest';
|
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'));
|
const wantsHtml = !isXhr && !String(req.originalUrl || '').startsWith('/api/') && (!req.accepts || req.accepts('html'));
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ async function fetchRolesForUser(pool, userId) {
|
|||||||
|
|
||||||
async function fetchUsersWithRoles(pool) {
|
async function fetchUsersWithRoles(pool) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
`SELECT u.id, u.name, u.username, u.account_locked, u.created_at, u.modified_at,
|
||||||
COALESCE(role_data.role_names, '') AS role_names,
|
COALESCE(role_data.role_names, '') AS role_names,
|
||||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||||
FROM a_users u
|
FROM a_users u
|
||||||
@@ -142,7 +142,7 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
|
|||||||
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
|
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
|
||||||
const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
|
const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
|
||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
selectSql: `SELECT u.id, u.name, u.username, u.account_locked, u.created_at, u.modified_at,
|
||||||
COALESCE(role_data.role_names, '') AS role_names,
|
COALESCE(role_data.role_names, '') AS role_names,
|
||||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||||
FROM a_users u
|
FROM a_users u
|
||||||
@@ -189,7 +189,7 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
|
|||||||
|
|
||||||
async function fetchUserWithRoles(pool, userId) {
|
async function fetchUserWithRoles(pool, userId) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
`SELECT u.id, u.name, u.username, u.account_locked, u.created_at, u.modified_at,
|
||||||
COALESCE(role_data.role_names, '') AS role_names,
|
COALESCE(role_data.role_names, '') AS role_names,
|
||||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||||
FROM a_users u
|
FROM a_users u
|
||||||
@@ -216,6 +216,17 @@ async function fetchUserWithRoles(pool, userId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchActiveUserSessions(pool, userId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT id, ip_address, user_agent, created_at, last_used_at, expires_at
|
||||||
|
FROM a_sessions
|
||||||
|
WHERE user_id = ? AND expires_at > NOW()
|
||||||
|
ORDER BY last_used_at DESC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
async function syncUserRoles(pool, userId, roleIds) {
|
async function syncUserRoles(pool, userId, roleIds) {
|
||||||
const uniqueRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
|
const uniqueRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
|
||||||
return Number(roleId);
|
return Number(roleId);
|
||||||
@@ -225,7 +236,14 @@ async function syncUserRoles(pool, userId, roleIds) {
|
|||||||
|
|
||||||
await pool.query('DELETE FROM a_user_roles WHERE user_id = ?', [userId]);
|
await pool.query('DELETE FROM a_user_roles WHERE user_id = ?', [userId]);
|
||||||
for (const roleId of uniqueRoleIds) {
|
for (const roleId of uniqueRoleIds) {
|
||||||
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
await pool.query(
|
||||||
|
`INSERT INTO a_user_roles (user_id, role_id, created_by, modified_by)
|
||||||
|
SELECT ?, ?, ?, ?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM a_user_roles WHERE user_id = ? AND role_id = ?
|
||||||
|
)`,
|
||||||
|
[userId, roleId, null, null, userId, roleId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +256,14 @@ async function syncRoleUsers(pool, roleId, userIds) {
|
|||||||
|
|
||||||
await pool.query('DELETE FROM a_user_roles WHERE role_id = ?', [roleId]);
|
await pool.query('DELETE FROM a_user_roles WHERE role_id = ?', [roleId]);
|
||||||
for (const userId of uniqueUserIds) {
|
for (const userId of uniqueUserIds) {
|
||||||
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
await pool.query(
|
||||||
|
`INSERT INTO a_user_roles (user_id, role_id, created_by, modified_by)
|
||||||
|
SELECT ?, ?, ?, ?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM a_user_roles WHERE user_id = ? AND role_id = ?
|
||||||
|
)`,
|
||||||
|
[userId, roleId, null, null, userId, roleId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +282,15 @@ async function syncRolePermissions(pool, roleId, permissionKeys) {
|
|||||||
|
|
||||||
await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
|
await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
|
||||||
for (const permissionRow of permissionRows) {
|
for (const permissionRow of permissionRows) {
|
||||||
await pool.query('INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
|
const permissionId = Number(permissionRow.id);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
|
||||||
|
SELECT ?, ?, ?, ?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM a_role_permissions WHERE role_id = ? AND permission_id = ?
|
||||||
|
)`,
|
||||||
|
[roleId, permissionId, null, null, roleId, permissionId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +306,7 @@ module.exports = {
|
|||||||
fetchUsersWithRoles,
|
fetchUsersWithRoles,
|
||||||
fetchUsersWithRolesPage,
|
fetchUsersWithRolesPage,
|
||||||
fetchUserWithRoles,
|
fetchUserWithRoles,
|
||||||
|
fetchActiveUserSessions,
|
||||||
syncUserRoles,
|
syncUserRoles,
|
||||||
syncRoleUsers,
|
syncRoleUsers,
|
||||||
syncRolePermissions
|
syncRolePermissions
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ function normalizeReturnToPath(value, baseUrl) {
|
|||||||
function createSessionService(options) {
|
function createSessionService(options) {
|
||||||
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
||||||
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
|
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
|
||||||
|
const getConfiguredSessionMaxAgeMs = options && options.getConfiguredSessionMaxAgeMs;
|
||||||
|
const getConfiguredMaxActiveSessions = options && options.getConfiguredMaxActiveSessions;
|
||||||
const hashSessionToken = options && options.hashSessionToken;
|
const hashSessionToken = options && options.hashSessionToken;
|
||||||
const createSessionToken = options && options.createSessionToken;
|
const createSessionToken = options && options.createSessionToken;
|
||||||
const authMessageCookieName = 'pulse_auth_message';
|
const authMessageCookieName = 'pulse_auth_message';
|
||||||
@@ -88,7 +90,20 @@ function createSessionService(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setSessionCookie(res, token) {
|
function setSessionCookie(res, token) {
|
||||||
appendCookieHeader(res, serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs }));
|
const hasRequestedMaxAge = arguments.length > 2;
|
||||||
|
const requestedMaxAgeMs = hasRequestedMaxAge ? Number(arguments[2]) : sessionMaxAgeMs;
|
||||||
|
const cookieOptions = hasRequestedMaxAge && arguments[2] === null
|
||||||
|
? {}
|
||||||
|
: { maxAge: Number.isFinite(requestedMaxAgeMs) && requestedMaxAgeMs > 0 ? requestedMaxAgeMs : sessionMaxAgeMs };
|
||||||
|
appendCookieHeader(res, serializeCookie(sessionCookieName, token, cookieOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSessionMaxAgeMs() {
|
||||||
|
const configuredValue = typeof getConfiguredSessionMaxAgeMs === 'function'
|
||||||
|
? await getConfiguredSessionMaxAgeMs()
|
||||||
|
: sessionMaxAgeMs;
|
||||||
|
const normalizedValue = Number(configuredValue);
|
||||||
|
return Number.isFinite(normalizedValue) && normalizedValue > 0 ? normalizedValue : sessionMaxAgeMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuthMessageCookie(res, message) {
|
function setAuthMessageCookie(res, message) {
|
||||||
@@ -113,7 +128,7 @@ function createSessionService(options) {
|
|||||||
|
|
||||||
const tokenHash = hashSessionToken(token);
|
const tokenHash = hashSessionToken(token);
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT s.user_id, u.id, u.name, u.username
|
`SELECT s.user_id, u.id, u.name, u.username, u.must_change_password
|
||||||
FROM a_sessions s
|
FROM a_sessions s
|
||||||
JOIN a_users u ON u.id = s.user_id
|
JOIN a_users u ON u.id = s.user_id
|
||||||
WHERE s.session_hash = ?
|
WHERE s.session_hash = ?
|
||||||
@@ -146,6 +161,7 @@ function createSessionService(options) {
|
|||||||
|
|
||||||
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
||||||
return Object.assign({}, rows[0], {
|
return Object.assign({}, rows[0], {
|
||||||
|
mustChangePassword: Boolean(rows[0].must_change_password),
|
||||||
roleKeys: roleRows.map(function (row) {
|
roleKeys: roleRows.map(function (row) {
|
||||||
return String(row.role_key || '').trim();
|
return String(row.role_key || '').trim();
|
||||||
}).filter(Boolean),
|
}).filter(Boolean),
|
||||||
@@ -155,14 +171,34 @@ function createSessionService(options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUserSession(pool, userId) {
|
async function createUserSession(pool, userId, configuredMaxAgeMs, metadata) {
|
||||||
const token = createSessionToken();
|
const token = createSessionToken();
|
||||||
const tokenHash = hashSessionToken(token);
|
const tokenHash = hashSessionToken(token);
|
||||||
const expiresAt = new Date(Date.now() + sessionMaxAgeMs);
|
const maxAgeMs = Number.isFinite(Number(configuredMaxAgeMs)) && Number(configuredMaxAgeMs) > 0
|
||||||
|
? Number(configuredMaxAgeMs)
|
||||||
|
: await getSessionMaxAgeMs();
|
||||||
|
const expiresAt = new Date(Date.now() + maxAgeMs);
|
||||||
|
const sessionMetadata = metadata && typeof metadata === 'object' ? metadata : {};
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'INSERT INTO a_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO a_sessions (session_hash, user_id, ip_address, user_agent, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
[tokenHash, userId, expiresAt, userId, userId]
|
[tokenHash, userId, String(sessionMetadata.ipAddress || '').slice(0, 255) || null, String(sessionMetadata.userAgent || '').slice(0, 512) || null, expiresAt, userId, userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (typeof getConfiguredMaxActiveSessions === 'function') {
|
||||||
|
const maxActiveSessions = Number(await getConfiguredMaxActiveSessions());
|
||||||
|
if (Number.isInteger(maxActiveSessions) && maxActiveSessions > 0) {
|
||||||
|
const [sessionRows] = await pool.query(
|
||||||
|
'SELECT id, session_hash FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at ASC, created_at ASC, id ASC',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const sessionsToRemove = (sessionRows || []).filter(function (session) {
|
||||||
|
return session.session_hash !== tokenHash;
|
||||||
|
}).slice(0, Math.max(0, sessionRows.length - maxActiveSessions));
|
||||||
|
for (const session of sessionsToRemove) {
|
||||||
|
await pool.query('DELETE FROM a_sessions WHERE id = ? AND user_id = ?', [session.id, userId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +220,7 @@ function createSessionService(options) {
|
|||||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||||
loadCurrentUser: loadCurrentUser,
|
loadCurrentUser: loadCurrentUser,
|
||||||
createUserSession: createUserSession,
|
createUserSession: createUserSession,
|
||||||
|
getSessionMaxAgeMs: getSessionMaxAgeMs,
|
||||||
requireAuth: requireAuth,
|
requireAuth: requireAuth,
|
||||||
getRequestOrigin: getRequestOrigin
|
getRequestOrigin: getRequestOrigin
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
|
const TASK = {
|
||||||
|
key: 'audit-log-sweep',
|
||||||
|
title: 'Audit log cleanup',
|
||||||
|
category: 'cleanup',
|
||||||
|
intervalMs: 24 * 60 * 60 * 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
function registerAuditLogSweepTask(options) {
|
||||||
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||||
|
const pool = options && options.pool;
|
||||||
|
if (!backgroundTaskQueue || !pool) {
|
||||||
|
throw new Error('registerAuditLogSweepTask requires audit cleanup dependencies.');
|
||||||
|
}
|
||||||
|
backgroundTaskQueue.registerRecurringTask({
|
||||||
|
key: TASK.key,
|
||||||
|
title: TASK.title,
|
||||||
|
category: TASK.category,
|
||||||
|
intervalMs: TASK.intervalMs,
|
||||||
|
metadata: {},
|
||||||
|
run: async function () {
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
const retentionDays = Number(settings['audit.retention_days']);
|
||||||
|
if (!Number.isInteger(retentionDays) || retentionDays <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
|
||||||
|
await pool.query('DELETE FROM o_audit_events WHERE occurred_at < ?', [cutoff]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerAuditLogSweepTask };
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const TASK = {
|
||||||
|
key: 'expired-session-sweep',
|
||||||
|
title: 'Expired session cleanup',
|
||||||
|
category: 'cleanup',
|
||||||
|
trigger: 'scheduled recurring task, hourly',
|
||||||
|
purpose: 'remove expired authentication sessions and their metadata.',
|
||||||
|
taskType: 'recurring-run',
|
||||||
|
intervalMs: 60 * 60 * 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
function registerExpiredSessionSweepTask(options) {
|
||||||
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||||
|
const pool = options && options.pool;
|
||||||
|
|
||||||
|
if (!backgroundTaskQueue || !pool) {
|
||||||
|
throw new Error('registerExpiredSessionSweepTask requires the session cleanup dependencies.');
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundTaskQueue.registerRecurringTask({
|
||||||
|
key: TASK.key,
|
||||||
|
title: TASK.title,
|
||||||
|
category: TASK.category,
|
||||||
|
intervalMs: TASK.intervalMs,
|
||||||
|
metadata: {},
|
||||||
|
run: async function () {
|
||||||
|
await pool.query('DELETE FROM a_sessions WHERE expires_at <= NOW()');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerExpiredSessionSweepTask };
|
||||||
@@ -9,8 +9,7 @@ function createWebConfig() {
|
|||||||
const bridgeInternalUrl = (process.env.BRIDGE_INTERNAL_URL || 'http://player-bridge:8090').replace(/\/$/, '');
|
const bridgeInternalUrl = (process.env.BRIDGE_INTERNAL_URL || 'http://player-bridge:8090').replace(/\/$/, '');
|
||||||
const webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, '');
|
const webInternalUrl = (process.env.WEB_INTERNAL_URL || `http://127.0.0.1:${Number(process.env.WEB_PORT || 8080)}`).replace(/\/$/, '');
|
||||||
const sessionCookieName = 'digital_signage_session';
|
const sessionCookieName = 'digital_signage_session';
|
||||||
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
const sessionMaxAgeMs = 14 * 24 * 60 * 60 * 1000;
|
||||||
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
|
|
||||||
const port = Number(process.env.WEB_PORT || 8080);
|
const port = Number(process.env.WEB_PORT || 8080);
|
||||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250));
|
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250));
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,6 @@ module.exports = {
|
|||||||
getAuditUserId: getAuditUserId,
|
getAuditUserId: getAuditUserId,
|
||||||
getCanvasSignature: getCanvasSignature,
|
getCanvasSignature: getCanvasSignature,
|
||||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasId,
|
|
||||||
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
|
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
|
||||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const vm = require('vm');
|
||||||
const {
|
const {
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
mediaKind,
|
mediaKind,
|
||||||
@@ -10,7 +12,13 @@ const {
|
|||||||
sanitizeFontSize,
|
sanitizeFontSize,
|
||||||
sanitizeTextColor
|
sanitizeTextColor
|
||||||
} = require('#src/player/render-helpers');
|
} = require('#src/player/render-helpers');
|
||||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth');
|
||||||
|
|
||||||
|
const placeholderUtils = (() => {
|
||||||
|
const sandbox = { window: {} };
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'js', 'shared', 'placeholder-utils.js'), 'utf8'), sandbox);
|
||||||
|
return sandbox.window.placeholderUtils || {};
|
||||||
|
})();
|
||||||
|
|
||||||
const SYSTEM_CHROMIUM_PATHS = [
|
const SYSTEM_CHROMIUM_PATHS = [
|
||||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||||
@@ -124,11 +132,51 @@ function hasVisibleContent(html) {
|
|||||||
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
|
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTextRegionMarkup(region, regionContent) {
|
function resolvePlaceholderPath(value, expression) {
|
||||||
|
const pathValue = String(expression || '').replace(/\.image\s*\([^)]*\)\s*$/i, '').trim();
|
||||||
|
return pathValue.split('.').reduce(function (current, segment) {
|
||||||
|
return current === undefined || current === null ? '' : current[segment];
|
||||||
|
}, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImagePlaceholderConfig(expression) {
|
||||||
|
const match = String(expression || '').match(/\.image\s*\(\s*([0-9]+)?\s*,?\s*([0-9]+)?\s*\)/i);
|
||||||
|
return {
|
||||||
|
width: match && match[1] ? Number(match[1]) : 0,
|
||||||
|
height: match && match[2] ? Number(match[2]) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function substitutePlaceholders(html, regionContent, options) {
|
||||||
|
const source = String(html || '');
|
||||||
|
const type = String(regionContent && regionContent.type || '').trim().toLowerCase();
|
||||||
|
const item = options && typeof options.getItem === 'function' ? options.getItem(type, regionContent) : null;
|
||||||
|
if (!item) return source;
|
||||||
|
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/gi, function (_match, expression) {
|
||||||
|
const resolved = typeof placeholderUtils.resolvePlaceholderExpression === 'function'
|
||||||
|
? placeholderUtils.resolvePlaceholderExpression(item, expression)
|
||||||
|
: resolvePlaceholderPath(item, expression);
|
||||||
|
const value = typeof placeholderUtils.formatPlaceholderValue === 'function' ? placeholderUtils.formatPlaceholderValue(resolved) : String(resolved || '');
|
||||||
|
if (typeof placeholderUtils.isImagePlaceholderExpression !== 'function' || !placeholderUtils.isImagePlaceholderExpression(expression)) {
|
||||||
|
return escapeHtml(value);
|
||||||
|
}
|
||||||
|
const remoteUrl = String(value || '').trim();
|
||||||
|
if (!/^https?:\/\//i.test(remoteUrl)) return '';
|
||||||
|
const imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : getImagePlaceholderConfig(expression);
|
||||||
|
const cacheFile = options && typeof options.getCachedImagePath === 'function' ? options.getCachedImagePath(remoteUrl) : '';
|
||||||
|
const imageUrl = cacheFile || remoteUrl;
|
||||||
|
const style = imageConfig.width && imageConfig.height
|
||||||
|
? 'display:block;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;object-fit:contain;'
|
||||||
|
: 'display:block;max-width:' + (imageConfig.width || 100) + 'px;max-height:' + (imageConfig.height || 100) + 'px;width:auto;height:auto;';
|
||||||
|
return '<img src="' + escapeHtml(resolveAssetUrl(options && options.baseUrl, imageUrl)) + '" alt="" style="' + style + '" />';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTextRegionMarkup(region, regionContent, options) {
|
||||||
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
|
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
|
||||||
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
|
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
|
||||||
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
|
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
|
||||||
const renderedBody = renderEditorJsContent(regionContent.value || '');
|
const renderedBody = renderEditorJsContent(substitutePlaceholders(regionContent.value || '', regionContent, options));
|
||||||
if (!hasVisibleContent(renderedBody)) {
|
if (!hasVisibleContent(renderedBody)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -136,7 +184,7 @@ function buildTextRegionMarkup(region, regionContent) {
|
|||||||
return '<div class="slide-preview-region slide-preview-text-region" style="' + region.baseStyle + '"><div class="slide-preview-text-content" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;overflow:hidden;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
return '<div class="slide-preview-region slide-preview-text-region" style="' + region.baseStyle + '"><div class="slide-preview-text-content" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;overflow:hidden;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
function buildRegionInnerHtml(region, regionContent, baseUrl, options) {
|
||||||
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
|
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
|
||||||
const rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
const rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
||||||
|
|
||||||
@@ -182,7 +230,7 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
|||||||
return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
|
return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildTextRegionMarkup(region, regionContent);
|
return buildTextRegionMarkup(region, regionContent, Object.assign({}, options, { baseUrl: baseUrl }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChromium() {
|
async function loadChromium() {
|
||||||
@@ -199,7 +247,7 @@ function loadSharp() {
|
|||||||
return require('sharp');
|
return require('sharp');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildThumbnailPreviewMarkup(slide, baseUrl) {
|
function buildThumbnailPreviewMarkup(slide, baseUrl, options) {
|
||||||
const template = slide && slide.template ? slide.template : null;
|
const template = slide && slide.template ? slide.template : null;
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return '';
|
return '';
|
||||||
@@ -214,7 +262,7 @@ function buildThumbnailPreviewMarkup(slide, baseUrl) {
|
|||||||
pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)),
|
pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)),
|
||||||
pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1))
|
pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1))
|
||||||
});
|
});
|
||||||
return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl);
|
return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl, options);
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +276,7 @@ function buildThumbnailPreviewPayload(slide, options) {
|
|||||||
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
|
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
|
||||||
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
|
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
|
||||||
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
|
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
|
||||||
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl)
|
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl, options)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +339,53 @@ async function captureSlideThumbnail(options) {
|
|||||||
throw new Error('Slide not found.');
|
throw new Error('Slide not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rssFeedsData = typeof common.fetchRssFeedsData === 'function' ? await common.fetchRssFeedsData(pool) : { rssFeeds: [] };
|
||||||
|
const rssFeeds = await Promise.all((rssFeedsData.rssFeeds || []).map(async function (feed) {
|
||||||
|
const items = typeof common.fetchRssFeedItemsByFeedId === 'function' ? await common.fetchRssFeedItemsByFeedId(pool, feed.id) : [];
|
||||||
|
return Object.assign({}, feed, { items: items });
|
||||||
|
}));
|
||||||
|
const apiSourcesData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||||
|
const apiSources = (apiSourcesData.apiSources || []).map(function (source) {
|
||||||
|
return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null });
|
||||||
|
});
|
||||||
|
|
||||||
|
function getApiItems(source) {
|
||||||
|
const response = source && source.responseJson;
|
||||||
|
const itemsPath = String(source && source.items_path || '').trim();
|
||||||
|
if (itemsPath) {
|
||||||
|
const selected = itemsPath.split('.').reduce(function (current, segment) {
|
||||||
|
return current === undefined || current === null ? '' : current[segment];
|
||||||
|
}, response);
|
||||||
|
return Array.isArray(selected) ? selected : [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(response)) return response;
|
||||||
|
if (response && Array.isArray(response.items)) return response.items;
|
||||||
|
if (response && Array.isArray(response.results)) return response.results;
|
||||||
|
if (response && Array.isArray(response.data)) return response.data;
|
||||||
|
return response ? [response] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThumbnailItem(type, regionContent) {
|
||||||
|
const index = Math.max(0, Math.max(1, Number(regionContent && regionContent.item_number || 1)) - 1);
|
||||||
|
if (type === 'api') {
|
||||||
|
const source = apiSources.find(function (entry) { return Number(entry.id) === Number(regionContent.source_id); });
|
||||||
|
return getApiItems(source)[index] || null;
|
||||||
|
}
|
||||||
|
if (type === 'rss') {
|
||||||
|
const feed = rssFeeds.find(function (entry) { return Number(entry.id) === Number(regionContent.feed_id); });
|
||||||
|
return feed && Array.isArray(feed.items) ? feed.items[index] || null : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedImagePath(remoteUrl) {
|
||||||
|
const hash = crypto.createHash('sha256').update(String(remoteUrl || '')).digest('hex');
|
||||||
|
const cacheDir = path.join(mediaDir, 'player-cache', 'remote-images');
|
||||||
|
const candidates = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
|
||||||
|
const candidate = candidates.map(function (extension) { return path.join(cacheDir, hash + extension); }).find(function (filePath) { return fs.existsSync(filePath); });
|
||||||
|
return candidate ? '/media/player-cache/remote-images/' + path.basename(candidate) : '';
|
||||||
|
}
|
||||||
|
|
||||||
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
||||||
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
||||||
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
||||||
@@ -340,22 +435,29 @@ async function captureSlideThumbnail(options) {
|
|||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
try {
|
try {
|
||||||
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/popup-preview';
|
|
||||||
const previewUrl = baseUrl + previewPath;
|
|
||||||
const previewPayload = buildThumbnailPreviewPayload(slide, {
|
const previewPayload = buildThumbnailPreviewPayload(slide, {
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
fontStylesheetHref: options && options.fontStylesheetHref ? options.fontStylesheetHref : ''
|
fontStylesheetHref: options && options.fontStylesheetHref ? options.fontStylesheetHref : '',
|
||||||
|
getItem: getThumbnailItem,
|
||||||
|
getCachedImagePath: getCachedImagePath
|
||||||
});
|
});
|
||||||
|
const previewPath = '/slides/popup-preview';
|
||||||
|
const previewUrl = baseUrl + previewPath + '#' + encodeURIComponent(JSON.stringify(previewPayload));
|
||||||
await page.setViewport({
|
await page.setViewport({
|
||||||
width: Math.max(1, Number(previewPayload.canvasWidth || PLAYER_VIEWPORT.width)),
|
width: Math.max(1, Number(previewPayload.canvasWidth || PLAYER_VIEWPORT.width)),
|
||||||
height: Math.max(1, Number(previewPayload.canvasHeight || PLAYER_VIEWPORT.height)),
|
height: Math.max(1, Number(previewPayload.canvasHeight || PLAYER_VIEWPORT.height)),
|
||||||
deviceScaleFactor: 1
|
deviceScaleFactor: 1
|
||||||
});
|
});
|
||||||
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
await page.setExtraHTTPHeaders(Object.assign({}, createRequestAuthHeaders({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
pathname: previewPath
|
pathname: previewPath
|
||||||
|
}), {
|
||||||
|
'x-pulse-page-auth': createPageAuthToken({ scope: 'thumbnail-preview', slideId: slide.id })
|
||||||
}));
|
}));
|
||||||
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
const previewResponse = await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
if (!previewResponse || previewResponse.status() >= 400) {
|
||||||
|
throw new Error('Popup preview returned HTTP ' + (previewResponse ? previewResponse.status() : 'no response') + '.');
|
||||||
|
}
|
||||||
await waitForThumbnailRender(page);
|
await waitForThumbnailRender(page);
|
||||||
const canvas = await page.$('#popup-preview-canvas');
|
const canvas = await page.$('#popup-preview-canvas');
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
|
|||||||
@@ -432,6 +432,10 @@ function createUploadSyncService(options) {
|
|||||||
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
|
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
|
||||||
const nextAbsolutePath = path.join(currentDir, entryName);
|
const nextAbsolutePath = path.join(currentDir, entryName);
|
||||||
|
|
||||||
|
if (!relativeDir && entryName === 'player-cache') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.isDirectory && entry.isDirectory()) {
|
if (entry.isDirectory && entry.isDirectory()) {
|
||||||
await walkDirectory(nextAbsolutePath, nextRelativePath);
|
await walkDirectory(nextAbsolutePath, nextRelativePath);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const path = require('path');
|
|||||||
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
|
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
|
||||||
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
|
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
|
||||||
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
|
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
|
||||||
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js'];
|
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js'];
|
||||||
|
|
||||||
function withAssetVersion(scriptPath, assetVersion) {
|
function withAssetVersion(scriptPath, assetVersion) {
|
||||||
if (!assetVersion) {
|
if (!assetVersion) {
|
||||||
|
|||||||
@@ -29,10 +29,15 @@ module.exports = function registerMiddleware(app, deps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use(function (req, res, next) {
|
app.use(function (req, res, next) {
|
||||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font') {
|
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font') {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
return requireAuth(req, res, next);
|
return requireAuth(req, res, function () {
|
||||||
|
if (req.currentUser && req.currentUser.mustChangePassword && req.path !== '/account' && req.path !== '/account/password' && req.path !== '/account/sessions/revoke' && req.path !== '/logout') {
|
||||||
|
return res.redirect('/account?message=' + encodeURIComponent('Please change your password before continuing.'));
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Forward unmatched requests to the shared error-page handler.
|
||||||
|
|
||||||
|
module.exports = function registerNotFoundHandler(app) {
|
||||||
|
app.use(function (_req, _res, next) {
|
||||||
|
const error = new Error('Page not found.');
|
||||||
|
error.statusCode = 404;
|
||||||
|
next(error);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -9,6 +9,8 @@ function routePath(...segments) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
renderLoginPage: require(routePath('auth', 'login')),
|
renderLoginPage: require(routePath('auth', 'login')),
|
||||||
renderAccountPage: require(routePath('account', 'password')),
|
renderAccountPage: require(routePath('account', 'password')),
|
||||||
|
renderSettingsPage: require(routePath('settings', 'index')),
|
||||||
|
renderAboutPage: require(routePath('settings', 'about', 'index')),
|
||||||
renderUsersPage: require(routePath('settings', 'users', 'list')),
|
renderUsersPage: require(routePath('settings', 'users', 'list')),
|
||||||
renderUsersAddPage: require(routePath('settings', 'users', 'add')),
|
renderUsersAddPage: require(routePath('settings', 'users', 'add')),
|
||||||
renderUsersEditPage: require(routePath('settings', 'users', 'edit')),
|
renderUsersEditPage: require(routePath('settings', 'users', 'edit')),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -163,6 +163,37 @@
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-nav-card {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-card h4.text-uppercase {
|
||||||
|
color: var(--bs-emphasis-color) !important;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-audit-retention-input {
|
||||||
|
max-width: 18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-audit-category-label {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.settings-nav-card {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
@@ -224,6 +255,39 @@
|
|||||||
.template-preview-card .btn-group {
|
.template-preview-card .btn-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
.audit-change-list {
|
||||||
|
display: grid;
|
||||||
|
padding: 0.08rem 0.3rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 18rem;
|
||||||
|
}
|
||||||
|
.audit-change-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
|
||||||
|
background: var(--bs-danger-bg-subtle);
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
background: var(--bs-success-bg-subtle);
|
||||||
|
.audit-change-to {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.audit-change-from {
|
||||||
|
color: var(--bs-danger-text-emphasis);
|
||||||
|
}
|
||||||
|
.audit-change-to {
|
||||||
|
color: var(--bs-success-text-emphasis);
|
||||||
|
}
|
||||||
|
.audit-change-from del,
|
||||||
|
.audit-change-to ins {
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
.audit-change-arrow {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +327,12 @@
|
|||||||
border-bottom-width: 0;
|
border-bottom-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rbac-permissions-table > thead > tr > * {
|
||||||
|
background-color: var(--bs-tertiary-bg) !important;
|
||||||
|
color: var(--bs-emphasis-color) !important;
|
||||||
|
border-bottom-color: var(--bs-border-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.template-designer-form--previewing .region-item [disabled],
|
.template-designer-form--previewing .region-item [disabled],
|
||||||
.template-designer-form--previewing .template-details-card [disabled],
|
.template-designer-form--previewing .template-details-card [disabled],
|
||||||
.template-designer-form--previewing .template-options-card [disabled],
|
.template-designer-form--previewing .template-options-card [disabled],
|
||||||
@@ -405,6 +475,11 @@
|
|||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: var(--bs-body-bg);
|
background: var(--bs-body-bg);
|
||||||
box-shadow: 0 1rem 2rem rgba(15, 23, 42, 0.18);
|
box-shadow: 0 1rem 2rem rgba(15, 23, 42, 0.18);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: min(32rem, calc(100vh - 3rem));
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.announcement-type-picker__menu {
|
.announcement-type-picker__menu {
|
||||||
@@ -437,15 +512,61 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.announcement-icon-picker__search {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-icon-picker__search .input-group-text {
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-icon-picker__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-icon-picker__section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-icon-picker__section-count {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.announcement-icon-picker__grid {
|
.announcement-icon-picker__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(2.75rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(2.75rem, 1fr));
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-icon-picker__search-grid {
|
||||||
|
max-height: none;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-icon-picker__search-empty {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.announcement-type-picker__grid {
|
.announcement-type-picker__grid {
|
||||||
@@ -742,6 +863,10 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-table-pagination-card] > .card-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
[data-table-pagination-card] > .card-footer {
|
[data-table-pagination-card] > .card-footer {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
background: var(--bs-body-bg);
|
background: var(--bs-body-bg);
|
||||||
@@ -752,6 +877,18 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-lock-label-locked {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-check:checked + label .account-lock-label-unlocked {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-check:checked + label .account-lock-label-locked {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-form-card .card-header {
|
.admin-form-card .card-header {
|
||||||
background: var(--bs-tertiary-bg);
|
background: var(--bs-tertiary-bg);
|
||||||
}
|
}
|
||||||
@@ -2396,6 +2533,13 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-region-placeholder-title-help {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.api-region-sample-accordion {
|
.api-region-sample-accordion {
|
||||||
padding: 0.85rem 1rem 1rem;
|
padding: 0.85rem 1rem 1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
|
|||||||
@@ -441,6 +441,21 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
form.dispatchEvent(new CustomEvent('web-async-save:success', {
|
||||||
|
bubbles: true,
|
||||||
|
detail: {
|
||||||
|
form: form,
|
||||||
|
response: response,
|
||||||
|
responseText: responseText,
|
||||||
|
responseDocument: responseDocument,
|
||||||
|
submitterValue: submitterValue
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore event dispatch failures and continue the save flow.
|
||||||
|
}
|
||||||
|
|
||||||
clearFormDirty(form);
|
clearFormDirty(form);
|
||||||
|
|
||||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||||
|
|||||||
@@ -39,6 +39,63 @@ function setAnnouncementScreenSelection(isSelected) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAnnouncementActionButtonState() {
|
||||||
|
var actionForm = document.getElementById('announcement-action-form');
|
||||||
|
var visibleButton = document.querySelector('button[form="announcement-action-form"]');
|
||||||
|
var actionPath = actionForm ? String(actionForm.getAttribute('action') || '').trim() : '';
|
||||||
|
var isPlay = /\/play$/.test(actionPath);
|
||||||
|
var isActive = !isPlay;
|
||||||
|
var selectedScreenCount = document.querySelectorAll('input[name="screen_ids[]"]:checked').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleButton: visibleButton,
|
||||||
|
actionForm: actionForm,
|
||||||
|
isActive: isActive,
|
||||||
|
hasTargets: selectedScreenCount > 0,
|
||||||
|
actionDisabled: !isActive && selectedScreenCount === 0,
|
||||||
|
actionLabel: isActive ? 'Stop' : 'Play',
|
||||||
|
actionIcon: isActive ? 'bi-stop-fill' : 'bi-play-fill',
|
||||||
|
actionClassName: !isActive && selectedScreenCount === 0
|
||||||
|
? 'btn-outline-info'
|
||||||
|
: (isActive ? 'btn-outline-warning' : 'btn-info'),
|
||||||
|
actionDisabledTitle: !isActive && selectedScreenCount === 0
|
||||||
|
? 'Select at least one screen group to play this announcement.'
|
||||||
|
: '',
|
||||||
|
actionConfirmMessage: isActive
|
||||||
|
? 'Stop this announcement on the selected screens now?'
|
||||||
|
: 'Send this announcement to the selected screens now?'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAnnouncementActionButtonState() {
|
||||||
|
var state = getAnnouncementActionButtonState();
|
||||||
|
if (!state.visibleButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.visibleButton.className = state.visibleButton.className.replace(/btn-outline-(info|warning|success)|btn-info/g, state.actionClassName);
|
||||||
|
state.visibleButton.disabled = state.actionDisabled;
|
||||||
|
state.visibleButton.setAttribute('aria-disabled', state.actionDisabled ? 'true' : 'false');
|
||||||
|
state.visibleButton.setAttribute('title', state.actionDisabled ? state.actionDisabledTitle : state.actionConfirmMessage);
|
||||||
|
state.visibleButton.setAttribute('aria-label', state.actionLabel);
|
||||||
|
|
||||||
|
var icon = state.visibleButton.querySelector('i.bi');
|
||||||
|
if (icon) {
|
||||||
|
icon.className = 'bi ' + state.actionIcon + ' me-1';
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = state.visibleButton.childNodes.length > 1 ? state.visibleButton.childNodes[state.visibleButton.childNodes.length - 1] : null;
|
||||||
|
if (label && label.nodeType === Node.TEXT_NODE) {
|
||||||
|
label.textContent = state.actionLabel;
|
||||||
|
} else {
|
||||||
|
state.visibleButton.textContent = state.actionLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.actionForm) {
|
||||||
|
state.actionForm.setAttribute('data-confirm-message', state.actionConfirmMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function positionAnnouncementTypePicker() {
|
function positionAnnouncementTypePicker() {
|
||||||
var picker = document.querySelector('[data-announcement-type-picker]');
|
var picker = document.querySelector('[data-announcement-type-picker]');
|
||||||
var toggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
var toggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||||
@@ -141,125 +198,6 @@ function setAnnouncementTypeValue(typeKey) {
|
|||||||
updateAnnouncementTypePickerSelection();
|
updateAnnouncementTypePickerSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAnnouncementIconPreview() {
|
|
||||||
var iconSelect = document.getElementById('announcement-icon');
|
|
||||||
var preview = document.querySelector('[data-announcement-icon-preview]');
|
|
||||||
if (!iconSelect || !preview) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectedOption = iconSelect.options[iconSelect.selectedIndex] || null;
|
|
||||||
var iconKey = selectedOption ? String(selectedOption.value || '').trim().toLowerCase() : '';
|
|
||||||
var label = selectedOption ? String(selectedOption.textContent || selectedOption.label || iconKey).trim() : iconKey;
|
|
||||||
|
|
||||||
preview.className = 'announcement-icon-preview';
|
|
||||||
preview.innerHTML = '<i class="bi bi-' + iconKey + '" aria-hidden="true"></i>';
|
|
||||||
preview.setAttribute('aria-label', label);
|
|
||||||
preview.setAttribute('title', label);
|
|
||||||
}
|
|
||||||
|
|
||||||
function positionAnnouncementIconPicker() {
|
|
||||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
|
||||||
var toggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
|
||||||
var shell = document.querySelector('[data-announcement-icon-picker-shell]');
|
|
||||||
if (!picker || picker.hidden || !toggle || !shell) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var padding = 8;
|
|
||||||
var toggleRect = toggle.getBoundingClientRect();
|
|
||||||
var menuRect = picker.getBoundingClientRect();
|
|
||||||
var viewportHeight = window.innerHeight || document.documentElement.clientHeight || toggleRect.bottom;
|
|
||||||
var placementAbove = false;
|
|
||||||
var spaceBelow = viewportHeight - toggleRect.bottom - padding;
|
|
||||||
var spaceAbove = toggleRect.top - padding;
|
|
||||||
|
|
||||||
if (menuRect.height > spaceBelow && spaceAbove > spaceBelow) {
|
|
||||||
placementAbove = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
picker.classList.toggle('is-open-above', placementAbove);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAnnouncementIconPicker() {
|
|
||||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
|
||||||
var toggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
|
||||||
if (!picker) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
picker.hidden = true;
|
|
||||||
picker.classList.remove('is-open-above');
|
|
||||||
if (toggle) {
|
|
||||||
toggle.setAttribute('aria-expanded', 'false');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAnnouncementIconPicker() {
|
|
||||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
|
||||||
var toggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
|
||||||
if (!picker) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
picker.hidden = false;
|
|
||||||
if (toggle) {
|
|
||||||
toggle.setAttribute('aria-expanded', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && window.requestAnimationFrame) {
|
|
||||||
window.requestAnimationFrame(positionAnnouncementIconPicker);
|
|
||||||
} else {
|
|
||||||
positionAnnouncementIconPicker();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAnnouncementIconPickerSelection() {
|
|
||||||
var iconSelect = document.getElementById('announcement-icon');
|
|
||||||
var previewButton = document.querySelector('[data-announcement-icon-picker-toggle]');
|
|
||||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
|
||||||
if (!iconSelect || !previewButton || !picker) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectedOption = iconSelect.options[iconSelect.selectedIndex] || null;
|
|
||||||
var selectedValue = selectedOption ? String(selectedOption.value || '').trim().toLowerCase() : '';
|
|
||||||
var label = selectedOption ? String(selectedOption.textContent || selectedOption.label || selectedValue).trim() : selectedValue;
|
|
||||||
var icon = previewButton.querySelector('[data-announcement-icon-picker-icon]');
|
|
||||||
var text = previewButton.querySelector('[data-announcement-icon-picker-label]');
|
|
||||||
|
|
||||||
previewButton.setAttribute('aria-label', label);
|
|
||||||
previewButton.setAttribute('title', label);
|
|
||||||
if (icon) {
|
|
||||||
icon.className = 'bi bi-' + selectedValue;
|
|
||||||
}
|
|
||||||
if (text) {
|
|
||||||
text.textContent = label;
|
|
||||||
}
|
|
||||||
|
|
||||||
picker.querySelectorAll('[data-announcement-icon-option]').forEach(function (button) {
|
|
||||||
var isSelected = String(button.getAttribute('data-icon-key') || '').trim().toLowerCase() === selectedValue;
|
|
||||||
button.classList.toggle('is-selected', isSelected);
|
|
||||||
button.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAnnouncementIconValue(iconKey) {
|
|
||||||
var iconSelect = document.getElementById('announcement-icon');
|
|
||||||
if (!iconSelect) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var normalized = String(iconKey || '').trim().toLowerCase();
|
|
||||||
if (!normalized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
iconSelect.value = normalized;
|
|
||||||
updateAnnouncementIconPreview();
|
|
||||||
updateAnnouncementIconPickerSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
function initAnnouncementForm() {
|
function initAnnouncementForm() {
|
||||||
var typeInput = document.getElementById('announcement-type');
|
var typeInput = document.getElementById('announcement-type');
|
||||||
var typePickerToggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
var typePickerToggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||||
@@ -270,10 +208,6 @@ function initAnnouncementForm() {
|
|||||||
var colorPicker = document.querySelector('[data-announcement-color-picker-shell]');
|
var colorPicker = document.querySelector('[data-announcement-color-picker-shell]');
|
||||||
var screenSelectAll = document.querySelector('[data-announcement-screen-select-all]');
|
var screenSelectAll = document.querySelector('[data-announcement-screen-select-all]');
|
||||||
var screenSelectNone = document.querySelector('[data-announcement-screen-select-none]');
|
var screenSelectNone = document.querySelector('[data-announcement-screen-select-none]');
|
||||||
var iconSelect = document.getElementById('announcement-icon');
|
|
||||||
var iconPickerToggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
|
||||||
var iconPicker = document.querySelector('[data-announcement-icon-picker]');
|
|
||||||
var iconPickerClose = document.querySelector('[data-announcement-icon-picker-close]');
|
|
||||||
|
|
||||||
if (typeInput) {
|
if (typeInput) {
|
||||||
updateAnnouncementTypePickerSelection();
|
updateAnnouncementTypePickerSelection();
|
||||||
@@ -347,44 +281,16 @@ function initAnnouncementForm() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (iconSelect) {
|
document.addEventListener('web-async-save:success', function (event) {
|
||||||
iconSelect.addEventListener('change', updateAnnouncementIconPreview);
|
var detail = event && event.detail ? event.detail : null;
|
||||||
updateAnnouncementIconPreview();
|
var form = detail && detail.form ? detail.form : null;
|
||||||
}
|
if (!form || form.id !== 'announcement-form') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (iconPickerToggle) {
|
updateAnnouncementActionButtonState();
|
||||||
iconPickerToggle.addEventListener('click', function (event) {
|
});
|
||||||
event.preventDefault();
|
|
||||||
var isExpanded = iconPicker && !iconPicker.hidden;
|
|
||||||
if (isExpanded) {
|
|
||||||
closeAnnouncementIconPicker();
|
|
||||||
} else {
|
|
||||||
openAnnouncementIconPicker();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (iconPickerClose) {
|
|
||||||
iconPickerClose.addEventListener('click', function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
closeAnnouncementIconPicker();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (iconPicker) {
|
|
||||||
iconPicker.addEventListener('click', function (event) {
|
|
||||||
var button = event.target && event.target.closest ? event.target.closest('[data-announcement-icon-option]') : null;
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
setAnnouncementIconValue(button.getAttribute('data-icon-key'));
|
|
||||||
closeAnnouncementIconPicker();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', positionAnnouncementIconPicker);
|
|
||||||
window.addEventListener('resize', positionAnnouncementTypePicker);
|
window.addEventListener('resize', positionAnnouncementTypePicker);
|
||||||
|
|
||||||
document.addEventListener('click', function (event) {
|
document.addEventListener('click', function (event) {
|
||||||
@@ -396,20 +302,9 @@ function initAnnouncementForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
|
||||||
var toggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
|
||||||
if (!picker || picker.hidden) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (picker.contains(event.target) || (toggle && toggle.contains(event.target))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeAnnouncementIconPicker();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
updateAnnouncementIconPickerSelection();
|
updateAnnouncementActionButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function initIconPicker(root) {
|
||||||
|
var select = root.querySelector('select');
|
||||||
|
var toggle = root.querySelector('[data-icon-picker-toggle]');
|
||||||
|
var menu = root.querySelector('[data-icon-picker-menu]');
|
||||||
|
var preview = root.querySelector('[data-icon-picker-preview]');
|
||||||
|
var label = root.querySelector('[data-icon-picker-label]');
|
||||||
|
var search = root.querySelector('[data-icon-picker-search]');
|
||||||
|
var close = root.querySelector('[data-icon-picker-close]');
|
||||||
|
var grid = root.querySelector('[data-icon-picker-grid]');
|
||||||
|
var empty = root.querySelector('[data-icon-picker-empty]');
|
||||||
|
var initialOptions = Array.prototype.slice.call(root.querySelectorAll('[data-icon-picker-option]'));
|
||||||
|
|
||||||
|
if (!select || !toggle || !menu || !grid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogOptions() {
|
||||||
|
return Array.prototype.slice.call(select.options).map(function (option) {
|
||||||
|
return { value: option.value, label: option.textContent || option.label || option.value };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOption(option, selectedValue) {
|
||||||
|
var button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'announcement-icon-picker__option';
|
||||||
|
button.setAttribute('data-icon-picker-option', '');
|
||||||
|
button.setAttribute('data-icon-key', option.value);
|
||||||
|
button.setAttribute('data-icon-label', option.label);
|
||||||
|
button.setAttribute('aria-pressed', option.value === selectedValue ? 'true' : 'false');
|
||||||
|
button.title = option.label;
|
||||||
|
button.innerHTML = '<i class="bi bi-' + option.value + '" aria-hidden="true"></i><span class="visually-hidden">' + option.label + '</span>';
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionLimit() {
|
||||||
|
var firstOption = grid.querySelector('[data-icon-picker-option]');
|
||||||
|
var gridStyle = window.getComputedStyle(grid);
|
||||||
|
var gap = parseFloat(gridStyle.columnGap || gridStyle.gap || '0') || 0;
|
||||||
|
var optionWidth = firstOption ? firstOption.getBoundingClientRect().width : 0;
|
||||||
|
var columns = optionWidth && grid.clientWidth
|
||||||
|
? Math.max(1, Math.floor((grid.clientWidth + gap) / (optionWidth + gap)))
|
||||||
|
: 4;
|
||||||
|
return columns * 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
var option = select.options[select.selectedIndex];
|
||||||
|
var value = option ? option.value : '';
|
||||||
|
if (preview) {
|
||||||
|
preview.className = 'announcement-icon-picker__toggle-icon bi bi-' + value;
|
||||||
|
}
|
||||||
|
if (label) {
|
||||||
|
label.textContent = option ? option.textContent : 'Select an icon';
|
||||||
|
}
|
||||||
|
root.querySelectorAll('[data-icon-picker-option]').forEach(function (optionButton) {
|
||||||
|
var selected = optionButton.getAttribute('data-icon-key') === value;
|
||||||
|
optionButton.classList.toggle('is-selected', selected);
|
||||||
|
optionButton.setAttribute('aria-pressed', selected ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(query) {
|
||||||
|
var normalizedQuery = String(query || '').trim().toLowerCase();
|
||||||
|
var selectedValue = String(select.value || '').trim();
|
||||||
|
var options = normalizedQuery
|
||||||
|
? catalogOptions().filter(function (option) { return (option.value + ' ' + option.label).toLowerCase().indexOf(normalizedQuery) !== -1; })
|
||||||
|
: initialOptions.map(function (option) { return { value: option.getAttribute('data-icon-key') || '', label: option.getAttribute('data-icon-label') || '' }; });
|
||||||
|
var visibleOptions = options.slice(0, getOptionLimit());
|
||||||
|
grid.innerHTML = '';
|
||||||
|
visibleOptions.forEach(function (option) { grid.appendChild(createOption(option, selectedValue)); });
|
||||||
|
if (empty) empty.hidden = options.length > 0;
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
menu.hidden = true;
|
||||||
|
toggle.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener('click', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
menu.hidden = !menu.hidden;
|
||||||
|
toggle.setAttribute('aria-expanded', menu.hidden ? 'false' : 'true');
|
||||||
|
if (!menu.hidden) {
|
||||||
|
render(search ? search.value : '');
|
||||||
|
if (search) search.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (close) close.addEventListener('click', function (event) { event.preventDefault(); closeMenu(); });
|
||||||
|
grid.addEventListener('click', function (event) {
|
||||||
|
var option = event.target.closest ? event.target.closest('[data-icon-picker-option]') : null;
|
||||||
|
if (!option) return;
|
||||||
|
select.value = option.getAttribute('data-icon-key') || '';
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
closeMenu();
|
||||||
|
});
|
||||||
|
if (search) search.addEventListener('input', function () { render(search.value); });
|
||||||
|
select.addEventListener('change', updatePreview);
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (!menu.hidden && !root.contains(event.target)) closeMenu();
|
||||||
|
});
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.PulseIconPicker = { init: initIconPicker };
|
||||||
|
document.querySelectorAll('[data-icon-picker]').forEach(initIconPicker);
|
||||||
|
}());
|
||||||
@@ -1665,6 +1665,7 @@
|
|||||||
var slidePickerSlides = [];
|
var slidePickerSlides = [];
|
||||||
var lastDurationPointerDown = null;
|
var lastDurationPointerDown = null;
|
||||||
var lastDurationPointerUp = null;
|
var lastDurationPointerUp = null;
|
||||||
|
var defaultSlideDuration = Number(tbody.getAttribute('data-default-slide-duration')) || 10;
|
||||||
|
|
||||||
if (!tbody || !form) {
|
if (!tbody || !form) {
|
||||||
return;
|
return;
|
||||||
@@ -1961,8 +1962,8 @@
|
|||||||
videoDurationSeconds: slide.videoDurationSeconds,
|
videoDurationSeconds: slide.videoDurationSeconds,
|
||||||
disableAudio: slide.disableAudio,
|
disableAudio: slide.disableAudio,
|
||||||
title: slide.title || 'Slide',
|
title: slide.title || 'Slide',
|
||||||
duration_seconds: 10,
|
duration_seconds: defaultSlideDuration,
|
||||||
durationSeconds: 10,
|
durationSeconds: defaultSlideDuration,
|
||||||
scheduleRules: [],
|
scheduleRules: [],
|
||||||
summary: 'Always visible'
|
summary: 'Always visible'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -105,6 +105,13 @@
|
|||||||
return document.querySelector('[data-permission-row-id="' + targetId + '"]');
|
return document.querySelector('[data-permission-row-id="' + targetId + '"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markPermissionFormDirty(control) {
|
||||||
|
var form = control && (control.form || (typeof control.closest === 'function' ? control.closest('form') : null));
|
||||||
|
if (form && form.dataset) {
|
||||||
|
form.dataset.dirty = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleRowChange(event) {
|
function handleRowChange(event) {
|
||||||
var checkbox = event.target && event.target.matches ? event.target : null;
|
var checkbox = event.target && event.target.matches ? event.target : null;
|
||||||
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
||||||
@@ -162,6 +169,7 @@
|
|||||||
|
|
||||||
if (control.hasAttribute('data-permission-row-toggle')) {
|
if (control.hasAttribute('data-permission-row-toggle')) {
|
||||||
toggleRowCheckboxes(findPermissionRow(targetId));
|
toggleRowCheckboxes(findPermissionRow(targetId));
|
||||||
|
markPermissionFormDirty(control);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function substituteVariables(html, item) {
|
function substituteVariables(html, item, options) {
|
||||||
var source = String(html || '');
|
var source = String(html || '');
|
||||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||||
if (!item || typeof item !== 'object') {
|
if (!item || typeof item !== 'object') {
|
||||||
@@ -105,7 +105,23 @@
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression)));
|
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
|
||||||
|
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
|
||||||
|
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
|
||||||
|
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
|
||||||
|
var imageStyle = options && options.preview && imageConfig.width && imageConfig.height
|
||||||
|
? 'display:block;width:100%;height:100%;object-fit:contain;'
|
||||||
|
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
|
||||||
|
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
|
||||||
|
if (options && options.preview && imageConfig.width && imageConfig.height) {
|
||||||
|
return '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);">' + image + '</span>';
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +152,24 @@
|
|||||||
return summary.join('');
|
return summary.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTransformInfo(regionId) {
|
||||||
|
var offcanvasId = 'api-placeholder-info-' + regionId;
|
||||||
|
return '' +
|
||||||
|
'<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" data-bs-toggle="offcanvas" data-bs-target="#' + offcanvasId + '" aria-controls="' + offcanvasId + '">More info</button>' +
|
||||||
|
'<div class="offcanvas offcanvas-end fw-normal" tabindex="-1" id="' + offcanvasId + '" aria-labelledby="' + offcanvasId + '-label">' +
|
||||||
|
'<div class="offcanvas-header">' +
|
||||||
|
'<h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">API placeholder transforms</h2>' +
|
||||||
|
'<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="offcanvas-body">' +
|
||||||
|
'<p class="small text-body-secondary">Placeholders read values from the selected API item. Nested fields use dots.</p>' +
|
||||||
|
(window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') +
|
||||||
|
(window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') +
|
||||||
|
(window.placeholderInfo ? window.placeholderInfo.renderImageTransform() : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRenderableValue(value) {
|
function normalizeRenderableValue(value) {
|
||||||
if (value && typeof value === 'object') {
|
if (value && typeof value === 'object') {
|
||||||
if (value.value !== undefined) {
|
if (value.value !== undefined) {
|
||||||
@@ -170,11 +204,8 @@
|
|||||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
|
||||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
|
||||||
var item = getItem(sourceId, itemNumber, sources, itemsPath);
|
var item = getItem(sourceId, itemNumber, sources, itemsPath);
|
||||||
var body = item ? substituteVariables(content, item) : '';
|
var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== '';
|
||||||
|
var body = hasSource ? (item ? substituteVariables(content, item, { preview: true }) : '') : content;
|
||||||
if (!body && item) {
|
|
||||||
body = getPreviewFallback(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
body = String(body || '')
|
body = String(body || '')
|
||||||
.replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '')
|
.replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '')
|
||||||
@@ -239,7 +270,7 @@
|
|||||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
||||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||||
'<div class="api-region-placeholder-section">' +
|
'<div class="api-region-placeholder-section">' +
|
||||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderTransformInfo(region.id) + '</div>' +
|
||||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
|
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
|
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
|
||||||
@@ -290,7 +321,7 @@
|
|||||||
return fallbackValue;
|
return fallbackValue;
|
||||||
}
|
}
|
||||||
}())),
|
}())),
|
||||||
source_id: (card.querySelector('select[name="region_api_source_id_' + region.id + '"]') || {}).value || current.source_id,
|
source_id: card.querySelector('select[name="region_api_source_id_' + region.id + '"]') ? card.querySelector('select[name="region_api_source_id_' + region.id + '"]').value : current.source_id,
|
||||||
item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number,
|
item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number,
|
||||||
items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path
|
items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path
|
||||||
} : current;
|
} : current;
|
||||||
|
|||||||
@@ -76,17 +76,31 @@
|
|||||||
|
|
||||||
function buildEditorCardContext(context) {
|
function buildEditorCardContext(context) {
|
||||||
var defaultConfig = context && context.uploadConfig ? context.uploadConfig : {};
|
var defaultConfig = context && context.uploadConfig ? context.uploadConfig : {};
|
||||||
|
var configuredTypes = Array.isArray(context && context.uploadMimeTypes)
|
||||||
|
? context.uploadMimeTypes.filter(function (mimeType) { return String(mimeType || '').indexOf('image/') === 0; })
|
||||||
|
: [];
|
||||||
|
var accept = configuredTypes.length
|
||||||
|
? configuredTypes
|
||||||
|
: (Array.isArray(defaultConfig.accept) ? defaultConfig.accept : ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']);
|
||||||
|
var acceptLabels = accept.map(function (mimeType) {
|
||||||
|
var subtype = String(mimeType).split('/')[1] || '';
|
||||||
|
return subtype === 'jpeg' ? 'JPG' : subtype === 'svg+xml' ? 'SVG' : subtype.toUpperCase();
|
||||||
|
});
|
||||||
|
var acceptLabel = acceptLabels.length > 1
|
||||||
|
? acceptLabels.slice(0, -1).join(', ') + ' or ' + acceptLabels[acceptLabels.length - 1]
|
||||||
|
: (acceptLabels[0] || 'supported image');
|
||||||
return {
|
return {
|
||||||
region: context.region,
|
region: context.region,
|
||||||
current: String(context.current || ''),
|
current: String(context.current || ''),
|
||||||
regionRatio: String(context.regionRatio || '1:1'),
|
regionRatio: String(context.regionRatio || '1:1'),
|
||||||
uploadConfig: {
|
uploadConfig: {
|
||||||
accept: Array.isArray(defaultConfig.accept) ? defaultConfig.accept : ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'],
|
accept: accept,
|
||||||
limitBytes: defaultConfig.limitBytes,
|
limitBytes: defaultConfig.limitBytes || context.uploadMaxBytes,
|
||||||
limitLabel: defaultConfig.limitLabel || context.uploadMaxLabel || '100 MB',
|
limitLabel: defaultConfig.limitLabel || context.uploadMaxLabel || '100 MB',
|
||||||
helpText: defaultConfig.helpText || 'PNG, JPG, GIF, WebP, or SVG'
|
helpText: defaultConfig.helpText || acceptLabel
|
||||||
},
|
},
|
||||||
uploadMaxLabel: context.uploadMaxLabel || '100 MB'
|
uploadMaxLabel: context.uploadMaxLabel || '100 MB',
|
||||||
|
uploadMimeTypes: context.uploadMimeTypes || []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +122,17 @@
|
|||||||
|
|
||||||
var uploadConfig = context.uploadConfig || {};
|
var uploadConfig = context.uploadConfig || {};
|
||||||
var accept = uploadConfig.accept || ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
|
var accept = uploadConfig.accept || ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||||
|
var acceptLabels = accept.map(function (mimeType) {
|
||||||
|
var subtype = String(mimeType).split('/')[1] || '';
|
||||||
|
return subtype === 'jpeg' ? 'JPG' : subtype === 'svg+xml' ? 'SVG' : subtype.toUpperCase();
|
||||||
|
});
|
||||||
|
var acceptLabel = acceptLabels.length > 1
|
||||||
|
? acceptLabels.slice(0, -1).join(', ') + (acceptLabels.length > 2 ? ', or ' : ' or ') + acceptLabels[acceptLabels.length - 1]
|
||||||
|
: (acceptLabels[0] || 'supported image');
|
||||||
var limitBytes = Number(uploadConfig.limitBytes || 100 * 1024 * 1024);
|
var limitBytes = Number(uploadConfig.limitBytes || 100 * 1024 * 1024);
|
||||||
|
|
||||||
if (!utils.fileMatchesAccept || !utils.fileMatchesAccept(context.file, accept)) {
|
if (!utils.fileMatchesAccept || !utils.fileMatchesAccept(context.file, accept)) {
|
||||||
showUploadWarning('This image region accepts PNG, JPG, GIF, WebP, or SVG files.');
|
showUploadWarning('This image region accepts ' + acceptLabel + ' files.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +154,7 @@
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
accept: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'],
|
accept: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'],
|
||||||
limitBytes: defaultConfig.limitBytes,
|
limitBytes: defaultConfig.limitBytes || context.uploadMaxBytes,
|
||||||
limitLabel: defaultConfig.limitLabel,
|
limitLabel: defaultConfig.limitLabel,
|
||||||
helpText: 'PNG, JPG, GIF, WebP, or SVG'
|
helpText: 'PNG, JPG, GIF, WebP, or SVG'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var registry = window.pulseRegionTypes;
|
var registry = window.pulseRegionTypes;
|
||||||
var utils = window.pulseRegionUtils || {};
|
var utils = window.pulseRegionUtils || {};
|
||||||
|
var placeholderUtils = window.placeholderUtils || {};
|
||||||
var DEFAULT_STYLE = {
|
var DEFAULT_STYLE = {
|
||||||
font_family: 'Arial',
|
font_family: 'Arial',
|
||||||
font_size: 32,
|
font_size: 32,
|
||||||
@@ -114,14 +115,32 @@
|
|||||||
return current === undefined || current === null ? '' : current;
|
return current === undefined || current === null ? '' : current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function substituteVariables(html, item) {
|
function substituteVariables(html, item, options) {
|
||||||
var source = String(html || '');
|
var source = String(html || '');
|
||||||
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
|
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||||
if (!item || typeof item !== 'object') {
|
if (!item || typeof item !== 'object') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName;
|
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
|
||||||
return escapeHtml(resolvePath(item, key || ''));
|
return '';
|
||||||
|
}
|
||||||
|
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
|
||||||
|
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
|
||||||
|
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
|
||||||
|
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
|
||||||
|
var imageStyle = options && options.preview && imageConfig.width && imageConfig.height
|
||||||
|
? 'display:block;width:100%;height:100%;object-fit:contain;'
|
||||||
|
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
|
||||||
|
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
|
||||||
|
if (options && options.preview && imageConfig.width && imageConfig.height) {
|
||||||
|
return '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);">' + image + '</span>';
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,18 +205,8 @@
|
|||||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
|
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
|
||||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
|
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
|
||||||
var item = getItem(feedId, itemNumber, feeds);
|
var item = getItem(feedId, itemNumber, feeds);
|
||||||
var body = item ? substituteVariables(content, item) : '';
|
var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== '';
|
||||||
|
var body = hasFeed ? (item ? substituteVariables(content, item, { preview: true }) : '') : content;
|
||||||
if (!body && item) {
|
|
||||||
var summaryParts = [];
|
|
||||||
if (item.title) {
|
|
||||||
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
|
|
||||||
}
|
|
||||||
if (item.description) {
|
|
||||||
summaryParts.push('<div>' + sanitizePreviewHtml(item.description) + '</div>');
|
|
||||||
}
|
|
||||||
body = summaryParts.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return '';
|
return '';
|
||||||
@@ -206,6 +215,21 @@
|
|||||||
return renderedBody ? '<div class="template-region rss" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
|
return renderedBody ? '<div class="template-region rss" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPlaceholderInfo(regionId) {
|
||||||
|
if (!window.placeholderInfo) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return window.placeholderInfo.render(regionId, 'RSS placeholder transforms',
|
||||||
|
'<p class="small text-body-secondary">Placeholders read values from the selected RSS entry. Use the available field chips as the starting point for your message.</p>' +
|
||||||
|
'<h3 class="fs-6 mt-4 fw-normal">Placeholder paths</h3>' +
|
||||||
|
'<p class="small">Nested values use dots. Missing values render as empty text.</p>' +
|
||||||
|
(window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') +
|
||||||
|
'<h3 class="fs-6 mt-4 fw-normal">Date formatting</h3>' +
|
||||||
|
'<p class="small">Use <code>format("MMM D, YYYY")</code> with date fields. Use <code>tz()</code> for a short timezone name and <code>tz_long()</code> for the full timezone name.</p>' +
|
||||||
|
(window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') +
|
||||||
|
(window.placeholderInfo ? window.placeholderInfo.renderImageTransform() : ''));
|
||||||
|
}
|
||||||
|
|
||||||
function renderEditorCard(context) {
|
function renderEditorCard(context) {
|
||||||
var region = context.region;
|
var region = context.region;
|
||||||
var current = context.current || {};
|
var current = context.current || {};
|
||||||
@@ -221,7 +245,7 @@
|
|||||||
'<span class="chip">RSS</span>' +
|
'<span class="chip">RSS</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3 pb-0">' +
|
'<div class="card-body p-3 d-grid gap-3">' +
|
||||||
'<div class="d-flex justify-content-end">' +
|
'<div class="d-flex justify-content-end">' +
|
||||||
'<div class="btn-group btn-group-sm template-editor-size-controls" role="group" aria-label="Editor size controls">' +
|
'<div class="btn-group btn-group-sm template-editor-size-controls" role="group" aria-label="Editor size controls">' +
|
||||||
'<button type="button" class="btn btn-outline-secondary" data-editor-size-action="decrease" aria-label="Decrease editor size">-</button>' +
|
'<button type="button" class="btn btn-outline-secondary" data-editor-size-action="decrease" aria-label="Decrease editor size">-</button>' +
|
||||||
@@ -247,9 +271,8 @@
|
|||||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
||||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||||
'<div class="api-region-placeholder-section">' +
|
'<div class="api-region-placeholder-section">' +
|
||||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</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 class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
|
||||||
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, or <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>' +
|
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -291,7 +314,7 @@
|
|||||||
return fallbackValue;
|
return fallbackValue;
|
||||||
}
|
}
|
||||||
}())),
|
}())),
|
||||||
feed_id: (card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') || {}).value || current.feed_id,
|
feed_id: card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') ? card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]').value : current.feed_id,
|
||||||
item_number: (card.querySelector('input[name="region_rss_item_number_' + region.id + '"]') || {}).value || current.item_number
|
item_number: (card.querySelector('input[name="region_rss_item_number_' + region.id + '"]') || {}).value || current.item_number
|
||||||
} : current;
|
} : current;
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var style = styleOrContext && styleOrContext.style ? styleOrContext.style : styleOrContext || {};
|
var style = styleOrContext && styleOrContext.style ? styleOrContext.style : styleOrContext || {};
|
||||||
var fontFamily = style && style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
|
var fontFamily = 'font-family:' + escapeHtml(style && style.font_family || DEFAULT_STYLE.font_family) + ';';
|
||||||
var color = style && style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
var color = 'color:' + escapeHtml(style && style.font_color || DEFAULT_STYLE.font_color) + ';';
|
||||||
var fontSize = style && style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
var fontSize = 'font-size:' + Math.max(1, Math.round(Number(style && style.font_size || DEFAULT_STYLE.font_size))) + 'px;';
|
||||||
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
|
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
|
||||||
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
||||||
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;line-height:1.5;' + fontFamily + fontSize + color;
|
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;line-height:1.5;' + fontFamily + fontSize + color;
|
||||||
|
|||||||
@@ -286,6 +286,19 @@
|
|||||||
return renderTimeDatePlaceholderChips();
|
return renderTimeDatePlaceholderChips();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPlaceholderInfo(regionId) {
|
||||||
|
if (!window.placeholderInfo) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var placeholders = window.timeDatePlaceholders && typeof window.timeDatePlaceholders.getTokens === 'function'
|
||||||
|
? window.timeDatePlaceholders.getTokens()
|
||||||
|
: [];
|
||||||
|
var table = '<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Placeholder</th><th>Output</th><th>Description</th></tr></thead><tbody>' + placeholders.map(function (item) {
|
||||||
|
return '<tr><td><code>{{' + escapeHtml(item.token) + '}}</code></td><td>' + escapeHtml(item.output) + '</td><td>' + escapeHtml(item.description) + '</td></tr>';
|
||||||
|
}).join('') + '</tbody></table></div>';
|
||||||
|
return window.placeholderInfo.render(regionId, 'Time and date placeholders', table);
|
||||||
|
}
|
||||||
|
|
||||||
function renderEditorCard(context) {
|
function renderEditorCard(context) {
|
||||||
var region = context.region;
|
var region = context.region;
|
||||||
var current = context.current || {};
|
var current = context.current || {};
|
||||||
@@ -317,9 +330,8 @@
|
|||||||
'<datalist id="region_time_date_timezones_' + region.id + '">' + buildTimezoneOptionsMarkup(timeZone) + '</datalist>' +
|
'<datalist id="region_time_date_timezones_' + region.id + '">' + buildTimezoneOptionsMarkup(timeZone) + '</datalist>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div>' +
|
'<div>' +
|
||||||
'<div class="api-region-placeholder-title mb-2">Available placeholders</div>' +
|
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2 mb-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' +
|
||||||
'<div class="d-flex flex-wrap gap-2">' + renderPlaceholderChips() + '</div>' +
|
'<div class="d-flex flex-wrap gap-2">' + renderPlaceholderChips() + '</div>' +
|
||||||
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, or <code>{{title.lower()}}</code>.</div>' +
|
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|||||||
@@ -240,6 +240,21 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPlaceholderInfo(regionId) {
|
||||||
|
if (!window.placeholderInfo) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var body = '<p class="small text-body-secondary">Placeholders read fields from the selected timetable entries. The selected group and display mode determine which entries and fields are available.</p>' +
|
||||||
|
'<h3 class="fs-6 mt-4 fw-normal">Placeholder paths</h3>' +
|
||||||
|
'<p class="small">Use the available field chips. Nested values use dots, and missing values render as empty text.</p>' +
|
||||||
|
window.placeholderInfo.renderTextTransforms() +
|
||||||
|
'<h3 class="fs-6 mt-4 fw-normal">Date and time formatting</h3>' +
|
||||||
|
'<p class="small">Use <code>format("MMM D, YYYY h:mm A")</code> with date fields. Use <code>tz()</code> for a short timezone name and <code>tz_long()</code> for the full timezone name.</p>' +
|
||||||
|
window.placeholderInfo.renderDateFormatTokens() +
|
||||||
|
'';
|
||||||
|
return window.placeholderInfo.render(regionId, 'Timetable placeholder transforms', body);
|
||||||
|
}
|
||||||
|
|
||||||
function renderEditorCard(context) {
|
function renderEditorCard(context) {
|
||||||
var region = context.region;
|
var region = context.region;
|
||||||
var current = context.current || {};
|
var current = context.current || {};
|
||||||
@@ -298,14 +313,8 @@
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="text-body-secondary small">Use the selected group and display mode to choose which timetable entry fields are available.</div>' +
|
'<div class="text-body-secondary small">Use the selected group and display mode to choose which timetable entry fields are available.</div>' +
|
||||||
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
|
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
|
||||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' +
|
||||||
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
|
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
|
||||||
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{start.format("MMM D, YYYY h:mm A")}}</code>, <code>{{start.tz()}}</code> for the short zone name, or <code>{{start.tz_long()}}</code> for the full IANA zone name.</div>' +
|
|
||||||
'<details class="mt-2">' +
|
|
||||||
'<summary class="small text-body-secondary">Supported date format tokens</summary>' +
|
|
||||||
'<div class="mt-2">' + renderScheduleFormatTokenTable() + '</div>' +
|
|
||||||
'<div class="text-body-secondary small mt-1">Use these tokens inside <code>.format(...)</code>; for example <code>{{start.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
|
|
||||||
'</details>' +
|
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
|||||||
@@ -82,17 +82,30 @@
|
|||||||
|
|
||||||
function buildEditorCardContext(context) {
|
function buildEditorCardContext(context) {
|
||||||
var defaultConfig = context && context.uploadConfig ? context.uploadConfig : {};
|
var defaultConfig = context && context.uploadConfig ? context.uploadConfig : {};
|
||||||
|
var configuredTypes = Array.isArray(context && context.uploadMimeTypes)
|
||||||
|
? context.uploadMimeTypes.filter(function (mimeType) { return String(mimeType || '').indexOf('video/') === 0; })
|
||||||
|
: [];
|
||||||
|
var accept = configuredTypes.length
|
||||||
|
? configuredTypes
|
||||||
|
: (Array.isArray(defaultConfig.accept) ? defaultConfig.accept : ['video/mp4', 'video/webm', 'video/ogg']);
|
||||||
|
var acceptLabels = accept.map(function (mimeType) {
|
||||||
|
return (String(mimeType).split('/')[1] || '').toUpperCase();
|
||||||
|
});
|
||||||
|
var acceptLabel = acceptLabels.length > 1
|
||||||
|
? acceptLabels.slice(0, -1).join(', ') + ' or ' + acceptLabels[acceptLabels.length - 1]
|
||||||
|
: (acceptLabels[0] || 'supported video');
|
||||||
return {
|
return {
|
||||||
region: context.region,
|
region: context.region,
|
||||||
current: String(context.current || ''),
|
current: String(context.current || ''),
|
||||||
currentDuration: String(context.currentDuration || ''),
|
currentDuration: String(context.currentDuration || ''),
|
||||||
uploadConfig: {
|
uploadConfig: {
|
||||||
accept: Array.isArray(defaultConfig.accept) ? defaultConfig.accept : ['video/mp4', 'video/webm', 'video/ogg'],
|
accept: accept,
|
||||||
limitBytes: defaultConfig.limitBytes,
|
limitBytes: defaultConfig.limitBytes || context.uploadVideoMaxBytes,
|
||||||
limitLabel: defaultConfig.limitLabel || context.uploadVideoMaxLabel || '1 GB',
|
limitLabel: defaultConfig.limitLabel || context.uploadVideoMaxLabel || '1 GB',
|
||||||
helpText: defaultConfig.helpText || 'MP4, WebM, or Ogg'
|
helpText: defaultConfig.helpText || acceptLabel
|
||||||
},
|
},
|
||||||
uploadVideoMaxLabel: context.uploadVideoMaxLabel || '1 GB'
|
uploadVideoMaxLabel: context.uploadVideoMaxLabel || '1 GB',
|
||||||
|
uploadMimeTypes: context.uploadMimeTypes || []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,10 +127,16 @@
|
|||||||
|
|
||||||
var uploadConfig = context.uploadConfig || {};
|
var uploadConfig = context.uploadConfig || {};
|
||||||
var accept = uploadConfig.accept || ['video/mp4', 'video/webm', 'video/ogg'];
|
var accept = uploadConfig.accept || ['video/mp4', 'video/webm', 'video/ogg'];
|
||||||
|
var acceptLabels = accept.map(function (mimeType) {
|
||||||
|
return (String(mimeType).split('/')[1] || '').toUpperCase();
|
||||||
|
});
|
||||||
|
var acceptLabel = acceptLabels.length > 1
|
||||||
|
? acceptLabels.slice(0, -1).join(', ') + (acceptLabels.length > 2 ? ', or ' : ' or ') + acceptLabels[acceptLabels.length - 1]
|
||||||
|
: (acceptLabels[0] || 'supported video');
|
||||||
var limitBytes = Number(uploadConfig.limitBytes || 1024 * 1024 * 1024);
|
var limitBytes = Number(uploadConfig.limitBytes || 1024 * 1024 * 1024);
|
||||||
|
|
||||||
if (!utils.fileMatchesAccept || !utils.fileMatchesAccept(context.file, accept)) {
|
if (!utils.fileMatchesAccept || !utils.fileMatchesAccept(context.file, accept)) {
|
||||||
showUploadWarning('This video region accepts MP4, WebM, or Ogg files.');
|
showUploadWarning('This video region accepts ' + acceptLabel + ' files.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +182,7 @@
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
accept: ['video/mp4', 'video/webm', 'video/ogg'],
|
accept: ['video/mp4', 'video/webm', 'video/ogg'],
|
||||||
limitBytes: defaultConfig.limitBytes,
|
limitBytes: defaultConfig.limitBytes || context.uploadVideoMaxBytes,
|
||||||
limitLabel: defaultConfig.limitLabel,
|
limitLabel: defaultConfig.limitLabel,
|
||||||
helpText: 'MP4, WebM, or Ogg'
|
helpText: 'MP4, WebM, or Ogg'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
(function () {
|
(function () {
|
||||||
if (window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-background' && window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-scheduled') {
|
var normalizedPathname = window.location.pathname.replace(/\/$/, '');
|
||||||
|
if (normalizedPathname !== '/settings/tasks-background' && normalizedPathname !== '/settings/tasks-scheduled') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function initIconSuggestions() {
|
||||||
|
var searchInput = document.querySelector('[data-icon-suggestions-search]');
|
||||||
|
var countNode = document.querySelector('[data-icon-suggestions-count]');
|
||||||
|
var selectedList = document.querySelector('[data-selected-icon-list]');
|
||||||
|
var noSelectedNode = document.querySelector('[data-no-selected-icons]');
|
||||||
|
var addButton = document.querySelector('[data-add-icon-button]');
|
||||||
|
var iconForm = document.querySelector('[data-settings-form="icons"]');
|
||||||
|
var options = Array.prototype.slice.call(document.querySelectorAll('[data-icon-suggestion-option]'));
|
||||||
|
var maxSelected = 48;
|
||||||
|
var draggedItem = null;
|
||||||
|
var initialOrder = [];
|
||||||
|
|
||||||
|
if (!searchInput || !countNode || !selectedList || !options.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionByKey(key) {
|
||||||
|
return options.find(function (option) {
|
||||||
|
var checkbox = option.querySelector('[data-icon-picker-checkbox]');
|
||||||
|
return checkbox && checkbox.value === key;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckedKeys() {
|
||||||
|
return options.filter(function (option) {
|
||||||
|
var checkbox = option.querySelector('[data-icon-picker-checkbox]');
|
||||||
|
return checkbox && checkbox.checked;
|
||||||
|
}).map(function (option) {
|
||||||
|
return option.querySelector('[data-icon-picker-checkbox]').value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedOrder() {
|
||||||
|
return Array.prototype.slice.call(selectedList.querySelectorAll('[data-selected-icon-item]')).map(function (item) {
|
||||||
|
return item.getAttribute('data-icon-key');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSelectedItem(key) {
|
||||||
|
var option = getOptionByKey(key);
|
||||||
|
var checkbox = option && option.querySelector('[data-icon-picker-checkbox]');
|
||||||
|
if (!option || !checkbox) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = document.createElement('div');
|
||||||
|
var content = document.createElement('div');
|
||||||
|
var dragButton = document.createElement('button');
|
||||||
|
var dragIcon = document.createElement('i');
|
||||||
|
var icon = document.createElement('i');
|
||||||
|
var label = document.createElement('span');
|
||||||
|
var hiddenInput = document.createElement('input');
|
||||||
|
var removeButton = document.createElement('button');
|
||||||
|
var removeIcon = document.createElement('i');
|
||||||
|
var iconLabel = String(option.getAttribute('data-icon-label') || key);
|
||||||
|
|
||||||
|
item.className = 'col-12 col-sm-6 col-xl-3';
|
||||||
|
content.className = 'd-flex align-items-center gap-2 border rounded p-2 h-100';
|
||||||
|
item.draggable = true;
|
||||||
|
item.setAttribute('data-selected-icon-item', '');
|
||||||
|
item.setAttribute('data-icon-key', key);
|
||||||
|
|
||||||
|
dragButton.type = 'button';
|
||||||
|
dragButton.className = 'btn btn-link text-body-secondary p-1';
|
||||||
|
dragButton.setAttribute('data-drag-icon', '');
|
||||||
|
dragButton.title = 'Drag to reorder';
|
||||||
|
dragButton.setAttribute('aria-label', 'Drag ' + iconLabel + ' to reorder');
|
||||||
|
dragIcon.className = 'bi bi-grip-vertical';
|
||||||
|
dragIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
dragButton.appendChild(dragIcon);
|
||||||
|
|
||||||
|
icon.className = 'bi bi-' + key + ' fs-5 text-primary';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
label.className = 'flex-grow-1';
|
||||||
|
label.textContent = iconLabel;
|
||||||
|
|
||||||
|
hiddenInput.type = 'hidden';
|
||||||
|
hiddenInput.name = 'suggested_icons[]';
|
||||||
|
hiddenInput.value = key;
|
||||||
|
hiddenInput.setAttribute('data-ordered-icon-input', '');
|
||||||
|
|
||||||
|
removeButton.type = 'button';
|
||||||
|
removeButton.className = 'btn btn-link text-danger p-1';
|
||||||
|
removeButton.setAttribute('data-remove-icon', '');
|
||||||
|
removeButton.title = 'Remove ' + iconLabel;
|
||||||
|
removeButton.setAttribute('aria-label', 'Remove ' + iconLabel);
|
||||||
|
removeIcon.className = 'bi bi-x-lg';
|
||||||
|
removeIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
removeButton.appendChild(removeIcon);
|
||||||
|
|
||||||
|
content.appendChild(dragButton);
|
||||||
|
content.appendChild(icon);
|
||||||
|
content.appendChild(label);
|
||||||
|
content.appendChild(hiddenInput);
|
||||||
|
content.appendChild(removeButton);
|
||||||
|
item.appendChild(content);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedItems(order) {
|
||||||
|
selectedList.querySelectorAll('[data-selected-icon-item]').forEach(function (item) {
|
||||||
|
item.remove();
|
||||||
|
});
|
||||||
|
order.forEach(function (key) {
|
||||||
|
var item = createSelectedItem(key);
|
||||||
|
if (item) {
|
||||||
|
selectedList.insertBefore(item, noSelectedNode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelectionState() {
|
||||||
|
var checkedKeys = getCheckedKeys();
|
||||||
|
var checkedSet = new Set(checkedKeys);
|
||||||
|
var currentOrder = getSelectedOrder().filter(function (key) {
|
||||||
|
return checkedSet.has(key);
|
||||||
|
});
|
||||||
|
checkedKeys.forEach(function (key) {
|
||||||
|
if (currentOrder.indexOf(key) === -1) {
|
||||||
|
currentOrder.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderSelectedItems(currentOrder);
|
||||||
|
countNode.textContent = String(currentOrder.length);
|
||||||
|
if (noSelectedNode) {
|
||||||
|
noSelectedNode.hidden = currentOrder.length > 0;
|
||||||
|
}
|
||||||
|
if (addButton) {
|
||||||
|
addButton.disabled = currentOrder.length >= maxSelected;
|
||||||
|
addButton.setAttribute('aria-disabled', addButton.disabled ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
options.forEach(function (option) {
|
||||||
|
var checkbox = option.querySelector('[data-icon-picker-checkbox]');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.disabled = !checkbox.checked && currentOrder.length >= maxSelected;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetIconForm() {
|
||||||
|
var initialSet = new Set(initialOrder);
|
||||||
|
options.forEach(function (option) {
|
||||||
|
var checkbox = option.querySelector('[data-icon-picker-checkbox]');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = initialSet.has(checkbox.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderSelectedItems(initialOrder.slice());
|
||||||
|
syncSelectionState();
|
||||||
|
if (iconForm) {
|
||||||
|
iconForm.dataset.dirty = 'false';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markIconFormDirty() {
|
||||||
|
if (iconForm) {
|
||||||
|
iconForm.dataset.dirty = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOptions() {
|
||||||
|
var query = String(searchInput.value || '').trim().toLowerCase();
|
||||||
|
options.forEach(function (option) {
|
||||||
|
var label = String(option.getAttribute('data-icon-label') || '').toLowerCase();
|
||||||
|
option.hidden = Boolean(query && label.indexOf(query) === -1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
options.forEach(function (option) {
|
||||||
|
var checkbox = option.querySelector('[data-icon-picker-checkbox]');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.addEventListener('change', function () {
|
||||||
|
markIconFormDirty();
|
||||||
|
syncSelectionState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedList.addEventListener('click', function (event) {
|
||||||
|
var removeButton = event.target.closest ? event.target.closest('[data-remove-icon]') : null;
|
||||||
|
if (!removeButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var item = removeButton.closest('[data-selected-icon-item]');
|
||||||
|
var key = item && item.getAttribute('data-icon-key');
|
||||||
|
var option = getOptionByKey(key);
|
||||||
|
var checkbox = option && option.querySelector('[data-icon-picker-checkbox]');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
}
|
||||||
|
markIconFormDirty();
|
||||||
|
syncSelectionState();
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedList.addEventListener('dragstart', function (event) {
|
||||||
|
var item = event.target.closest ? event.target.closest('[data-selected-icon-item]') : null;
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draggedItem = item;
|
||||||
|
item.classList.add('opacity-50');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', item.getAttribute('data-icon-key') || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedList.addEventListener('dragover', function (event) {
|
||||||
|
if (!draggedItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
var target = event.target.closest ? event.target.closest('[data-selected-icon-item]') : null;
|
||||||
|
if (!target || target === draggedItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var bounds = target.getBoundingClientRect();
|
||||||
|
var insertBefore = event.clientY < bounds.top + bounds.height / 2;
|
||||||
|
selectedList.insertBefore(draggedItem, insertBefore ? target : target.nextSibling);
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedList.addEventListener('dragend', function () {
|
||||||
|
if (!draggedItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draggedItem.classList.remove('opacity-50');
|
||||||
|
draggedItem = null;
|
||||||
|
markIconFormDirty();
|
||||||
|
syncSelectionState();
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', filterOptions);
|
||||||
|
syncSelectionState();
|
||||||
|
initialOrder = getSelectedOrder();
|
||||||
|
document.addEventListener('settings:reset', resetIconForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDefaultAnnouncementIconPicker() {
|
||||||
|
return;
|
||||||
|
var shell = document.querySelector('[data-settings-default-icon-picker]');
|
||||||
|
if (!shell) return;
|
||||||
|
var select = shell.querySelector('select');
|
||||||
|
var toggle = shell.querySelector('[data-settings-default-icon-toggle]');
|
||||||
|
var menu = shell.querySelector('[data-settings-default-icon-menu]');
|
||||||
|
var preview = shell.querySelector('[data-settings-default-icon-preview]');
|
||||||
|
var label = shell.querySelector('[data-settings-default-icon-label]');
|
||||||
|
var search = shell.querySelector('[data-settings-default-icon-search]');
|
||||||
|
var empty = shell.querySelector('[data-settings-default-icon-empty]');
|
||||||
|
var options = Array.prototype.slice.call(shell.querySelectorAll('[data-settings-default-icon-option]'));
|
||||||
|
|
||||||
|
function getCatalogOptions() {
|
||||||
|
return Array.prototype.slice.call(select.options).map(function (option) {
|
||||||
|
return { value: option.value, label: option.textContent || option.label || option.value };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOption(option, selectedValue) {
|
||||||
|
var item = document.createElement('button');
|
||||||
|
item.type = 'button';
|
||||||
|
item.className = 'announcement-icon-picker__option';
|
||||||
|
item.setAttribute('data-settings-default-icon-option', '');
|
||||||
|
item.setAttribute('data-icon-key', option.value);
|
||||||
|
item.setAttribute('data-icon-label', option.label);
|
||||||
|
item.setAttribute('data-icon-search-terms', option.value + ' ' + option.label);
|
||||||
|
item.setAttribute('aria-pressed', option.value === selectedValue ? 'true' : 'false');
|
||||||
|
item.title = option.label;
|
||||||
|
item.innerHTML = '<i class="bi bi-' + option.value + '" aria-hidden="true"></i><span class="visually-hidden">' + option.label + '</span>';
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOptions(query) {
|
||||||
|
var normalizedQuery = String(query || '').trim().toLowerCase();
|
||||||
|
var selectedValue = String(select.value || '').trim();
|
||||||
|
var sourceOptions = normalizedQuery ? getCatalogOptions().filter(function (option) {
|
||||||
|
return (option.value + ' ' + option.label).toLowerCase().indexOf(normalizedQuery) !== -1;
|
||||||
|
}) : options.map(function (option) {
|
||||||
|
return { value: option.getAttribute('data-icon-key') || '', label: option.getAttribute('data-icon-label') || '' };
|
||||||
|
});
|
||||||
|
var grid = shell.querySelector('[data-settings-default-icon-grid]');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
sourceOptions.forEach(function (option) {
|
||||||
|
grid.appendChild(createOption(option, selectedValue));
|
||||||
|
});
|
||||||
|
options = Array.prototype.slice.call(grid.querySelectorAll('[data-settings-default-icon-option]'));
|
||||||
|
options.forEach(bindOption);
|
||||||
|
empty.hidden = Boolean(sourceOptions.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindOption(item) {
|
||||||
|
item.addEventListener('click', function () {
|
||||||
|
select.value = item.getAttribute('data-icon-key') || '';
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
menu.hidden = true;
|
||||||
|
toggle.setAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var option = select.options[select.selectedIndex];
|
||||||
|
var key = option ? option.value : '';
|
||||||
|
var text = option ? option.textContent : 'Select an icon';
|
||||||
|
preview.className = 'announcement-icon-picker__toggle-icon bi bi-' + key;
|
||||||
|
if (label) label.textContent = text;
|
||||||
|
options.forEach(function (item) {
|
||||||
|
item.classList.toggle('is-selected', item.getAttribute('data-icon-key') === key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
menu.hidden = !menu.hidden;
|
||||||
|
toggle.setAttribute('aria-expanded', menu.hidden ? 'false' : 'true');
|
||||||
|
if (!menu.hidden && search) search.focus();
|
||||||
|
});
|
||||||
|
shell.querySelector('[data-settings-default-icon-close]').addEventListener('click', function () {
|
||||||
|
menu.hidden = true;
|
||||||
|
toggle.setAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
options.forEach(bindOption);
|
||||||
|
if (search) search.addEventListener('input', function () {
|
||||||
|
renderOptions(search.value);
|
||||||
|
});
|
||||||
|
select.addEventListener('change', render);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSettingsSectionNavigation() {
|
||||||
|
var links = Array.prototype.slice.call(document.querySelectorAll('[data-settings-section-link]'));
|
||||||
|
var sections = Array.prototype.slice.call(document.querySelectorAll('[data-settings-section]'));
|
||||||
|
if (!links.length || !sections.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveSection(sectionId) {
|
||||||
|
links.forEach(function (link) {
|
||||||
|
var isActive = link.getAttribute('href') === '#' + sectionId;
|
||||||
|
link.classList.toggle('active', isActive);
|
||||||
|
link.setAttribute('aria-current', isActive ? 'page' : 'false');
|
||||||
|
});
|
||||||
|
sections.forEach(function (section) {
|
||||||
|
section.hidden = section.id !== sectionId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDirtySettingsForm() {
|
||||||
|
return Array.prototype.some.call(document.querySelectorAll('[data-settings-form]'), function (form) {
|
||||||
|
return form.dataset && form.dataset.dirty === 'true';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSettingsForms() {
|
||||||
|
document.querySelectorAll('[data-settings-form]').forEach(function (form) {
|
||||||
|
if (typeof form.reset === 'function') {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
form.dataset.dirty = 'false';
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new CustomEvent('settings:reset'));
|
||||||
|
}
|
||||||
|
|
||||||
|
links.forEach(function (link) {
|
||||||
|
link.addEventListener('click', function (event) {
|
||||||
|
var target = document.getElementById(String(link.getAttribute('href') || '').replace(/^#/, ''));
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var currentSection = sections.find(function (section) { return !section.hidden; });
|
||||||
|
if (currentSection && target.id !== currentSection.id && hasDirtySettingsForm()) {
|
||||||
|
if (!window.confirm('You have unsaved settings. Switch sections anyway?')) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetSettingsForms();
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveSection(target.id);
|
||||||
|
window.history.replaceState(null, '', '#' + target.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var hashSectionId = String(window.location.hash || '').replace(/^#/, '');
|
||||||
|
var initialSection = sections.some(function (section) {
|
||||||
|
return section.id === hashSectionId;
|
||||||
|
}) ? hashSectionId : sections[0].id;
|
||||||
|
setActiveSection(initialSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
initIconSuggestions();
|
||||||
|
initDefaultAnnouncementIconPicker();
|
||||||
|
initSettingsSectionNavigation();
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Shared offcanvas renderer for region placeholder documentation.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var root = window;
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value === undefined || value === null ? '' : value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(regionId, title, body) {
|
||||||
|
var offcanvasId = String(regionId || 'region') + '-placeholder-info';
|
||||||
|
return '' +
|
||||||
|
'<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" data-bs-toggle="offcanvas" data-bs-target="#' + offcanvasId + '" aria-controls="' + offcanvasId + '">More info</button>' +
|
||||||
|
'<div class="offcanvas offcanvas-end fw-normal" tabindex="-1" id="' + offcanvasId + '" aria-labelledby="' + offcanvasId + '-label">' +
|
||||||
|
'<div class="offcanvas-header">' +
|
||||||
|
'<h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">' + escapeHtml(title) + '</h2>' +
|
||||||
|
'<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="offcanvas-body">' + body + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTextTransforms() {
|
||||||
|
return '<h3 class="fs-6 mt-4 fw-normal">Text transforms</h3>' +
|
||||||
|
'<dl class="small fw-normal">' +
|
||||||
|
'<dt class="fw-normal"><code>upper()</code></dt><dd>Converts text to uppercase.</dd>' +
|
||||||
|
'<dt class="fw-normal"><code>lower()</code></dt><dd>Converts text to lowercase.</dd>' +
|
||||||
|
'<dt class="fw-normal"><code>title()</code></dt><dd>Capitalizes each word.</dd>' +
|
||||||
|
'<dt class="fw-normal"><code>tz()</code></dt><dd>Returns the short timezone name for a date.</dd>' +
|
||||||
|
'<dt class="fw-normal"><code>tz_long()</code></dt><dd>Returns the full timezone name for a date.</dd>' +
|
||||||
|
'</dl>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDateFormatTokens() {
|
||||||
|
return '<h3 class="fs-6 mt-4 fw-normal">Date format tokens</h3>' +
|
||||||
|
'<p class="small">Use the <code>format("...")</code> transform with these tokens. Text inside square brackets is treated as a literal.</p>' +
|
||||||
|
'<div class="table-responsive small"><table class="table table-sm align-middle mb-0"><thead><tr><th>Token</th><th>Meaning</th><th>Example</th></tr></thead><tbody>' +
|
||||||
|
'<tr><td><code>YYYY</code></td><td>Four-digit year</td><td><code>2026</code></td></tr><tr><td><code>YY</code></td><td>Two-digit year</td><td><code>26</code></td></tr>' +
|
||||||
|
'<tr><td><code>MMMM</code></td><td>Full month name</td><td><code>August</code></td></tr><tr><td><code>MMM</code></td><td>Short month name</td><td><code>Aug</code></td></tr>' +
|
||||||
|
'<tr><td><code>MM</code></td><td>Two-digit month</td><td><code>08</code></td></tr><tr><td><code>M</code></td><td>Month number</td><td><code>8</code></td></tr>' +
|
||||||
|
'<tr><td><code>DD</code></td><td>Two-digit day</td><td><code>16</code></td></tr><tr><td><code>D</code></td><td>Day number</td><td><code>16</code></td></tr>' +
|
||||||
|
'<tr><td><code>dddd</code></td><td>Full weekday name</td><td><code>Sunday</code></td></tr><tr><td><code>ddd</code></td><td>Short weekday name</td><td><code>Sun</code></td></tr>' +
|
||||||
|
'<tr><td><code>HH</code> / <code>H</code></td><td>24-hour time</td><td><code>19</code> / <code>7</code></td></tr><tr><td><code>hh</code> / <code>h</code></td><td>12-hour time</td><td><code>07</code> / <code>7</code></td></tr>' +
|
||||||
|
'<tr><td><code>mm</code> / <code>m</code></td><td>Minutes</td><td><code>05</code> / <code>5</code></td></tr><tr><td><code>ss</code> / <code>s</code></td><td>Seconds</td><td><code>09</code> / <code>9</code></td></tr>' +
|
||||||
|
'<tr><td><code>A</code> / <code>a</code></td><td>AM or PM</td><td><code>PM</code> / <code>pm</code></td></tr>' +
|
||||||
|
'</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImageTransform() {
|
||||||
|
return '<h3 class="fs-6 mt-4 fw-normal">Image transform</h3>' +
|
||||||
|
'<p class="small">Use <code>image(width, height)</code> to render an image URL inside a proportional bounding box.</p>' +
|
||||||
|
'<ul class="small"><li>The preview shows the width and height boundary.</li><li>The player has no boundary.</li><li>The image keeps its original aspect ratio.</li><li>Both dimensions are required for a bounding box.</li></ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
root.placeholderInfo = {
|
||||||
|
escapeHtml: escapeHtml,
|
||||||
|
render: render,
|
||||||
|
renderTextTransforms: renderTextTransforms,
|
||||||
|
renderDateFormatTokens: renderDateFormatTokens,
|
||||||
|
renderImageTransform: renderImageTransform
|
||||||
|
};
|
||||||
|
}());
|
||||||
@@ -298,6 +298,27 @@
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isImagePlaceholderExpression(expression) {
|
||||||
|
var parsed = parsePlaceholderExpression(expression);
|
||||||
|
return parsed.transforms.some(function (transform) {
|
||||||
|
return transform && transform.name === 'image';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImagePlaceholderConfig(expression) {
|
||||||
|
var parsed = parsePlaceholderExpression(expression);
|
||||||
|
var imageTransform = parsed.transforms.find(function (transform) {
|
||||||
|
return transform && transform.name === 'image';
|
||||||
|
});
|
||||||
|
var args = imageTransform && Array.isArray(imageTransform.args) ? imageTransform.args : [];
|
||||||
|
var width = Number(args[0]);
|
||||||
|
var height = Number(args[1]);
|
||||||
|
return {
|
||||||
|
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 0,
|
||||||
|
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatPlaceholderValue(value) {
|
function formatPlaceholderValue(value) {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return '';
|
return '';
|
||||||
@@ -349,6 +370,8 @@
|
|||||||
resolvePath: resolvePath,
|
resolvePath: resolvePath,
|
||||||
parsePlaceholderExpression: parsePlaceholderExpression,
|
parsePlaceholderExpression: parsePlaceholderExpression,
|
||||||
resolvePlaceholderExpression: resolvePlaceholderExpression,
|
resolvePlaceholderExpression: resolvePlaceholderExpression,
|
||||||
|
isImagePlaceholderExpression: isImagePlaceholderExpression,
|
||||||
|
getImagePlaceholderConfig: getImagePlaceholderConfig,
|
||||||
formatPlaceholderValue: formatPlaceholderValue,
|
formatPlaceholderValue: formatPlaceholderValue,
|
||||||
collectPlaceholderFieldPaths: collectPlaceholderFieldPaths
|
collectPlaceholderFieldPaths: collectPlaceholderFieldPaths
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export function createSlideFormEditorController(options) {
|
|||||||
var templateFields = settings.templateFields || null;
|
var templateFields = settings.templateFields || null;
|
||||||
var templateSelectorLock = settings.templateSelectorLock || null;
|
var templateSelectorLock = settings.templateSelectorLock || null;
|
||||||
var requestPreviewRender = typeof settings.requestPreviewRender === 'function' ? settings.requestPreviewRender : function () {};
|
var requestPreviewRender = typeof settings.requestPreviewRender === 'function' ? settings.requestPreviewRender : function () {};
|
||||||
|
var markFormDirty = typeof settings.markFormDirty === 'function' ? settings.markFormDirty : function () {};
|
||||||
var defaultFontSize = Math.max(1, Number(settings.defaultFontSize || 32));
|
var defaultFontSize = Math.max(1, Number(settings.defaultFontSize || 32));
|
||||||
var fontFamilyFormats = String(settings.fontFamilyFormats || '').trim();
|
var fontFamilyFormats = String(settings.fontFamilyFormats || '').trim();
|
||||||
var fontStylesheetHref = String(settings.fontStylesheetHref || '').trim();
|
var fontStylesheetHref = String(settings.fontStylesheetHref || '').trim();
|
||||||
@@ -17,8 +18,16 @@ export function createSlideFormEditorController(options) {
|
|||||||
var imageUploadMaxBytes = Math.max(1, Number(settings.imageUploadMaxBytes || 2 * 1024 * 1024));
|
var imageUploadMaxBytes = Math.max(1, Number(settings.imageUploadMaxBytes || 2 * 1024 * 1024));
|
||||||
var imageUploadLimitLabel = String(settings.imageUploadLimitLabel || '').trim() || Math.max(1, Math.round(imageUploadMaxBytes / (1024 * 1024))) + ' MB';
|
var imageUploadLimitLabel = String(settings.imageUploadLimitLabel || '').trim() || Math.max(1, Math.round(imageUploadMaxBytes / (1024 * 1024))) + ' MB';
|
||||||
var imageUploadContext = String(settings.imageUploadContext || 'wysiwyg').trim() || 'wysiwyg';
|
var imageUploadContext = String(settings.imageUploadContext || 'wysiwyg').trim() || 'wysiwyg';
|
||||||
var imageUploadAllowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'];
|
var configuredImageMimeTypes = Array.isArray(settings.uploadMimeTypes)
|
||||||
var imageUploadAllowedMimeTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
|
? settings.uploadMimeTypes.filter(function (mimeType) { return String(mimeType || '').indexOf('image/') === 0; })
|
||||||
|
: [];
|
||||||
|
var imageUploadAllowedMimeTypes = configuredImageMimeTypes.length
|
||||||
|
? configuredImageMimeTypes
|
||||||
|
: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||||
|
var imageUploadAllowedExtensions = imageUploadAllowedMimeTypes.map(function (mimeType) {
|
||||||
|
var subtype = String(mimeType).split('/')[1] || '';
|
||||||
|
return subtype === 'jpeg' ? 'jpg' : subtype === 'svg+xml' ? 'svg' : subtype;
|
||||||
|
});
|
||||||
var imageUploadFileTypes = imageUploadAllowedExtensions.join(',');
|
var imageUploadFileTypes = imageUploadAllowedExtensions.join(',');
|
||||||
var wysiwygEditorHeightStep = Math.max(1, Number(settings.wysiwygEditorHeightStep || 80));
|
var wysiwygEditorHeightStep = Math.max(1, Number(settings.wysiwygEditorHeightStep || 80));
|
||||||
var wysiwygEditorHeightMin = Math.max(1, Number(settings.wysiwygEditorHeightMin || 240));
|
var wysiwygEditorHeightMin = Math.max(1, Number(settings.wysiwygEditorHeightMin || 240));
|
||||||
@@ -35,10 +44,6 @@ export function createSlideFormEditorController(options) {
|
|||||||
return String(value || '');
|
return String(value || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEditorMarkup(value) {
|
|
||||||
return String(value === undefined || value === null ? '' : value).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEmptyRichTextValue(value) {
|
function isEmptyRichTextValue(value) {
|
||||||
var raw = String(value === undefined || value === null ? '' : value).trim();
|
var raw = String(value === undefined || value === null ? '' : value).trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -542,6 +547,9 @@ export function createSlideFormEditorController(options) {
|
|||||||
['change', 'keyup', 'undo', 'redo', 'Paste', 'input'].forEach(function (eventName) {
|
['change', 'keyup', 'undo', 'redo', 'Paste', 'input'].forEach(function (eventName) {
|
||||||
editor.on(eventName, function () {
|
editor.on(eventName, function () {
|
||||||
syncState(true);
|
syncState(true);
|
||||||
|
if (hydrated) {
|
||||||
|
markFormDirty();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -563,7 +571,7 @@ export function createSlideFormEditorController(options) {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
source.value = normalizeEditorMarkup(normalizeEditorData(source && source.value !== undefined && source.value !== null ? source.value : (hidden ? hidden.value : '')));
|
source.value = normalizeEditorData(source && source.value !== undefined && source.value !== null ? source.value : (hidden ? hidden.value : ''));
|
||||||
|
|
||||||
if (!source.id) {
|
if (!source.id) {
|
||||||
source.id = 'slide-editor-region-' + regionId;
|
source.id = 'slide-editor-region-' + regionId;
|
||||||
|
|||||||
@@ -198,9 +198,9 @@ export function createSlideFormPreviewHelpers(options) {
|
|||||||
return module.renderPreview(region, value, style, scale);
|
return module.renderPreview(region, value, style, scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
|
var fontFamily = 'font-family:' + escapeHtml(style.font_family || 'Arial') + ';';
|
||||||
var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
var color = 'color:' + escapeHtml(style.font_color || '#000000') + ';';
|
||||||
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
var fontSize = 'font-size:' + Math.max(1, Math.round(Number(style.font_size || 32))) + 'px;';
|
||||||
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
|
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
|
||||||
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
||||||
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color;
|
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color;
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export function createSlideFormRegionHelpers(options) {
|
|||||||
var defaultFontSize = Math.max(1, Number(settings.defaultFontSize || 32));
|
var defaultFontSize = Math.max(1, Number(settings.defaultFontSize || 32));
|
||||||
var uploadMaxLabel = String(settings.uploadMaxLabel || '100 MB');
|
var uploadMaxLabel = String(settings.uploadMaxLabel || '100 MB');
|
||||||
var uploadVideoMaxLabel = String(settings.uploadVideoMaxLabel || '1 GB');
|
var uploadVideoMaxLabel = String(settings.uploadVideoMaxLabel || '1 GB');
|
||||||
|
var uploadMaxBytes = Number(settings.uploadMaxBytes || 100 * 1024 * 1024);
|
||||||
|
var uploadVideoMaxBytes = Number(settings.uploadVideoMaxBytes || 1024 * 1024 * 1024);
|
||||||
|
var uploadMimeTypes = Array.isArray(settings.uploadMimeTypes) ? settings.uploadMimeTypes : [];
|
||||||
|
|
||||||
function sanitizeFontSize(value) {
|
function sanitizeFontSize(value) {
|
||||||
var raw = String(value || '').trim().toLowerCase();
|
var raw = String(value || '').trim().toLowerCase();
|
||||||
@@ -120,7 +123,7 @@ export function createSlideFormRegionHelpers(options) {
|
|||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLimitedPlaceholderChipMarkup(fieldList, emptyLabel) {
|
function buildLimitedPlaceholderChipMarkup(fieldList, emptyLabel, includeTransformHint) {
|
||||||
var fields = Array.isArray(fieldList) ? fieldList : [];
|
var fields = Array.isArray(fieldList) ? fieldList : [];
|
||||||
var visibleFields = fields.slice(0, 8);
|
var visibleFields = fields.slice(0, 8);
|
||||||
var overflowFields = fields.slice(visibleFields.length);
|
var overflowFields = fields.slice(visibleFields.length);
|
||||||
@@ -134,7 +137,7 @@ export function createSlideFormRegionHelpers(options) {
|
|||||||
? placeholderChips.renderChip(field)
|
? placeholderChips.renderChip(field)
|
||||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||||
}).join('');
|
}).join('');
|
||||||
var transformHint = '<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>';
|
var transformHint = includeTransformHint === false ? '' : '<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>';
|
||||||
|
|
||||||
if (!overflowFields.length) {
|
if (!overflowFields.length) {
|
||||||
return visibleMarkup + transformHint;
|
return visibleMarkup + transformHint;
|
||||||
@@ -414,7 +417,7 @@ export function createSlideFormRegionHelpers(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = buildLimitedPlaceholderChipMarkup(getApiFieldList(sourceId, itemsPathOverride), 'No JSON fields available.');
|
container.innerHTML = buildLimitedPlaceholderChipMarkup(getApiFieldList(sourceId, itemsPathOverride), 'No JSON fields available.', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateApiSampleDataPanel(regionId, sourceId, itemNumber, itemsPathOverride) {
|
function updateApiSampleDataPanel(regionId, sourceId, itemNumber, itemsPathOverride) {
|
||||||
@@ -539,6 +542,9 @@ export function createSlideFormRegionHelpers(options) {
|
|||||||
regionRatio: reduceAspectRatio(region.width, region.height),
|
regionRatio: reduceAspectRatio(region.width, region.height),
|
||||||
uploadMaxLabel: uploadMaxLabel,
|
uploadMaxLabel: uploadMaxLabel,
|
||||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||||
|
uploadMaxBytes: uploadMaxBytes,
|
||||||
|
uploadVideoMaxBytes: uploadVideoMaxBytes,
|
||||||
|
uploadMimeTypes: uploadMimeTypes,
|
||||||
disableAudio: (existingContent[region.region_key] || {}).disable_audio,
|
disableAudio: (existingContent[region.region_key] || {}).disable_audio,
|
||||||
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : getCurrentRssConfig(region),
|
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : getCurrentRssConfig(region),
|
||||||
style: getCurrentTextStyle(region),
|
style: getCurrentTextStyle(region),
|
||||||
@@ -555,7 +561,7 @@ export function createSlideFormRegionHelpers(options) {
|
|||||||
? getRssFieldList(getCurrentRssConfig(region).feed_id).map(function (field) {
|
? getRssFieldList(getCurrentRssConfig(region).feed_id).map(function (field) {
|
||||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||||
}).join('')
|
}).join('')
|
||||||
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'),
|
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.', false),
|
||||||
placeholderFields: region.region_type === 'api'
|
placeholderFields: region.region_type === 'api'
|
||||||
? getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path)
|
? getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path)
|
||||||
: region.region_type === 'rss'
|
: region.region_type === 'rss'
|
||||||
@@ -579,6 +585,9 @@ export function createSlideFormRegionHelpers(options) {
|
|||||||
regionRatio: reduceAspectRatio(region.width, region.height),
|
regionRatio: reduceAspectRatio(region.width, region.height),
|
||||||
uploadMaxLabel: uploadMaxLabel,
|
uploadMaxLabel: uploadMaxLabel,
|
||||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||||
|
uploadMaxBytes: uploadMaxBytes,
|
||||||
|
uploadVideoMaxBytes: uploadVideoMaxBytes,
|
||||||
|
uploadMimeTypes: uploadMimeTypes,
|
||||||
disableAudio: currentContent.disable_audio,
|
disableAudio: currentContent.disable_audio,
|
||||||
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : rssConfig,
|
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : rssConfig,
|
||||||
style: textStyle,
|
style: textStyle,
|
||||||
@@ -593,7 +602,7 @@ export function createSlideFormRegionHelpers(options) {
|
|||||||
}).join(''),
|
}).join(''),
|
||||||
placeholderChips: region.region_type === 'rss'
|
placeholderChips: region.region_type === 'rss'
|
||||||
? buildLimitedPlaceholderChipMarkup(getRssFieldList(rssConfig.feed_id), 'No RSS fields available.')
|
? buildLimitedPlaceholderChipMarkup(getRssFieldList(rssConfig.feed_id), 'No RSS fields available.')
|
||||||
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
|
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.', false),
|
||||||
placeholderFields: region.region_type === 'api'
|
placeholderFields: region.region_type === 'api'
|
||||||
? getApiFieldList(apiConfig.source_id, apiItemsPath)
|
? getApiFieldList(apiConfig.source_id, apiItemsPath)
|
||||||
: region.region_type === 'rss'
|
: region.region_type === 'rss'
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
(function () {
|
(function () {
|
||||||
var DEFAULT_FONT_SIZE = 32;
|
var DEFAULT_FONT_SIZE = 32;
|
||||||
|
|
||||||
|
function formatUploadLimitLabel(bytes) {
|
||||||
|
var megabytes = Number(bytes || 0) / 1024 / 1024;
|
||||||
|
if (megabytes >= 1024 && megabytes % 1024 === 0) {
|
||||||
|
return String(megabytes / 1024) + ' GB';
|
||||||
|
}
|
||||||
|
return (Number.isInteger(megabytes) ? String(megabytes) : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
var dataElement = document.getElementById('slide-editor-data');
|
var dataElement = document.getElementById('slide-editor-data');
|
||||||
if (!dataElement) {
|
if (!dataElement) {
|
||||||
return;
|
return;
|
||||||
@@ -37,12 +45,14 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
var slidePreviewEmpty = document.getElementById('slide-preview-empty');
|
var slidePreviewEmpty = document.getElementById('slide-preview-empty');
|
||||||
var slidePreviewOverlay = document.getElementById('slide-preview-overlay');
|
var slidePreviewOverlay = document.getElementById('slide-preview-overlay');
|
||||||
var openPreviewPopupButton = document.getElementById('open-preview-popup');
|
var openPreviewPopupButton = document.getElementById('open-preview-popup');
|
||||||
var uploadMaxBytes = 100 * 1024 * 1024;
|
var uploadLimits = slideEditorData.uploadLimits || {};
|
||||||
var uploadMaxLabel = '100 MB';
|
var uploadMaxBytes = Number(uploadLimits.imageMaxBytes || 100 * 1024 * 1024);
|
||||||
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
|
var uploadMaxLabel = formatUploadLimitLabel(uploadMaxBytes);
|
||||||
var uploadVideoMaxLabel = '1 GB';
|
var uploadVideoMaxBytes = Number(uploadLimits.videoMaxBytes || 1024 * 1024 * 1024);
|
||||||
var wysiwygImageUploadMaxBytes = 2 * 1024 * 1024;
|
var uploadVideoMaxLabel = formatUploadLimitLabel(uploadVideoMaxBytes);
|
||||||
var wysiwygImageUploadLimitLabel = '2 MB';
|
var wysiwygImageUploadMaxBytes = Number(uploadLimits.wysiwygImageMaxBytes || 2 * 1024 * 1024);
|
||||||
|
var wysiwygImageUploadLimitLabel = formatUploadLimitLabel(wysiwygImageUploadMaxBytes);
|
||||||
|
var uploadMimeTypes = Array.isArray(uploadLimits.allowedMimeTypes) ? uploadLimits.allowedMimeTypes : [];
|
||||||
var videoDurationCache = Object.create(null);
|
var videoDurationCache = Object.create(null);
|
||||||
var previewRenderFrame = 0;
|
var previewRenderFrame = 0;
|
||||||
var previewPopupWindow = null;
|
var previewPopupWindow = null;
|
||||||
@@ -111,7 +121,11 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
imageUploadMaxBytes: wysiwygImageUploadMaxBytes,
|
imageUploadMaxBytes: wysiwygImageUploadMaxBytes,
|
||||||
imageUploadLimitLabel: wysiwygImageUploadLimitLabel,
|
imageUploadLimitLabel: wysiwygImageUploadLimitLabel,
|
||||||
imageUploadContext: 'wysiwyg',
|
imageUploadContext: 'wysiwyg',
|
||||||
|
uploadMimeTypes: uploadMimeTypes,
|
||||||
getRegionTypeModule: getRegionTypeModule,
|
getRegionTypeModule: getRegionTypeModule,
|
||||||
|
markFormDirty: function () {
|
||||||
|
slideForm.dataset.dirty = 'true';
|
||||||
|
},
|
||||||
getEditorBackgroundColor: function () {
|
getEditorBackgroundColor: function () {
|
||||||
var template = getTemplateById(templateSelect.value);
|
var template = getTemplateById(templateSelect.value);
|
||||||
return template && template.background_color ? String(template.background_color) : '#111111';
|
return template && template.background_color ? String(template.background_color) : '#111111';
|
||||||
@@ -128,6 +142,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
defaultFontSize: DEFAULT_FONT_SIZE,
|
defaultFontSize: DEFAULT_FONT_SIZE,
|
||||||
uploadMaxLabel: uploadMaxLabel,
|
uploadMaxLabel: uploadMaxLabel,
|
||||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||||
|
uploadMaxBytes: uploadMaxBytes,
|
||||||
|
uploadVideoMaxBytes: uploadVideoMaxBytes,
|
||||||
|
uploadMimeTypes: uploadMimeTypes,
|
||||||
escapeHtml: escapeHtml,
|
escapeHtml: escapeHtml,
|
||||||
getRegionTypeModule: getRegionTypeModule,
|
getRegionTypeModule: getRegionTypeModule,
|
||||||
requestPreviewRender: requestPreviewRender
|
requestPreviewRender: requestPreviewRender
|
||||||
@@ -637,7 +654,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
var content = typeModule && typeof typeModule.renderPreview === 'function'
|
var content = typeModule && typeof typeModule.renderPreview === 'function'
|
||||||
? typeModule.renderPreview(region, previewContext, previewContext)
|
? typeModule.renderPreview(region, previewContext, previewContext)
|
||||||
: renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale);
|
: renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale);
|
||||||
if (typeModule && typeof typeModule.renderPreview === 'function') {
|
if (typeModule && typeof typeModule.renderPreview === 'function' && region.region_type !== 'api' && region.region_type !== 'rss') {
|
||||||
content = content || renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale);
|
content = content || renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale);
|
||||||
}
|
}
|
||||||
var selected = region.region_type === 'image'
|
var selected = region.region_type === 'image'
|
||||||
|
|||||||
@@ -23,6 +23,14 @@
|
|||||||
var listenersAttached = false;
|
var listenersAttached = false;
|
||||||
var uploadMaxBytes = 100 * 1024 * 1024;
|
var uploadMaxBytes = 100 * 1024 * 1024;
|
||||||
var uploadMaxLabel = '100 MB';
|
var uploadMaxLabel = '100 MB';
|
||||||
|
var editorDataElement = document.getElementById('slide-editor-data');
|
||||||
|
try {
|
||||||
|
var editorData = JSON.parse(editorDataElement && (editorDataElement.value || editorDataElement.textContent) || '{}') || {};
|
||||||
|
uploadMaxBytes = Number(editorData.uploadLimits && editorData.uploadLimits.imageMaxBytes) || uploadMaxBytes;
|
||||||
|
var megabytes = uploadMaxBytes / 1024 / 1024;
|
||||||
|
uploadMaxLabel = Number.isInteger(megabytes) ? String(megabytes) + ' MB' : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') + ' MB';
|
||||||
|
} catch (_error) {
|
||||||
|
}
|
||||||
|
|
||||||
function getRegionId(input) {
|
function getRegionId(input) {
|
||||||
var match = String(input && input.name || '').match(/^region_image_(\d+)$/);
|
var match = String(input && input.name || '').match(/^region_image_(\d+)$/);
|
||||||
|
|||||||
@@ -13,7 +13,26 @@ function normalizeReturnUrl(value) {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function renderAccountPage(currentUser, message, returnUrl) {
|
module.exports = function renderAccountPage(currentUser, message, returnUrl, allowUserSessionRevocation, sessions, passwordRequirements) {
|
||||||
|
const requirements = passwordRequirements || {
|
||||||
|
minimumLength: 10,
|
||||||
|
minimumCategories: 3,
|
||||||
|
requireLowercase: false,
|
||||||
|
requireUppercase: false,
|
||||||
|
requireNumber: false,
|
||||||
|
requireSymbol: false
|
||||||
|
};
|
||||||
|
const requiredCategories = [];
|
||||||
|
if (requirements.requireLowercase) requiredCategories.push('lowercase');
|
||||||
|
if (requirements.requireUppercase) requiredCategories.push('uppercase');
|
||||||
|
if (requirements.requireNumber) requiredCategories.push('number');
|
||||||
|
if (requirements.requireSymbol) requiredCategories.push('symbol');
|
||||||
|
const passwordRequirementsText = requiredCategories.length
|
||||||
|
? 'Use at least ' + requirements.minimumLength + ' characters and include ' + requiredCategories.join(', ') + '.'
|
||||||
|
: requirements.minimumCategories === 4
|
||||||
|
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.'
|
||||||
|
: 'Use at least ' + requirements.minimumLength + ' characters and include ' + requirements.minimumCategories + ' of: uppercase, lowercase, number, and symbol.';
|
||||||
|
|
||||||
return renderView('account/password', {
|
return renderView('account/password', {
|
||||||
title: 'My account',
|
title: 'My account',
|
||||||
active: 'account',
|
active: 'account',
|
||||||
@@ -21,6 +40,10 @@ module.exports = function renderAccountPage(currentUser, message, returnUrl) {
|
|||||||
message: message || '',
|
message: message || '',
|
||||||
username: currentUser ? currentUser.username : '',
|
username: currentUser ? currentUser.username : '',
|
||||||
name: currentUser ? String(currentUser.name || '') : '',
|
name: currentUser ? String(currentUser.name || '') : '',
|
||||||
returnUrl: normalizeReturnUrl(returnUrl)
|
returnUrl: normalizeReturnUrl(returnUrl),
|
||||||
|
allowUserSessionRevocation: Boolean(allowUserSessionRevocation),
|
||||||
|
sessions: Array.isArray(sessions) ? sessions : [],
|
||||||
|
passwordMinimumLength: requirements.minimumLength,
|
||||||
|
passwordRequirementsText: passwordRequirementsText
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
// Admin account route registration and profile helpers.
|
// Admin account route registration and profile helpers.
|
||||||
|
|
||||||
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
module.exports = function registerAccountRoutes(app, deps) {
|
module.exports = function registerAccountRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
@@ -11,9 +13,129 @@ module.exports = function registerAccountRoutes(app, deps) {
|
|||||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||||
const createUserSession = deps.createUserSession;
|
const createUserSession = deps.createUserSession;
|
||||||
const setSessionCookie = deps.setSessionCookie;
|
const setSessionCookie = deps.setSessionCookie;
|
||||||
|
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
|
||||||
|
const parseCookies = deps.parseCookies;
|
||||||
|
const hashSessionToken = deps.hashSessionToken;
|
||||||
|
const sessionCookieName = deps.sessionCookieName;
|
||||||
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||||
|
|
||||||
|
async function getPasswordRequirements() {
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
return buildPasswordRequirements(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPasswordRequirements(settings) {
|
||||||
|
return {
|
||||||
|
minimumLength: settings['security.password_min_length'],
|
||||||
|
minimumCategories: settings['security.password_min_categories'],
|
||||||
|
requireLowercase: settings['security.password_require_lowercase'],
|
||||||
|
requireUppercase: settings['security.password_require_uppercase'],
|
||||||
|
requireNumber: settings['security.password_require_number'],
|
||||||
|
requireSymbol: settings['security.password_require_symbol']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionMetadata(req) {
|
||||||
|
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||||
|
return {
|
||||||
|
ipAddress: forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim(),
|
||||||
|
userAgent: req && req.headers && req.headers['user-agent']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/account', function (req, res) {
|
app.get('/account', function (req, res) {
|
||||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard'));
|
fetchAppSettings(pool).then(function (settings) {
|
||||||
|
const passwordRequirements = buildPasswordRequirements(settings);
|
||||||
|
if (!settings['security.allow_user_session_revocation']) {
|
||||||
|
return res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', false, [], passwordRequirements));
|
||||||
|
}
|
||||||
|
const cookies = parseCookies(req.headers.cookie || '');
|
||||||
|
const tokenHash = cookies[sessionCookieName] ? hashSessionToken(cookies[sessionCookieName]) : '';
|
||||||
|
return pool.query(
|
||||||
|
'SELECT id, session_hash, ip_address, user_agent, created_at, last_used_at, expires_at FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at DESC',
|
||||||
|
[req.currentUser.id]
|
||||||
|
).then(function (result) {
|
||||||
|
const rows = result[0] || [];
|
||||||
|
const sessions = rows.map(function (session) {
|
||||||
|
return {
|
||||||
|
id: Number(session.id),
|
||||||
|
isCurrent: Boolean(tokenHash && session.session_hash === tokenHash),
|
||||||
|
ipAddress: String(session.ip_address || 'Unknown'),
|
||||||
|
userAgent: String(session.user_agent || 'Unknown browser'),
|
||||||
|
createdAtLabel: formatDashboardDate(session.created_at),
|
||||||
|
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
|
||||||
|
expiresAtLabel: formatDashboardDate(session.expires_at)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements));
|
||||||
|
});
|
||||||
|
}).catch(function (error) {
|
||||||
|
res.status(500).send(error.message || 'Unable to load account settings.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/account/sessions/:id/revoke', async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const sessionId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(sessionId) || sessionId <= 0) {
|
||||||
|
return res.status(400).send('Invalid session.');
|
||||||
|
}
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
if (!settings['security.allow_user_session_revocation']) {
|
||||||
|
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
|
||||||
|
}
|
||||||
|
const cookies = parseCookies(req.headers.cookie || '');
|
||||||
|
const token = cookies[sessionCookieName];
|
||||||
|
if (!token) {
|
||||||
|
return res.redirect('/account?message=' + encodeURIComponent('Current session could not be identified.'));
|
||||||
|
}
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'DELETE FROM a_sessions WHERE id = ? AND user_id = ? AND session_hash <> ?',
|
||||||
|
[sessionId, req.currentUser.id, hashSessionToken(token)]
|
||||||
|
);
|
||||||
|
if (result && result.affectedRows && typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'sessions',
|
||||||
|
eventType: 'session.revoked',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: req.currentUser.id,
|
||||||
|
targetLabel: req.currentUser.username,
|
||||||
|
details: { scope: 'single' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.redirect('/account?message=' + encodeURIComponent(result && result.affectedRows ? 'Session signed out.' : 'The current session cannot be signed out.'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/account/sessions/revoke', async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
if (!settings['security.allow_user_session_revocation']) {
|
||||||
|
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
|
||||||
|
}
|
||||||
|
const cookies = parseCookies(req.headers.cookie || '');
|
||||||
|
const token = cookies[sessionCookieName];
|
||||||
|
if (token) {
|
||||||
|
await pool.query('DELETE FROM a_sessions WHERE user_id = ? AND session_hash <> ?', [req.currentUser.id, hashSessionToken(token)]);
|
||||||
|
}
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'sessions',
|
||||||
|
eventType: 'session.revoked',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: req.currentUser.id,
|
||||||
|
targetLabel: req.currentUser.username,
|
||||||
|
details: { scope: 'other_sessions' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.redirect('/account?message=' + encodeURIComponent('Other active sessions were signed out.'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/account/name', async function (req, res, next) {
|
app.post('/account/name', async function (req, res, next) {
|
||||||
@@ -53,7 +175,7 @@ module.exports = function registerAccountRoutes(app, deps) {
|
|||||||
if (!verifyPassword(currentPassword, user)) {
|
if (!verifyPassword(currentPassword, user)) {
|
||||||
return res.status(400).send('Current password is incorrect.');
|
return res.status(400).send('Current password is incorrect.');
|
||||||
}
|
}
|
||||||
const passwordStrengthMessage = validatePasswordStrength(newPassword);
|
const passwordStrengthMessage = validatePasswordStrength(newPassword, await getPasswordRequirements());
|
||||||
if (passwordStrengthMessage) {
|
if (passwordStrengthMessage) {
|
||||||
return res.status(400).send(passwordStrengthMessage);
|
return res.status(400).send(passwordStrengthMessage);
|
||||||
}
|
}
|
||||||
@@ -63,13 +185,25 @@ module.exports = function registerAccountRoutes(app, deps) {
|
|||||||
|
|
||||||
const passwordRecord = hashPassword(newPassword);
|
const passwordRecord = hashPassword(newPassword);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = 0, modified_by = ? WHERE id = ?',
|
||||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
|
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
|
||||||
);
|
);
|
||||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]);
|
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'security',
|
||||||
|
eventType: 'password.changed',
|
||||||
|
actorUserId: user.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
targetLabel: user.username,
|
||||||
|
details: { source: 'account' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const token = await createUserSession(pool, user.id);
|
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined;
|
||||||
setSessionCookie(res, token);
|
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, getSessionMetadata(req));
|
||||||
|
setSessionCookie(res, token, sessionMaxAgeMs);
|
||||||
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
|
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
||||||
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||||
|
|
||||||
module.exports = function registerContentRoutes(app, deps) {
|
module.exports = function registerContentRoutes(app, deps) {
|
||||||
@@ -19,6 +21,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
const removeUnusedUploadFiles = deps.removeUnusedUploadFiles;
|
const removeUnusedUploadFiles = deps.removeUnusedUploadFiles;
|
||||||
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
|
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
|
||||||
const getAuditUserId = deps.getAuditUserId;
|
const getAuditUserId = deps.getAuditUserId;
|
||||||
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||||
const redirectAfterSave = deps.redirectAfterSave;
|
const redirectAfterSave = deps.redirectAfterSave;
|
||||||
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
||||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||||
@@ -37,11 +40,32 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||||
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
function normalizeTemplateRegionsForAudit(regions) {
|
||||||
|
return (Array.isArray(regions) ? regions : []).map(function (region) {
|
||||||
|
const animation = typeof region.animation_json === 'string'
|
||||||
|
? common.parseJsonSafe(region.animation_json) || {}
|
||||||
|
: region.animation_json || {};
|
||||||
|
return {
|
||||||
|
region_key: region.region_key,
|
||||||
|
region_type: region.region_type,
|
||||||
|
label: region.label,
|
||||||
|
lock_ratio: region.lock_ratio,
|
||||||
|
animation_json: animation,
|
||||||
|
x: Number(region.x),
|
||||||
|
y: Number(region.y),
|
||||||
|
width: Number(region.width),
|
||||||
|
height: Number(region.height),
|
||||||
|
z_index: Number(region.z_index)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSlideFormData() {
|
async function fetchSlideFormData() {
|
||||||
const data = await common.fetchTemplatesData(pool);
|
const data = await common.fetchTemplatesData(pool);
|
||||||
const rssData = await common.fetchRssFeedsData(pool);
|
const rssData = await common.fetchRssFeedsData(pool);
|
||||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||||
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
|
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
|
||||||
|
const appSettings = await fetchAppSettings(pool);
|
||||||
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
||||||
return Object.assign({}, source, {
|
return Object.assign({}, source, {
|
||||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||||
@@ -60,7 +84,13 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
rssFeeds: rssFeeds,
|
rssFeeds: rssFeeds,
|
||||||
apiSources: apiSources,
|
apiSources: apiSources,
|
||||||
timetableGroups: timetableData.timetableGroups || [],
|
timetableGroups: timetableData.timetableGroups || [],
|
||||||
fontLibrary: loadFontLibrary(deps.uploadDir)
|
fontLibrary: loadFontLibrary(deps.uploadDir),
|
||||||
|
uploadLimits: {
|
||||||
|
imageMaxBytes: Number(appSettings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES,
|
||||||
|
videoMaxBytes: Number(appSettings['uploads.video_max_bytes']) || VIDEO_UPLOAD_MAX_BYTES,
|
||||||
|
wysiwygImageMaxBytes: Number(appSettings['uploads.wysiwyg_image_max_bytes']) || WYSIWYG_IMAGE_UPLOAD_MAX_BYTES,
|
||||||
|
allowedMimeTypes: Array.isArray(appSettings['uploads.allowed_mime_types']) ? appSettings['uploads.allowed_mime_types'] : []
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,20 +153,24 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUploadedFileLimitBytes(file, uploadContext) {
|
function getUploadedFileLimitBytes(file, uploadContext, settings) {
|
||||||
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
||||||
return WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
|
return Number(settings && settings['uploads.wysiwyg_image_max_bytes']) || WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getUploadedFileMediaType(file, uploadContext) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
return getUploadedFileMediaType(file, uploadContext) === 'video'
|
||||||
|
? Number(settings && settings['uploads.video_max_bytes']) || VIDEO_UPLOAD_MAX_BYTES
|
||||||
|
: Number(settings && settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUploadedFileLimitLabel(file, uploadContext) {
|
function getUploadedFileLimitLabel(file, uploadContext, settings) {
|
||||||
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
||||||
return '10 MB';
|
const limitMb = getUploadedFileLimitBytes(file, uploadContext, settings) / 1024 / 1024;
|
||||||
|
return (Number.isInteger(limitMb) ? String(limitMb) : limitMb.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')) + ' MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
return getUploadedFileMediaType(file, uploadContext) === 'video' ? '1 GB' : '100 MB';
|
const limitMb = Math.round(getUploadedFileLimitBytes(file, uploadContext, settings) / 1024 / 1024);
|
||||||
|
return limitMb >= 1024 && limitMb % 1024 === 0 ? (limitMb / 1024) + ' GB' : limitMb + ' MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeUploadedFile(file) {
|
async function removeUploadedFile(file) {
|
||||||
@@ -154,8 +188,18 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
|
|
||||||
async function validateUploadedFiles(files, uploadContext) {
|
async function validateUploadedFiles(files, uploadContext) {
|
||||||
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
for (let i = 0; i < list.length; i += 1) {
|
for (let i = 0; i < list.length; i += 1) {
|
||||||
const file = list[i];
|
const file = list[i];
|
||||||
|
const fileMimeType = String(file && file.mimetype || '').trim().toLowerCase();
|
||||||
|
const allowedMimeTypes = Array.isArray(settings['uploads.allowed_mime_types']) ? settings['uploads.allowed_mime_types'] : [];
|
||||||
|
if (allowedMimeTypes.indexOf(fileMimeType) === -1) {
|
||||||
|
await removeUploadedFile(file);
|
||||||
|
const error = new Error('This upload type is disabled in Media Uploads settings.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
error.expose = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const mediaType = getUploadedFileMediaType(file, uploadContext);
|
const mediaType = getUploadedFileMediaType(file, uploadContext);
|
||||||
if (!mediaType) {
|
if (!mediaType) {
|
||||||
await removeUploadedFile(file);
|
await removeUploadedFile(file);
|
||||||
@@ -165,11 +209,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext)) {
|
if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext, settings)) {
|
||||||
await removeUploadedFile(file);
|
await removeUploadedFile(file);
|
||||||
const error = new Error(String(uploadContext || '').trim().toLowerCase() === 'wysiwyg'
|
const error = new Error(String(uploadContext || '').trim().toLowerCase() === 'wysiwyg'
|
||||||
? 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.'
|
? 'Image must be ' + getUploadedFileLimitLabel(file, uploadContext, settings) + ' or smaller. Larger images should use the dedicated Image region.'
|
||||||
: 'File must be ' + getUploadedFileLimitLabel(file, uploadContext) + ' or smaller.');
|
: 'File must be ' + getUploadedFileLimitLabel(file, uploadContext, settings) + ' or smaller.');
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
error.expose = true;
|
error.expose = true;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -447,6 +491,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
queueSlideThumbnailRefresh(result.insertId, null).catch(function (error) {
|
queueSlideThumbnailRefresh(result.insertId, null).catch(function (error) {
|
||||||
console.warn('Unable to queue slide thumbnail refresh:', error);
|
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||||
});
|
});
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.created', actorUserId: actorId, targetType: 'slide', targetId: result.insertId, targetLabel: payload.title });
|
||||||
redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', {
|
redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', {
|
||||||
closeUrl: '/slides',
|
closeUrl: '/slides',
|
||||||
newUrl: '/slides/new',
|
newUrl: '/slides/new',
|
||||||
@@ -473,6 +518,15 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
title: slide.title,
|
||||||
|
templateId: Number(slide.template_id),
|
||||||
|
content: slide.content
|
||||||
|
}, {
|
||||||
|
title: payload.title,
|
||||||
|
templateId: Number(payload.templateId),
|
||||||
|
content: common.parseJsonSafe(payload.contentJson) || {}
|
||||||
|
});
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
||||||
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
|
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
|
||||||
@@ -488,6 +542,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
screenSlideCounts: screenSlideCounts
|
screenSlideCounts: screenSlideCounts
|
||||||
});
|
});
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } });
|
||||||
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
|
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
|
||||||
console.warn('Unable to queue slide thumbnail refresh:', error);
|
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||||
});
|
});
|
||||||
@@ -526,6 +581,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
screenSlideCounts: screenSlideCounts
|
screenSlideCounts: screenSlideCounts
|
||||||
});
|
});
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.deleted', actorUserId: getAuditUserId(req), targetType: 'slide', targetId: slide.id, targetLabel: slide.title });
|
||||||
res.redirect('/slides?message=' + encodeURIComponent('Slide deleted.'));
|
res.redirect('/slides?message=' + encodeURIComponent('Slide deleted.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -584,6 +640,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
localUploadDir: deps.uploadDir,
|
localUploadDir: deps.uploadDir,
|
||||||
nextUploadRefs: collectUploadReferencesFromPayload(payload)
|
nextUploadRefs: collectUploadReferencesFromPayload(payload)
|
||||||
});
|
});
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.created', actorUserId: actorId, targetType: 'template', targetId: result.insertId, targetLabel: payload.name });
|
||||||
redirectAfterSave(req, res, '/templates/' + result.insertId + '/edit', {
|
redirectAfterSave(req, res, '/templates/' + result.insertId + '/edit', {
|
||||||
closeUrl: '/templates',
|
closeUrl: '/templates',
|
||||||
newUrl: '/templates/new',
|
newUrl: '/templates/new',
|
||||||
@@ -628,6 +685,19 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: template.name,
|
||||||
|
canvasSizeId: Number(template.canvas_size_id),
|
||||||
|
backgroundImagePath: template.background_image_path,
|
||||||
|
backgroundColor: template.background_color,
|
||||||
|
regions: normalizeTemplateRegionsForAudit(template.regions)
|
||||||
|
}, {
|
||||||
|
name: payload.name,
|
||||||
|
canvasSizeId: Number(payload.canvasSizeId),
|
||||||
|
backgroundImagePath: payload.backgroundImagePath,
|
||||||
|
backgroundColor: payload.backgroundColor,
|
||||||
|
regions: normalizeTemplateRegionsForAudit(payload.regions)
|
||||||
|
});
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
|
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
|
||||||
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
|
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
|
||||||
@@ -654,6 +724,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name, details: { changes: changes } });
|
||||||
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
|
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
|
||||||
closeUrl: '/templates',
|
closeUrl: '/templates',
|
||||||
newUrl: '/templates/new',
|
newUrl: '/templates/new',
|
||||||
@@ -686,6 +757,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
previousUploadRefs: uploadRefs
|
previousUploadRefs: uploadRefs
|
||||||
});
|
});
|
||||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.deleted', actorUserId: getAuditUserId(req), targetType: 'template', targetId: template.id, targetLabel: template.name });
|
||||||
res.redirect('/templates?message=' + encodeURIComponent('Template deleted.'));
|
res.redirect('/templates?message=' + encodeURIComponent('Template deleted.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -743,6 +815,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
const [result] = await pool.query('INSERT INTO c_canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
const [result] = await pool.query('INSERT INTO c_canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.created', actorUserId: actorId, targetType: 'canvas-size', targetId: result.insertId, targetLabel: payload.name, details: { width: payload.width, height: payload.height } });
|
||||||
redirectAfterSave(req, res, '/canvas-sizes/' + result.insertId + '/edit', {
|
redirectAfterSave(req, res, '/canvas-sizes/' + result.insertId + '/edit', {
|
||||||
closeUrl: '/canvas-sizes',
|
closeUrl: '/canvas-sizes',
|
||||||
newUrl: '/canvas-sizes/new',
|
newUrl: '/canvas-sizes/new',
|
||||||
@@ -783,7 +856,17 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||||
return res.status(400).send('That canvas size already exists.');
|
return res.status(400).send('That canvas size already exists.');
|
||||||
}
|
}
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: canvasSize.name,
|
||||||
|
width: Number(canvasSize.width),
|
||||||
|
height: Number(canvasSize.height)
|
||||||
|
}, {
|
||||||
|
name: payload.name,
|
||||||
|
width: payload.width,
|
||||||
|
height: payload.height
|
||||||
|
});
|
||||||
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.updated', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: payload.name, details: { changes: changes } });
|
||||||
redirectAfterSave(req, res, '/canvas-sizes', {
|
redirectAfterSave(req, res, '/canvas-sizes', {
|
||||||
closeUrl: '/canvas-sizes',
|
closeUrl: '/canvas-sizes',
|
||||||
newUrl: '/canvas-sizes/new',
|
newUrl: '/canvas-sizes/new',
|
||||||
@@ -806,6 +889,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
await pool.query('UPDATE c_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
|
await pool.query('UPDATE c_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
|
||||||
await pool.query('DELETE FROM c_canvas_sizes WHERE id = ?', [canvasSize.id]);
|
await pool.query('DELETE FROM c_canvas_sizes WHERE id = ?', [canvasSize.id]);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.deleted', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: canvasSize.name });
|
||||||
res.redirect('/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
|
res.redirect('/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
// Admin manage routes for screens and commands.
|
// Admin manage routes for screens and commands.
|
||||||
|
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerManageRoutes(app, deps) {
|
module.exports = function registerManageRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const getAuditUserId = deps.getAuditUserId;
|
const getAuditUserId = deps.getAuditUserId;
|
||||||
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||||
const redirectAfterSave = deps.redirectAfterSave;
|
const redirectAfterSave = deps.redirectAfterSave;
|
||||||
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
||||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||||
@@ -217,6 +220,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.created', actorUserId: actorId, targetType: 'screen', targetId: result.insertId, targetLabel: name, details: { slug: slug, playlistId: playlistId } });
|
||||||
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
|
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
|
||||||
closeUrl: '/screens',
|
closeUrl: '/screens',
|
||||||
newUrl: '/screens/new',
|
newUrl: '/screens/new',
|
||||||
@@ -246,6 +250,15 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
const previousPlaylistId = screen.playlist_id;
|
const previousPlaylistId = screen.playlist_id;
|
||||||
const slug = String(screen.slug || '').trim();
|
const slug = String(screen.slug || '').trim();
|
||||||
const previousSlug = String(screen.slug || '').trim();
|
const previousSlug = String(screen.slug || '').trim();
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: screen.name,
|
||||||
|
slug: previousSlug,
|
||||||
|
playlistId: previousPlaylistId === null ? null : Number(previousPlaylistId)
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
slug: slug,
|
||||||
|
playlistId: playlistId
|
||||||
|
});
|
||||||
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
||||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||||
@@ -262,6 +275,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
await forwardPlayerCommand(previousSlug, redirectPayload);
|
await forwardPlayerCommand(previousSlug, redirectPayload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { changes: changes } });
|
||||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||||
closeUrl: '/screens',
|
closeUrl: '/screens',
|
||||||
newUrl: '/screens/new',
|
newUrl: '/screens/new',
|
||||||
@@ -283,6 +297,7 @@ module.exports = function registerManageRoutes(app, deps) {
|
|||||||
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
|
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
|
||||||
}
|
}
|
||||||
await pool.query('DELETE FROM d_screens WHERE id = ?', [screen.id]);
|
await pool.query('DELETE FROM d_screens WHERE id = ?', [screen.id]);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.deleted', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: screen.name });
|
||||||
res.redirect('/screens?message=' + encodeURIComponent('Screen deleted.'));
|
res.redirect('/screens?message=' + encodeURIComponent('Screen deleted.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// Playlist admin routes and playlist-slide management.
|
// Playlist admin routes and playlist-slide management.
|
||||||
|
|
||||||
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerPlaylistRoutes(app, deps) {
|
module.exports = function registerPlaylistRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
@@ -9,6 +12,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
const fetchPlaylistCanvasId = deps.fetchPlaylistCanvasId;
|
const fetchPlaylistCanvasId = deps.fetchPlaylistCanvasId;
|
||||||
const readArrayField = deps.readArrayField;
|
const readArrayField = deps.readArrayField;
|
||||||
const getAuditUserId = deps.getAuditUserId;
|
const getAuditUserId = deps.getAuditUserId;
|
||||||
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||||
const redirectAfterSave = deps.redirectAfterSave;
|
const redirectAfterSave = deps.redirectAfterSave;
|
||||||
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
||||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||||
@@ -295,6 +299,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
};
|
};
|
||||||
await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: false });
|
await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: false });
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.created', actorUserId: actorId, targetType: 'playlist', targetId: result.insertId, targetLabel: name });
|
||||||
redirectAfterSave(req, res, '/playlists/' + result.insertId + '/edit', {
|
redirectAfterSave(req, res, '/playlists/' + result.insertId + '/edit', {
|
||||||
closeUrl: '/playlists',
|
closeUrl: '/playlists',
|
||||||
newUrl: '/playlists/new',
|
newUrl: '/playlists/new',
|
||||||
@@ -352,6 +357,20 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
await connection.commit();
|
await connection.commit();
|
||||||
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: playlist.name,
|
||||||
|
fadeBetweenSlides: Boolean(playlist.fade_between_slides),
|
||||||
|
skipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
|
||||||
|
canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id)
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
fadeBetweenSlides: Boolean(fadeBetweenSlides),
|
||||||
|
skipUnavailableRtmp: Boolean(skipUnavailableRtmp),
|
||||||
|
canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId)
|
||||||
|
});
|
||||||
|
await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name, details: { changes: changes } });
|
||||||
|
}
|
||||||
redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', {
|
redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', {
|
||||||
closeUrl: '/playlists',
|
closeUrl: '/playlists',
|
||||||
newUrl: '/playlists/new',
|
newUrl: '/playlists/new',
|
||||||
@@ -383,6 +402,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
|
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
|
||||||
}
|
}
|
||||||
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
|
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.deleted', actorUserId: getAuditUserId(req), targetType: 'playlist', targetId: playlist.id, targetLabel: playlist.name });
|
||||||
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
|
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -411,7 +431,9 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
if (playlistCanvasId && slideCanvasId !== playlistCanvasId) {
|
if (playlistCanvasId && slideCanvasId !== playlistCanvasId) {
|
||||||
return res.status(400).send('The slide canvas size must match the existing playlist items.');
|
return res.status(400).send('The slide canvas size must match the existing playlist items.');
|
||||||
}
|
}
|
||||||
const durationValue = Number(req.body.duration_seconds || 10);
|
const hasSubmittedDuration = req.body.duration_seconds !== undefined && String(req.body.duration_seconds).trim() !== '';
|
||||||
|
const settings = hasSubmittedDuration ? null : await fetchAppSettings(pool);
|
||||||
|
const durationValue = Number(hasSubmittedDuration ? req.body.duration_seconds : settings['player.default_slide_duration_seconds']);
|
||||||
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||||
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||||
const nextPosition = Number(positionRows[0].max_position) + 1;
|
const nextPosition = Number(positionRows[0].max_position) + 1;
|
||||||
|
|||||||
+108
-25
@@ -1,10 +1,13 @@
|
|||||||
// Admin RBAC route registration and permission management.
|
// Admin RBAC route registration and permission management.
|
||||||
|
const { DEFAULT_ROLE } = require('#src/rbac');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerRbacRoutes(app, deps) {
|
module.exports = function registerRbacRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const getAuditUserId = deps.getAuditUserId;
|
const getAuditUserId = deps.getAuditUserId;
|
||||||
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||||
const rbacData = deps.rbacData;
|
const rbacData = deps.rbacData;
|
||||||
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
|
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
|
||||||
const readArrayField = deps.readArrayField;
|
const readArrayField = deps.readArrayField;
|
||||||
@@ -114,6 +117,7 @@
|
|||||||
sectionIndex: toSortIndex(permission.sectionIndex),
|
sectionIndex: toSortIndex(permission.sectionIndex),
|
||||||
sectionOrder: Number(permission.sectionOrder) || 999,
|
sectionOrder: Number(permission.sectionOrder) || 999,
|
||||||
resourceIndex: toSortIndex(permission.resourceIndex),
|
resourceIndex: toSortIndex(permission.resourceIndex),
|
||||||
|
resourceOrder: Number(permission.resourceOrder) || 999,
|
||||||
permissions: []
|
permissions: []
|
||||||
};
|
};
|
||||||
groupIndex.set(sectionKey, group);
|
groupIndex.set(sectionKey, group);
|
||||||
@@ -191,7 +195,7 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
|
app.get('/settings/roles', requirePermission('rbac.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const search = common.getSearchQuery(req);
|
const search = common.getSearchQuery(req);
|
||||||
@@ -207,7 +211,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
|
app.get('/settings/roles/new', requirePermission('rbac.create'), function (req, res, next) {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const search = common.getSearchQuery(req);
|
const search = common.getSearchQuery(req);
|
||||||
const sort = common.getSortQuery(req);
|
const sort = common.getSortQuery(req);
|
||||||
@@ -223,7 +227,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/rbac/:id/duplicate', requirePermission('rbac.read'), requirePermission('rbac.create'), async function (req, res, next) {
|
app.get('/settings/roles/:id/duplicate', requirePermission('rbac.read'), requirePermission('rbac.create'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const roleId = Number(req.params.id);
|
const roleId = Number(req.params.id);
|
||||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||||
@@ -252,7 +256,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/rbac', requirePermission('rbac.create'), async function (req, res, next) {
|
app.post('/settings/roles', requirePermission('rbac.create'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
|
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
|
||||||
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
|
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
|
||||||
@@ -319,13 +323,24 @@
|
|||||||
} finally {
|
} finally {
|
||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
res.redirect('/rbac/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.'));
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'roles',
|
||||||
|
eventType: 'role.created',
|
||||||
|
actorUserId: actorId,
|
||||||
|
targetType: 'role',
|
||||||
|
targetId: insertedRoleId,
|
||||||
|
targetLabel: name,
|
||||||
|
details: { permissionKeys: selectedPermissionKeys, userIds: normalizedUserIds }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.redirect('/settings/roles/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/rbac/:id/edit', requirePermission('rbac.update'), async function (req, res, next) {
|
app.get('/settings/roles/:id/edit', requirePermission('rbac.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const roleId = Number(req.params.id);
|
const roleId = Number(req.params.id);
|
||||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||||
@@ -346,7 +361,7 @@
|
|||||||
excludeUserId: currentUserId
|
excludeUserId: currentUserId
|
||||||
});
|
});
|
||||||
const users = mapUsersForView(data.users, viewModel.selectedUserIds);
|
const users = mapUsersForView(data.users, viewModel.selectedUserIds);
|
||||||
viewModel.role.inUse = String(viewModel.role.role_key || '') === 'administrators' || Number(viewModel.role.user_count) > 0;
|
viewModel.role.inUse = String(viewModel.role.role_key || '') === DEFAULT_ROLE.key || String(viewModel.role.role_key || '') === 'administrators' || Number(viewModel.role.user_count) > 0;
|
||||||
|
|
||||||
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')));
|
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -354,7 +369,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/rbac/:id', requirePermission('rbac.update'), async function (req, res, next) {
|
app.post('/settings/roles/:id', requirePermission('rbac.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const roleId = Number(req.params.id);
|
const roleId = Number(req.params.id);
|
||||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||||
@@ -376,15 +391,18 @@
|
|||||||
const selectedUserIds = shouldSyncUsers
|
const selectedUserIds = shouldSyncUsers
|
||||||
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
||||||
: [];
|
: [];
|
||||||
|
const visibleUserIds = shouldSyncUsers
|
||||||
|
? normalizeSelectedIds(readArrayField(req.body, ['user_ids_present[]', 'user_ids_present']))
|
||||||
|
: [];
|
||||||
const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : [];
|
const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : [];
|
||||||
const normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : [];
|
let normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : [];
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
|
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await common.fetchDuplicateName(pool, 'a_roles', name, roleId)) {
|
if (await common.fetchDuplicateName(pool, 'a_roles', name, roleId)) {
|
||||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.'));
|
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||||
@@ -393,12 +411,21 @@
|
|||||||
if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) {
|
if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) {
|
||||||
return !validPermissionKeys.has(permissionKey);
|
return !validPermissionKeys.has(permissionKey);
|
||||||
})) {
|
})) {
|
||||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingRolePermissionKeys = shouldSyncPermissions
|
||||||
|
? await rbacData.fetchRolePermissionKeys(pool, roleId)
|
||||||
|
: [];
|
||||||
|
let existingRoleUserIds = [];
|
||||||
let availableUsers = [];
|
let availableUsers = [];
|
||||||
if (shouldSyncUsers) {
|
if (shouldSyncUsers) {
|
||||||
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||||
|
existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||||
|
const visibleUserIdSet = new Set(visibleUserIds);
|
||||||
|
normalizedUserIds = existingRoleUserIds.filter(function (userId) {
|
||||||
|
return !visibleUserIdSet.has(userId);
|
||||||
|
}).concat(normalizedUserIds);
|
||||||
}
|
}
|
||||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||||
return Number(user.id);
|
return Number(user.id);
|
||||||
@@ -406,7 +433,7 @@
|
|||||||
if (shouldSyncUsers && normalizedUserIds.some(function (userId) {
|
if (shouldSyncUsers && normalizedUserIds.some(function (userId) {
|
||||||
return !validUserIds.has(userId);
|
return !validUserIds.has(userId);
|
||||||
})) {
|
})) {
|
||||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
@@ -429,13 +456,35 @@
|
|||||||
} finally {
|
} finally {
|
||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: role.name,
|
||||||
|
description: role.description,
|
||||||
|
permissionKeys: existingRolePermissionKeys,
|
||||||
|
userIds: existingRoleUserIds
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
description: description || null,
|
||||||
|
permissionKeys: normalizedPermissionKeys,
|
||||||
|
userIds: normalizedUserIds
|
||||||
|
});
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'roles',
|
||||||
|
eventType: 'role.updated',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'role',
|
||||||
|
targetId: roleId,
|
||||||
|
targetLabel: name,
|
||||||
|
details: { changes: changes }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/rbac/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) {
|
app.post('/settings/roles/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const roleId = Number(req.params.id);
|
const roleId = Number(req.params.id);
|
||||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||||
@@ -459,8 +508,9 @@
|
|||||||
if (normalizedPermissionKeys.some(function (permissionKey) {
|
if (normalizedPermissionKeys.some(function (permissionKey) {
|
||||||
return !validPermissionKeys.has(permissionKey);
|
return !validPermissionKeys.has(permissionKey);
|
||||||
})) {
|
})) {
|
||||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||||
}
|
}
|
||||||
|
const existingPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
@@ -473,13 +523,24 @@
|
|||||||
} finally {
|
} finally {
|
||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'roles',
|
||||||
|
eventType: 'role.permissions_updated',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'role',
|
||||||
|
targetId: roleId,
|
||||||
|
targetLabel: role.name,
|
||||||
|
details: { changes: buildAuditChanges({ permissionKeys: existingPermissionKeys }, { permissionKeys: normalizedPermissionKeys }) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/rbac/:id/users', requirePermission('rbac.update'), async function (req, res, next) {
|
app.post('/settings/roles/:id/users', requirePermission('rbac.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const roleId = Number(req.params.id);
|
const roleId = Number(req.params.id);
|
||||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||||
@@ -497,6 +558,7 @@
|
|||||||
? [].concat(req.body.user_ids)
|
? [].concat(req.body.user_ids)
|
||||||
: [];
|
: [];
|
||||||
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
||||||
|
const existingUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||||
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||||
return Number(user.id);
|
return Number(user.id);
|
||||||
@@ -505,17 +567,28 @@
|
|||||||
if (normalizedUserIds.some(function (userId) {
|
if (normalizedUserIds.some(function (userId) {
|
||||||
return !validUserIds.has(userId);
|
return !validUserIds.has(userId);
|
||||||
})) {
|
})) {
|
||||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds);
|
await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds);
|
||||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'roles',
|
||||||
|
eventType: 'role.users_updated',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'role',
|
||||||
|
targetId: roleId,
|
||||||
|
targetLabel: role.name,
|
||||||
|
details: { changes: buildAuditChanges({ userIds: existingUserIds }, { userIds: normalizedUserIds }) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
|
app.post('/settings/roles/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const roleId = Number(req.params.id);
|
const roleId = Number(req.params.id);
|
||||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||||
@@ -526,15 +599,25 @@
|
|||||||
if (!role) {
|
if (!role) {
|
||||||
return res.status(404).send('Role not found.');
|
return res.status(404).send('Role not found.');
|
||||||
}
|
}
|
||||||
if (String(role.role_key || '') === 'administrators') {
|
if (String(role.role_key || '') === DEFAULT_ROLE.key || String(role.role_key || '') === 'administrators') {
|
||||||
return res.redirect('/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
|
return res.redirect('/settings/roles?message=' + encodeURIComponent('The built-in Super Admin role cannot be deleted.'));
|
||||||
}
|
}
|
||||||
if (Number(role.user_count) > 0) {
|
if (Number(role.user_count) > 0) {
|
||||||
return res.redirect('/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
|
return res.redirect('/settings/roles?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query('DELETE FROM a_roles WHERE id = ?', [roleId]);
|
await pool.query('DELETE FROM a_roles WHERE id = ?', [roleId]);
|
||||||
res.redirect('/rbac?message=' + encodeURIComponent('Role deleted.'));
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'roles',
|
||||||
|
eventType: 'role.deleted',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'role',
|
||||||
|
targetId: roleId,
|
||||||
|
targetLabel: role.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.redirect('/settings/roles?message=' + encodeURIComponent('Role deleted.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
+196
-33
@@ -1,11 +1,15 @@
|
|||||||
// Admin user route registration and user-role management.
|
// Admin user route registration and user-role management.
|
||||||
|
|
||||||
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||||
|
|
||||||
module.exports = function registerUsersRoutes(app, deps) {
|
module.exports = function registerUsersRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const formatDashboardDate = deps.formatDashboardDate;
|
const formatDashboardDate = deps.formatDashboardDate;
|
||||||
const getAuditUserId = deps.getAuditUserId;
|
const getAuditUserId = deps.getAuditUserId;
|
||||||
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||||
const hashPassword = deps.hashPassword;
|
const hashPassword = deps.hashPassword;
|
||||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||||
const readArrayField = deps.readArrayField;
|
const readArrayField = deps.readArrayField;
|
||||||
@@ -23,6 +27,23 @@
|
|||||||
return rbacData.fetchRoles(pool);
|
return rbacData.fetchRoles(pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function shouldRequirePasswordChange(settingKey) {
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
return Boolean(settings[settingKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPasswordRequirements() {
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
return {
|
||||||
|
minimumLength: settings['security.password_min_length'],
|
||||||
|
minimumCategories: settings['security.password_min_categories'],
|
||||||
|
requireLowercase: settings['security.password_require_lowercase'],
|
||||||
|
requireUppercase: settings['security.password_require_uppercase'],
|
||||||
|
requireNumber: settings['security.password_require_number'],
|
||||||
|
requireSymbol: settings['security.password_require_symbol']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mapRolesForForm(roles, selectedRoleIds) {
|
function mapRolesForForm(roles, selectedRoleIds) {
|
||||||
const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
|
const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
|
||||||
return Number(roleId);
|
return Number(roleId);
|
||||||
@@ -57,7 +78,7 @@
|
|||||||
return { ok: true, roleIds: normalizedRoleIds };
|
return { ok: true, roleIds: normalizedRoleIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
|
app.get('/settings/users', requirePermission('users.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const search = common.getSearchQuery(req);
|
const search = common.getSearchQuery(req);
|
||||||
@@ -81,7 +102,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/users/new', requirePermission('users.create'), function (req, res) {
|
app.get('/settings/users/new', requirePermission('users.create'), function (req, res) {
|
||||||
fetchRoleOptions().then(function (roles) {
|
fetchRoleOptions().then(function (roles) {
|
||||||
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
|
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
@@ -89,7 +110,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) {
|
app.get('/settings/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const userId = Number(req.params.id);
|
const userId = Number(req.params.id);
|
||||||
if (!Number.isInteger(userId) || userId <= 0) {
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
@@ -100,8 +121,9 @@
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).send('User not found.');
|
return res.status(404).send('User not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number(req.currentUser.id) === userId) {
|
if (Number(req.currentUser.id) === userId) {
|
||||||
return res.redirect('/users?message=' + encodeURIComponent('Use Add user to create another login for yourself.'));
|
return res.redirect('/settings/users?message=' + encodeURIComponent('Use Add user to create another login for yourself.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = await fetchRoleOptions();
|
const roles = await fetchRoleOptions();
|
||||||
@@ -121,7 +143,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
|
app.get('/settings/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const userId = Number(req.params.id);
|
const userId = Number(req.params.id);
|
||||||
if (!Number.isInteger(userId) || userId <= 0) {
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
@@ -140,19 +162,62 @@
|
|||||||
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
|
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
|
||||||
|
|
||||||
const roles = await fetchRoleOptions();
|
const roles = await fetchRoleOptions();
|
||||||
|
const sessions = (await rbacData.fetchActiveUserSessions(pool, userId)).map(function (session) {
|
||||||
|
return {
|
||||||
|
id: Number(session.id),
|
||||||
|
ipAddress: String(session.ip_address || 'Unknown'),
|
||||||
|
userAgent: String(session.user_agent || 'Unknown browser'),
|
||||||
|
createdAtLabel: formatDashboardDate(session.created_at),
|
||||||
|
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
|
||||||
|
expiresAtLabel: formatDashboardDate(session.expires_at)
|
||||||
|
};
|
||||||
|
});
|
||||||
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
|
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
|
||||||
isCurrentUser: false,
|
isCurrentUser: false,
|
||||||
createdAtLabel: formatDashboardDate(user.created_at),
|
createdAtLabel: formatDashboardDate(user.created_at),
|
||||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned',
|
roleNames: String(user.roleNames || '').trim() || 'No roles assigned',
|
||||||
inUse: !canDelete
|
inUse: !canDelete
|
||||||
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
|
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds), sessions));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/users', requirePermission('users.create'), async function (req, res, next) {
|
app.post('/settings/users/:id/sessions/:sessionId/revoke', requirePermission('users.update'), async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const userId = Number(req.params.id);
|
||||||
|
const sessionId = Number(req.params.sessionId);
|
||||||
|
if (!Number.isInteger(userId) || userId <= 0 || !Number.isInteger(sessionId) || sessionId <= 0) {
|
||||||
|
return res.status(400).send('Invalid session.');
|
||||||
|
}
|
||||||
|
if (Number(req.currentUser.id) === userId) {
|
||||||
|
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to manage your own sessions.'));
|
||||||
|
}
|
||||||
|
const [result] = await pool.query('DELETE FROM a_sessions WHERE id = ? AND user_id = ?', [sessionId, userId]);
|
||||||
|
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(result && result.affectedRows ? 'Session signed out.' : 'Session was not found.'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/settings/users/:id/sessions/revoke-all', 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 manage your own sessions.'));
|
||||||
|
}
|
||||||
|
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||||
|
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('All sessions signed out.'));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/settings/users', requirePermission('users.create'), async function (req, res, next) {
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name');
|
const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name');
|
||||||
@@ -178,7 +243,7 @@
|
|||||||
if (!username) {
|
if (!username) {
|
||||||
return renderValidationError('Username is required.');
|
return renderValidationError('Username is required.');
|
||||||
}
|
}
|
||||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements());
|
||||||
if (passwordStrengthMessage) {
|
if (passwordStrengthMessage) {
|
||||||
return renderValidationError(passwordStrengthMessage);
|
return renderValidationError(passwordStrengthMessage);
|
||||||
}
|
}
|
||||||
@@ -198,18 +263,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordRecord = hashPassword(password);
|
const passwordRecord = hashPassword(password);
|
||||||
|
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_for_new_users');
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
const [result] = await connection.query(
|
const [result] = await connection.query(
|
||||||
'INSERT INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO a_users (name, username, password_hash, password_salt, password_iterations, must_change_password, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId]
|
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, actorId, actorId]
|
||||||
);
|
);
|
||||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
if (saveAction === 'new') {
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
return res.redirect('/users/new?message=' + encodeURIComponent('User created.'));
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'users',
|
||||||
|
eventType: 'user.created',
|
||||||
|
actorUserId: actorId,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: result.insertId,
|
||||||
|
targetLabel: username,
|
||||||
|
details: { roleIds: roleCheck.roleIds }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
res.redirect('/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.'));
|
if (saveAction === 'new') {
|
||||||
|
return res.redirect('/settings/users/new?message=' + encodeURIComponent('User created.'));
|
||||||
|
}
|
||||||
|
res.redirect('/settings/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
await connection.rollback();
|
await connection.rollback();
|
||||||
@@ -222,7 +299,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
|
app.post('/settings/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const userId = Number(req.params.id);
|
const userId = Number(req.params.id);
|
||||||
if (!Number.isInteger(userId) || userId <= 0) {
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
@@ -240,17 +317,30 @@
|
|||||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||||
if (!roleCheck.ok) {
|
if (!roleCheck.ok) {
|
||||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingUser = await rbacData.fetchUserWithRoles(pool, userId);
|
||||||
|
const changes = buildAuditChanges({ roleIds: existingUser ? existingUser.roleIds : [] }, { roleIds: roleCheck.roleIds });
|
||||||
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
||||||
res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'users',
|
||||||
|
eventType: 'user.roles_updated',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: userId,
|
||||||
|
targetLabel: String(userId),
|
||||||
|
details: { changes: changes }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
|
app.post('/settings/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
const userId = Number(req.params.id);
|
const userId = Number(req.params.id);
|
||||||
@@ -274,6 +364,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const username = common.validateMaxLength(req.body.username || user.username || '', USER_USERNAME_MAX_LENGTH, 'Username');
|
const username = common.validateMaxLength(req.body.username || user.username || '', USER_USERNAME_MAX_LENGTH, 'Username');
|
||||||
|
const accountLocked = req.body.account_locked === '1' || (Array.isArray(req.body.account_locked) && req.body.account_locked.includes('1'));
|
||||||
|
|
||||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
||||||
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
|
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
|
||||||
@@ -296,7 +387,7 @@
|
|||||||
return renderValidationError('Username is required.');
|
return renderValidationError('Username is required.');
|
||||||
}
|
}
|
||||||
if (shouldUpdatePassword) {
|
if (shouldUpdatePassword) {
|
||||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements());
|
||||||
if (passwordStrengthMessage) {
|
if (passwordStrengthMessage) {
|
||||||
return renderValidationError(passwordStrengthMessage);
|
return renderValidationError(passwordStrengthMessage);
|
||||||
}
|
}
|
||||||
@@ -316,26 +407,75 @@
|
|||||||
return renderValidationError('That username already exists.');
|
return renderValidationError('That username already exists.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const changes = buildAuditChanges({
|
||||||
|
name: user.name,
|
||||||
|
username: user.username,
|
||||||
|
roleIds: user.roleIds,
|
||||||
|
accountLocked: Boolean(user.account_locked),
|
||||||
|
passwordReset: false
|
||||||
|
}, {
|
||||||
|
name: name,
|
||||||
|
username: username,
|
||||||
|
roleIds: roleCheck.roleIds,
|
||||||
|
accountLocked: accountLocked,
|
||||||
|
passwordReset: shouldUpdatePassword
|
||||||
|
});
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
|
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]);
|
||||||
if (!result.affectedRows) {
|
if (!result.affectedRows) {
|
||||||
await connection.rollback();
|
await connection.rollback();
|
||||||
return res.status(404).send('User not found.');
|
return res.status(404).send('User not found.');
|
||||||
}
|
}
|
||||||
await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
|
await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
|
||||||
|
if (accountLocked) {
|
||||||
|
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||||
|
}
|
||||||
if (shouldUpdatePassword) {
|
if (shouldUpdatePassword) {
|
||||||
const passwordRecord = hashPassword(password);
|
const passwordRecord = hashPassword(password);
|
||||||
|
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset');
|
||||||
await connection.query(
|
await connection.query(
|
||||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?',
|
||||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]
|
||||||
);
|
);
|
||||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||||
}
|
}
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
if (saveAction === 'new') {
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
return res.redirect('/users/new?message=' + encodeURIComponent('User updated.'));
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'users',
|
||||||
|
eventType: 'user.updated',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: userId,
|
||||||
|
targetLabel: username,
|
||||||
|
details: { changes: changes }
|
||||||
|
});
|
||||||
|
if (Boolean(user.account_locked) !== accountLocked) {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'security',
|
||||||
|
eventType: accountLocked ? 'account.locked' : 'account.unlocked',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: userId,
|
||||||
|
targetLabel: username
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (shouldUpdatePassword) {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'security',
|
||||||
|
eventType: 'password.reset',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: userId,
|
||||||
|
targetLabel: username,
|
||||||
|
details: { source: 'administrator' }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
|
if (saveAction === 'new') {
|
||||||
|
return res.redirect('/settings/users/new?message=' + encodeURIComponent('User updated.'));
|
||||||
|
}
|
||||||
|
res.redirect('/settings/users?message=' + encodeURIComponent('User updated.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
await connection.rollback();
|
await connection.rollback();
|
||||||
@@ -348,12 +488,12 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/users/:id/password', requirePermission('users.update'), async function (req, res, next) {
|
app.post('/settings/users/:id/password', requirePermission('users.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const userId = Number(req.params.id);
|
const userId = Number(req.params.id);
|
||||||
const password = String(req.body.password || '');
|
const password = String(req.body.password || '');
|
||||||
const confirmPassword = String(req.body.confirm_password || '');
|
const confirmPassword = String(req.body.confirm_password || '');
|
||||||
const editUrl = '/users/' + userId + '/edit';
|
const editUrl = '/settings/users/' + userId + '/edit';
|
||||||
|
|
||||||
if (!Number.isInteger(userId) || userId <= 0) {
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
return res.status(400).send('Invalid user.');
|
return res.status(400).send('Invalid user.');
|
||||||
@@ -361,7 +501,7 @@
|
|||||||
if (Number(req.currentUser.id) === userId) {
|
if (Number(req.currentUser.id) === userId) {
|
||||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
|
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
|
||||||
}
|
}
|
||||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements());
|
||||||
if (passwordStrengthMessage) {
|
if (passwordStrengthMessage) {
|
||||||
return res.redirect(editUrl + '?message=' + encodeURIComponent(passwordStrengthMessage));
|
return res.redirect(editUrl + '?message=' + encodeURIComponent(passwordStrengthMessage));
|
||||||
}
|
}
|
||||||
@@ -375,37 +515,60 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordRecord = hashPassword(password);
|
const passwordRecord = hashPassword(password);
|
||||||
|
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset');
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?',
|
||||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]
|
||||||
);
|
);
|
||||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'security',
|
||||||
|
eventType: 'password.reset',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: userId,
|
||||||
|
targetLabel: rows[0].username || String(userId),
|
||||||
|
details: { source: 'administrator' }
|
||||||
|
});
|
||||||
|
}
|
||||||
res.redirect(editUrl + '?message=' + encodeURIComponent('Password updated.'));
|
res.redirect(editUrl + '?message=' + encodeURIComponent('Password updated.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
|
app.post('/settings/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const userId = Number(req.params.id);
|
const userId = Number(req.params.id);
|
||||||
if (!Number.isInteger(userId) || userId <= 0) {
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
return res.status(400).send('Invalid user.');
|
return res.status(400).send('Invalid user.');
|
||||||
}
|
}
|
||||||
if (Number(req.currentUser.id) === userId) {
|
if (Number(req.currentUser.id) === userId) {
|
||||||
return res.redirect('/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
|
return res.redirect('/settings/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
||||||
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
|
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
|
||||||
return res.redirect('/users?message=' + encodeURIComponent('At least one user must remain.'));
|
return res.redirect('/settings/users?message=' + encodeURIComponent('At least one user must remain.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [userRows] = await pool.query('SELECT username FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||||
const [result] = await pool.query('DELETE FROM a_users WHERE id = ?', [userId]);
|
const [result] = await pool.query('DELETE FROM a_users WHERE id = ?', [userId]);
|
||||||
if (!result.affectedRows) {
|
if (!result.affectedRows) {
|
||||||
return res.status(404).send('User not found.');
|
return res.status(404).send('User not found.');
|
||||||
}
|
}
|
||||||
res.redirect('/users?message=' + encodeURIComponent('User deleted.'));
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'users',
|
||||||
|
eventType: 'user.deleted',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: userId,
|
||||||
|
targetLabel: userRows[0] && userRows[0].username ? userRows[0].username : String(userId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.redirect('/settings/users?message=' + encodeURIComponent('User deleted.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
+147
-12
@@ -1,12 +1,14 @@
|
|||||||
// Authentication route registration for the web app.
|
// Authentication route registration for the web app.
|
||||||
|
|
||||||
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
|
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
|
||||||
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
module.exports = function registerAuthRoutes(app, deps) {
|
module.exports = function registerAuthRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const createUserSession = deps.createUserSession;
|
const createUserSession = deps.createUserSession;
|
||||||
const setSessionCookie = deps.setSessionCookie;
|
const setSessionCookie = deps.setSessionCookie;
|
||||||
|
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
|
||||||
const clearSessionCookie = deps.clearSessionCookie;
|
const clearSessionCookie = deps.clearSessionCookie;
|
||||||
const parseCookies = deps.parseCookies;
|
const parseCookies = deps.parseCookies;
|
||||||
const consumeAuthMessageCookie = deps.consumeAuthMessageCookie;
|
const consumeAuthMessageCookie = deps.consumeAuthMessageCookie;
|
||||||
@@ -14,13 +16,65 @@ module.exports = function registerAuthRoutes(app, deps) {
|
|||||||
const hashSessionToken = deps.hashSessionToken;
|
const hashSessionToken = deps.hashSessionToken;
|
||||||
const verifyPassword = deps.verifyPassword;
|
const verifyPassword = deps.verifyPassword;
|
||||||
const sessionCookieName = deps.sessionCookieName;
|
const sessionCookieName = deps.sessionCookieName;
|
||||||
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||||
|
|
||||||
function getReturnTo(req) {
|
function getReturnTo(req) {
|
||||||
return normalizeReturnToPath(req && (req.query && req.query.returnTo || req.body && req.body.returnTo), getRequestOrigin(req));
|
return normalizeReturnToPath(req && (req.query && req.query.returnTo || req.body && req.body.returnTo), getRequestOrigin(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLoginRedirect(returnTo) {
|
function buildLoginRedirect(returnTo, username) {
|
||||||
return returnTo ? '/login?returnTo=' + encodeURIComponent(returnTo) : '/login';
|
const query = [];
|
||||||
|
if (returnTo) query.push('returnTo=' + encodeURIComponent(returnTo));
|
||||||
|
if (username) query.push('username=' + encodeURIComponent(username));
|
||||||
|
return query.length ? '/login?' + query.join('&') : '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientAddress(req) {
|
||||||
|
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||||
|
return forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim() || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRateLimitKey(username, address, scope) {
|
||||||
|
if (scope === 'username') return 'username:' + username;
|
||||||
|
if (scope === 'ip') return 'ip:' + address;
|
||||||
|
return 'both:' + JSON.stringify([username, address]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLoginRateLimitSettings() {
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
return {
|
||||||
|
maxAttempts: Number(settings['security.login_max_attempts']) || 5,
|
||||||
|
lockoutMinutes: Number(settings['security.login_lockout_minutes']) || 15,
|
||||||
|
scope: String(settings['security.login_rate_limit_scope'] || 'both')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLoginRateLimitStatus(rateKey) {
|
||||||
|
const [rows] = await pool.query('SELECT locked_until FROM a_login_attempts WHERE rate_key = ? LIMIT 1', [rateKey]);
|
||||||
|
const lockedUntil = rows && rows[0] && rows[0].locked_until ? new Date(rows[0].locked_until).getTime() : 0;
|
||||||
|
return {
|
||||||
|
limited: lockedUntil > Date.now(),
|
||||||
|
remainingMinutes: Math.max(1, Math.ceil((lockedUntil - Date.now()) / 60000))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordFailedLogin(rateKey, settings) {
|
||||||
|
const [rows] = await pool.query('SELECT failed_count, locked_until FROM a_login_attempts WHERE rate_key = ? LIMIT 1', [rateKey]);
|
||||||
|
const existing = rows && rows[0] ? rows[0] : null;
|
||||||
|
const existingLockedUntil = existing && existing.locked_until ? new Date(existing.locked_until).getTime() : 0;
|
||||||
|
const currentCount = existingLockedUntil && existingLockedUntil <= Date.now() ? 0 : Number(existing && existing.failed_count) || 0;
|
||||||
|
const failedCount = currentCount + 1;
|
||||||
|
const lockedUntil = failedCount >= settings.maxAttempts ? new Date(Date.now() + settings.lockoutMinutes * 60 * 1000) : null;
|
||||||
|
if (existing) {
|
||||||
|
await pool.query('UPDATE a_login_attempts SET failed_count = ?, last_failed_at = ?, locked_until = ? WHERE rate_key = ?', [failedCount, new Date(), lockedUntil, rateKey]);
|
||||||
|
return lockedUntil ? { remainingMinutes: Math.max(1, Math.ceil((lockedUntil.getTime() - Date.now()) / 60000)) } : null;
|
||||||
|
}
|
||||||
|
await pool.query('INSERT INTO a_login_attempts (rate_key, failed_count, last_failed_at, locked_until) VALUES (?, ?, ?, ?)', [rateKey, failedCount, new Date(), lockedUntil]);
|
||||||
|
return lockedUntil ? { remainingMinutes: Math.max(1, Math.ceil((lockedUntil.getTime() - Date.now()) / 60000)) } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearFailedLogins(rateKey) {
|
||||||
|
await pool.query('DELETE FROM a_login_attempts WHERE rate_key = ?', [rateKey]);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/', function (req, res) {
|
app.get('/', function (req, res) {
|
||||||
@@ -36,7 +90,7 @@ module.exports = function registerAuthRoutes(app, deps) {
|
|||||||
const message = typeof consumeAuthMessageCookie === 'function'
|
const message = typeof consumeAuthMessageCookie === 'function'
|
||||||
? consumeAuthMessageCookie(req, res)
|
? consumeAuthMessageCookie(req, res)
|
||||||
: (req.query.message ? String(req.query.message) : '');
|
: (req.query.message ? String(req.query.message) : '');
|
||||||
res.send(pages.renderLoginPage(message, returnTo));
|
res.send(pages.renderLoginPage(message, returnTo, req.query.username ? String(req.query.username) : ''));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/login', async function (req, res, next) {
|
app.post('/login', async function (req, res, next) {
|
||||||
@@ -47,21 +101,92 @@ module.exports = function registerAuthRoutes(app, deps) {
|
|||||||
return res.status(400).send('Username and password are required.');
|
return res.status(400).send('Username and password are required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE username = ? LIMIT 1', [username]);
|
const loginRateLimitSettings = await getLoginRateLimitSettings();
|
||||||
const user = rows[0] || null;
|
const rateKey = getRateLimitKey(username.toLowerCase(), getClientAddress(req), loginRateLimitSettings.scope);
|
||||||
if (!user || !verifyPassword(password, user)) {
|
const rateLimitStatus = await getLoginRateLimitStatus(rateKey);
|
||||||
|
if (rateLimitStatus.limited) {
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'authentication',
|
||||||
|
eventType: 'login.locked_out',
|
||||||
|
targetType: 'user',
|
||||||
|
targetLabel: username,
|
||||||
|
details: { reason: 'rate_limit' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const message = 'Too many failed login attempts. Try again in ' + rateLimitStatus.remainingMinutes + ' minute' + (rateLimitStatus.remainingMinutes === 1 ? '' : 's') + '.';
|
||||||
const returnTo = getReturnTo(req);
|
const returnTo = getReturnTo(req);
|
||||||
if (typeof setAuthMessageCookie === 'function') {
|
if (typeof setAuthMessageCookie === 'function') {
|
||||||
setAuthMessageCookie(res, 'Invalid username or password.');
|
setAuthMessageCookie(res, message);
|
||||||
return res.redirect(buildLoginRedirect(returnTo));
|
return res.redirect(buildLoginRedirect(returnTo, username));
|
||||||
}
|
}
|
||||||
|
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
|
||||||
return res.status(401).send(pages.renderLoginPage('Invalid username or password.', returnTo));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations, account_locked FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||||
|
const user = rows[0] || null;
|
||||||
|
if (user && user.account_locked) {
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'security',
|
||||||
|
eventType: 'login.blocked_account_locked',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
targetLabel: user.username
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const returnTo = getReturnTo(req);
|
||||||
|
const message = 'This account is locked. Contact an administrator.';
|
||||||
|
if (typeof setAuthMessageCookie === 'function') {
|
||||||
|
setAuthMessageCookie(res, message);
|
||||||
|
return res.redirect(buildLoginRedirect(returnTo, username));
|
||||||
|
}
|
||||||
|
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
|
||||||
|
}
|
||||||
|
if (!user || !verifyPassword(password, user)) {
|
||||||
|
const lockout = await recordFailedLogin(rateKey, loginRateLimitSettings);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'authentication',
|
||||||
|
eventType: lockout ? 'login.lockout' : 'login.failed',
|
||||||
|
targetType: user ? 'user' : 'username',
|
||||||
|
targetId: user ? user.id : null,
|
||||||
|
targetLabel: user ? user.username : username,
|
||||||
|
details: { reason: user ? 'invalid_password' : 'unknown_username' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const message = lockout
|
||||||
|
? 'Too many failed login attempts. Try again in ' + lockout.remainingMinutes + ' minute' + (lockout.remainingMinutes === 1 ? '' : 's') + '.'
|
||||||
|
: 'Invalid username or password.';
|
||||||
|
const returnTo = getReturnTo(req);
|
||||||
|
if (typeof setAuthMessageCookie === 'function') {
|
||||||
|
setAuthMessageCookie(res, message);
|
||||||
|
return res.redirect(buildLoginRedirect(returnTo, username));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
|
||||||
|
}
|
||||||
|
|
||||||
|
await clearFailedLogins(rateKey);
|
||||||
const returnTo = getReturnTo(req);
|
const returnTo = getReturnTo(req);
|
||||||
const token = await createUserSession(pool, user.id);
|
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined;
|
||||||
setSessionCookie(res, token);
|
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, {
|
||||||
|
ipAddress: getClientAddress(req),
|
||||||
|
userAgent: req.headers && req.headers['user-agent']
|
||||||
|
});
|
||||||
|
const rememberMe = req.body.remember_me === '1' || req.body.remember_me === 'true' || req.body.remember_me === true;
|
||||||
|
setSessionCookie(res, token, rememberMe ? sessionMaxAgeMs : null);
|
||||||
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'authentication',
|
||||||
|
eventType: 'login.success',
|
||||||
|
actorUserId: user.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
targetLabel: user.username,
|
||||||
|
details: { rememberMe: rememberMe }
|
||||||
|
});
|
||||||
|
}
|
||||||
res.redirect(returnTo || '/dashboard');
|
res.redirect(returnTo || '/dashboard');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -75,6 +200,16 @@ module.exports = function registerAuthRoutes(app, deps) {
|
|||||||
if (token) {
|
if (token) {
|
||||||
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
|
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
|
||||||
}
|
}
|
||||||
|
if (typeof recordRequestAuditEvent === 'function' && req.currentUser) {
|
||||||
|
await recordRequestAuditEvent(pool, req, {
|
||||||
|
category: 'sessions',
|
||||||
|
eventType: 'session.logout',
|
||||||
|
actorUserId: req.currentUser.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: req.currentUser.id,
|
||||||
|
targetLabel: req.currentUser.username
|
||||||
|
});
|
||||||
|
}
|
||||||
clearSessionCookie(res);
|
clearSessionCookie(res);
|
||||||
if (typeof setAuthMessageCookie === 'function') {
|
if (typeof setAuthMessageCookie === 'function') {
|
||||||
setAuthMessageCookie(res, 'You have been signed out.');
|
setAuthMessageCookie(res, 'You have been signed out.');
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
const { renderView } = require('../../view');
|
const { renderView } = require('../../view');
|
||||||
|
|
||||||
module.exports = function renderLoginPage(message, returnTo) {
|
module.exports = function renderLoginPage(message, returnTo, username) {
|
||||||
return renderView('auth/login', {
|
return renderView('auth/login', {
|
||||||
title: 'Sign in',
|
title: 'Sign in',
|
||||||
authShell: true,
|
authShell: true,
|
||||||
bodyClass: 'login-page-body',
|
bodyClass: 'login-page-body',
|
||||||
message: message || '',
|
message: message || '',
|
||||||
returnTo: returnTo || '',
|
returnTo: returnTo || '',
|
||||||
|
username: username || '',
|
||||||
messageVariant: 'warning'
|
messageVariant: 'warning'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -5,6 +5,7 @@ const renderApiSourcesPage = require('./list');
|
|||||||
const renderApiSourceAddPage = require('./add');
|
const renderApiSourceAddPage = require('./add');
|
||||||
const renderApiSourceEditPage = require('./edit');
|
const renderApiSourceEditPage = require('./edit');
|
||||||
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
|
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
|
||||||
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
function toIsoTimestamp(value) {
|
function toIsoTimestamp(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -123,8 +124,16 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
|
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), async function (req, res, next) {
|
||||||
res.send(renderApiSourceAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
try {
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
res.send(renderApiSourceAddPage({
|
||||||
|
updateIntervalValue: settings['data-sources.api_default_interval_value'],
|
||||||
|
updateIntervalUnit: settings['data-sources.api_default_interval_unit']
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
|
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const renderRssFeedsPage = require('./list');
|
|||||||
const renderRssFeedAddPage = require('./add');
|
const renderRssFeedAddPage = require('./add');
|
||||||
const renderRssFeedEditPage = require('./edit');
|
const renderRssFeedEditPage = require('./edit');
|
||||||
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
|
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
|
||||||
|
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||||
|
|
||||||
async function getDataSourceUsageMaps(pool, common) {
|
async function getDataSourceUsageMaps(pool, common) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||||
@@ -99,8 +100,16 @@ module.exports = function registerRssFeedRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
|
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), async function (req, res, next) {
|
||||||
res.send(renderRssFeedAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
try {
|
||||||
|
const settings = await fetchAppSettings(pool);
|
||||||
|
res.send(renderRssFeedAddPage({
|
||||||
|
updateIntervalValue: settings['data-sources.rss_default_interval_value'],
|
||||||
|
updateIntervalUnit: settings['data-sources.rss_default_interval_unit']
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Shared timetable group form view-model builder.
|
// Shared timetable group form view-model builder.
|
||||||
|
|
||||||
const { normalizeTimeZone } = require('../../../../data/timetables');
|
const { normalizeTimeZone } = require('#src/data/timetables');
|
||||||
|
|
||||||
const COMMON_TIME_ZONES = [
|
const COMMON_TIME_ZONES = [
|
||||||
'UTC',
|
'UTC',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Timetable group list page renderer.
|
// Timetable group list page renderer.
|
||||||
|
|
||||||
const { renderView } = require('../../../view');
|
const { renderView } = require('../../../view');
|
||||||
const { normalizeTimeZone } = require('../../../../data/timetables');
|
const { normalizeTimeZone } = require('#src/data/timetables');
|
||||||
|
|
||||||
function formatDateInTimeZone(value, timeZone) {
|
function formatDateInTimeZone(value, timeZone) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes');
|
|||||||
const registerTimetableRoutes = require('./data-sources/timetables/routes');
|
const registerTimetableRoutes = require('./data-sources/timetables/routes');
|
||||||
const registerSettingsRoutes = require('./settings/background-tasks');
|
const registerSettingsRoutes = require('./settings/background-tasks');
|
||||||
const registerFontRoutes = require('./settings/fonts');
|
const registerFontRoutes = require('./settings/fonts');
|
||||||
|
const registerSettingsPageRoutes = require('./settings/routes');
|
||||||
|
const registerAuditLogRoutes = require('./settings/audit-log');
|
||||||
|
const registerAboutRoutes = require('./settings/about/routes');
|
||||||
const registerInternalSyncRoutes = require('./internal/sync');
|
const registerInternalSyncRoutes = require('./internal/sync');
|
||||||
const registerScreensRoutes = require('./signage/screens/routes');
|
const registerScreensRoutes = require('./signage/screens/routes');
|
||||||
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
||||||
@@ -24,6 +27,19 @@ function registerRoutes(app, deps) {
|
|||||||
registerAuthAndAccountRoutes(app, deps);
|
registerAuthAndAccountRoutes(app, deps);
|
||||||
registerSignageRoutes(app, deps);
|
registerSignageRoutes(app, deps);
|
||||||
registerSettingsAndContentRoutes(app, deps);
|
registerSettingsAndContentRoutes(app, deps);
|
||||||
|
registerSettingsPageRoutes(app, {
|
||||||
|
pool: deps.pool,
|
||||||
|
pages: deps.pages,
|
||||||
|
mediaDir: deps.mediaDir,
|
||||||
|
requirePermission: deps.requirePermission,
|
||||||
|
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||||
|
});
|
||||||
|
registerAuditLogRoutes(app, {
|
||||||
|
pool: deps.pool,
|
||||||
|
formatDashboardDate: deps.formatDashboardDate,
|
||||||
|
setAuthMessageCookie: deps.setAuthMessageCookie
|
||||||
|
});
|
||||||
|
registerAboutRoutes(app, deps);
|
||||||
registerInternalSyncRoutes(app, {
|
registerInternalSyncRoutes(app, {
|
||||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||||
uploadSyncService: deps.uploadSyncService,
|
uploadSyncService: deps.uploadSyncService,
|
||||||
@@ -50,13 +66,16 @@ function registerAuthAndAccountRoutes(app, deps) {
|
|||||||
pages: deps.pages,
|
pages: deps.pages,
|
||||||
createUserSession: deps.createUserSession,
|
createUserSession: deps.createUserSession,
|
||||||
setSessionCookie: deps.setSessionCookie,
|
setSessionCookie: deps.setSessionCookie,
|
||||||
|
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
|
||||||
|
getConfiguredMaxActiveSessions: deps.getConfiguredMaxActiveSessions,
|
||||||
clearSessionCookie: deps.clearSessionCookie,
|
clearSessionCookie: deps.clearSessionCookie,
|
||||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||||
consumeAuthMessageCookie: deps.consumeAuthMessageCookie,
|
consumeAuthMessageCookie: deps.consumeAuthMessageCookie,
|
||||||
parseCookies: deps.parseCookies,
|
parseCookies: deps.parseCookies,
|
||||||
hashSessionToken: deps.hashSessionToken,
|
hashSessionToken: deps.hashSessionToken,
|
||||||
verifyPassword: deps.verifyPassword,
|
verifyPassword: deps.verifyPassword,
|
||||||
sessionCookieName: deps.sessionCookieName
|
sessionCookieName: deps.sessionCookieName,
|
||||||
|
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||||
});
|
});
|
||||||
|
|
||||||
registerPagesRoutes(app, {
|
registerPagesRoutes(app, {
|
||||||
@@ -80,7 +99,12 @@ function registerAuthAndAccountRoutes(app, deps) {
|
|||||||
hashPassword: deps.hashPassword,
|
hashPassword: deps.hashPassword,
|
||||||
validatePasswordStrength: deps.validatePasswordStrength,
|
validatePasswordStrength: deps.validatePasswordStrength,
|
||||||
createUserSession: deps.createUserSession,
|
createUserSession: deps.createUserSession,
|
||||||
setSessionCookie: deps.setSessionCookie
|
setSessionCookie: deps.setSessionCookie,
|
||||||
|
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
|
||||||
|
parseCookies: deps.parseCookies,
|
||||||
|
hashSessionToken: deps.hashSessionToken,
|
||||||
|
sessionCookieName: deps.sessionCookieName,
|
||||||
|
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||||
});
|
});
|
||||||
|
|
||||||
registerUsersRoutes(app, {
|
registerUsersRoutes(app, {
|
||||||
@@ -93,7 +117,8 @@ function registerAuthAndAccountRoutes(app, deps) {
|
|||||||
validatePasswordStrength: deps.validatePasswordStrength,
|
validatePasswordStrength: deps.validatePasswordStrength,
|
||||||
readArrayField: deps.readArrayField,
|
readArrayField: deps.readArrayField,
|
||||||
rbacData: deps.rbacData,
|
rbacData: deps.rbacData,
|
||||||
requirePermission: deps.requirePermission
|
requirePermission: deps.requirePermission,
|
||||||
|
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +137,8 @@ function registerSignageRoutes(app, deps) {
|
|||||||
notifyPlayerScreens: deps.notifyPlayerScreens,
|
notifyPlayerScreens: deps.notifyPlayerScreens,
|
||||||
broadcastDashboardState: deps.broadcastDashboardState,
|
broadcastDashboardState: deps.broadcastDashboardState,
|
||||||
getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage,
|
getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage,
|
||||||
requirePermission: deps.requirePermission
|
requirePermission: deps.requirePermission,
|
||||||
|
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||||
});
|
});
|
||||||
|
|
||||||
registerManageRoutes(app, {
|
registerManageRoutes(app, {
|
||||||
@@ -128,7 +154,8 @@ function registerSignageRoutes(app, deps) {
|
|||||||
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
||||||
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
|
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
|
||||||
forwardPlayerCommandToDevice: deps.playerActionService.forwardPlayerCommandToDevice,
|
forwardPlayerCommandToDevice: deps.playerActionService.forwardPlayerCommandToDevice,
|
||||||
requirePermission: deps.requirePermission
|
requirePermission: deps.requirePermission,
|
||||||
|
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAnnouncementRoutes(app, {
|
registerAnnouncementRoutes(app, {
|
||||||
@@ -167,7 +194,8 @@ function registerSettingsAndContentRoutes(app, deps) {
|
|||||||
readArrayField: deps.readArrayField,
|
readArrayField: deps.readArrayField,
|
||||||
permissions: PERMISSIONS,
|
permissions: PERMISSIONS,
|
||||||
normalizePermissionKeys: normalizePermissionKeys,
|
normalizePermissionKeys: normalizePermissionKeys,
|
||||||
requirePermission: deps.requirePermission
|
requirePermission: deps.requirePermission,
|
||||||
|
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||||
});
|
});
|
||||||
|
|
||||||
registerContentRoutes(app, {
|
registerContentRoutes(app, {
|
||||||
@@ -193,7 +221,8 @@ function registerSettingsAndContentRoutes(app, deps) {
|
|||||||
getSlideDeleteBlockMessage: deps.playerActionService.getSlideDeleteBlockMessage,
|
getSlideDeleteBlockMessage: deps.playerActionService.getSlideDeleteBlockMessage,
|
||||||
getTemplateDeleteBlockMessage: deps.playerActionService.getTemplateDeleteBlockMessage,
|
getTemplateDeleteBlockMessage: deps.playerActionService.getTemplateDeleteBlockMessage,
|
||||||
getCanvasSizeDeleteBlockMessage: deps.playerActionService.getCanvasSizeDeleteBlockMessage,
|
getCanvasSizeDeleteBlockMessage: deps.playerActionService.getCanvasSizeDeleteBlockMessage,
|
||||||
requirePermission: deps.requirePermission
|
requirePermission: deps.requirePermission,
|
||||||
|
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||||
});
|
});
|
||||||
|
|
||||||
registerRootDataSourceRoutes(app, {
|
registerRootDataSourceRoutes(app, {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Settings page renderer for the About page.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { renderView } = require('../../../view');
|
||||||
|
const packageLock = require('#root/package-lock.json');
|
||||||
|
const tinyMcePackage = require('#root/src/web/public/vendor/tinymce/package.json');
|
||||||
|
|
||||||
|
function readVersionFromLockfile(packageName) {
|
||||||
|
const entry = packageLock && packageLock.packages ? packageLock.packages[`node_modules/${packageName}`] : null;
|
||||||
|
return entry && entry.version ? String(entry.version) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPackageVersion(packageName) {
|
||||||
|
const lockedVersion = readVersionFromLockfile(packageName);
|
||||||
|
if (lockedVersion) {
|
||||||
|
return lockedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packageJsonPath = require.resolve(`${packageName}/package.json`);
|
||||||
|
return String(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version || '');
|
||||||
|
} catch (_error) {
|
||||||
|
try {
|
||||||
|
let currentDir = path.dirname(require.resolve(packageName));
|
||||||
|
for (;;) {
|
||||||
|
const packageJsonPath = path.join(currentDir, 'package.json');
|
||||||
|
if (fs.existsSync(packageJsonPath)) {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
if (String(parsed && parsed.name || '') === packageName) {
|
||||||
|
return String(parsed.version || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDir = path.dirname(currentDir);
|
||||||
|
if (parentDir === currentDir) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
currentDir = parentDir;
|
||||||
|
}
|
||||||
|
} catch (_error2) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAdminLteVersion() {
|
||||||
|
const cssPath = path.join(__dirname, '../../../public/adminlte/css/adminlte.min.css');
|
||||||
|
const cssText = fs.readFileSync(cssPath, 'utf8');
|
||||||
|
const match = cssText.match(/AdminLTE v([0-9]+\.[0-9]+\.[0-9]+)/i);
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLibraryList() {
|
||||||
|
return [
|
||||||
|
{ name: 'AdminLTE', version: readAdminLteVersion(), usage: 'Admin dashboard layout and components', license: 'MIT', url: 'https://adminlte.io/' },
|
||||||
|
{ name: 'Animate.css', version: readPackageVersion('animate.css'), usage: 'Reusable animation classes', license: 'MIT', url: 'https://animate.style/' },
|
||||||
|
{ name: 'Bootstrap Icons', version: readPackageVersion('bootstrap-icons'), usage: 'Icon set used throughout the UI', license: 'MIT', url: 'https://icons.getbootstrap.com/' },
|
||||||
|
{ name: 'Cropper.js', version: readPackageVersion('cropperjs'), usage: 'Image cropping in the slide editor', license: 'MIT', url: 'https://github.com/fengyuanchen/cropperjs' },
|
||||||
|
{ name: 'Express', version: readPackageVersion('express'), usage: 'Web server and routing', license: 'MIT', url: 'https://expressjs.com/' },
|
||||||
|
{ name: 'Handlebars', version: readPackageVersion('handlebars'), usage: 'Server-side view rendering', license: 'MIT', url: 'https://handlebarsjs.com/' },
|
||||||
|
{ name: 'hls.js', version: readPackageVersion('hls.js'), usage: 'HTTP Live Streaming playback', license: 'Apache-2.0', url: 'https://github.com/video-dev/hls.js' },
|
||||||
|
{ name: 'Multer', version: readPackageVersion('multer'), usage: 'Multipart file uploads', license: 'MIT', url: 'https://github.com/expressjs/multer' },
|
||||||
|
{ name: 'MySQL2', version: readPackageVersion('mysql2'), usage: 'Database access layer', license: 'MIT', url: 'https://github.com/sidorares/node-mysql2' },
|
||||||
|
{ name: 'Sharp', version: readPackageVersion('sharp'), usage: 'Image processing and transforms', license: 'Apache-2.0', url: 'https://sharp.pixelplumbing.com/' },
|
||||||
|
{ name: 'TinyMCE', version: tinyMcePackage.version, usage: 'Rich text editing for content fields', license: 'GPL-2.0-or-later', url: 'https://www.tiny.cloud/' },
|
||||||
|
{ name: 'ws', version: readPackageVersion('ws'), usage: 'WebSocket transport', license: 'MIT', url: 'https://github.com/websockets/ws' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function renderAboutPage(currentUser) {
|
||||||
|
return renderView('settings/about/index', {
|
||||||
|
title: 'About',
|
||||||
|
active: 'about',
|
||||||
|
currentUser: currentUser || null,
|
||||||
|
repositoryUrl: 'https://git.lzstealth.com/LZStealth/pulse-signage.git',
|
||||||
|
libraries: buildLibraryList(),
|
||||||
|
scripts: []
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.buildLibraryList = buildLibraryList;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Settings route registration for the About page.
|
||||||
|
|
||||||
|
module.exports = function registerAboutRoutes(app, deps) {
|
||||||
|
const pages = deps.pages;
|
||||||
|
|
||||||
|
if (!pages) {
|
||||||
|
throw new Error('registerAboutRoutes requires pages.');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/settings/about', function (req, res) {
|
||||||
|
res.send(pages.renderAboutPage(req.currentUser));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
const { renderView } = require('#src/web/view');
|
||||||
|
const { hasPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||||
|
const { AUDIT_CATEGORY_KEYS } = require('#src/data/audit-log');
|
||||||
|
const { buildPagination } = require('#src/web/lib/pagination');
|
||||||
|
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
const SORTED_AUDIT_CATEGORY_KEYS = AUDIT_CATEGORY_KEYS.slice().sort(function (left, right) {
|
||||||
|
return String(left).localeCompare(String(right));
|
||||||
|
});
|
||||||
|
|
||||||
|
function requireAuditLogAccess(setAuthMessageCookie) {
|
||||||
|
return function (req, res, next) {
|
||||||
|
if (!req.currentUser) {
|
||||||
|
if (typeof setAuthMessageCookie === 'function') {
|
||||||
|
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||||
|
}
|
||||||
|
return res.redirect('/login');
|
||||||
|
}
|
||||||
|
if (hasPermission(req.currentUser, 'audit-log.read')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||||
|
error.statusCode = 403;
|
||||||
|
error.expose = true;
|
||||||
|
return next(error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuditExportAccess(setAuthMessageCookie) {
|
||||||
|
return function (req, res, next) {
|
||||||
|
if (!req.currentUser) {
|
||||||
|
if (typeof setAuthMessageCookie === 'function') setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||||
|
return res.redirect('/login');
|
||||||
|
}
|
||||||
|
if (hasPermission(req.currentUser, 'audit-log.allow')) return next();
|
||||||
|
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||||
|
error.statusCode = 403;
|
||||||
|
error.expose = true;
|
||||||
|
return next(error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value) {
|
||||||
|
let text = String(value === undefined || value === null ? '' : value);
|
||||||
|
if (/^[=+\-@]/.test(text)) text = "'" + text;
|
||||||
|
return '"' + text.replace(/"/g, '""').replace(/\r?\n/g, ' ') + '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value) {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAuditValue(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value === undefined) {
|
||||||
|
return 'undefined';
|
||||||
|
}
|
||||||
|
if (value === null || typeof value !== 'object') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrimitiveArray(value) {
|
||||||
|
return Array.isArray(value) && value.every(function (item) {
|
||||||
|
return item === null || typeof item !== 'object';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingAuditValue(value) {
|
||||||
|
return value === null || value === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAuditChangeRows(previousValue, nextValue, path, rows) {
|
||||||
|
if (isMissingAuditValue(previousValue) && isMissingAuditValue(nextValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMissingAuditValue(previousValue)) {
|
||||||
|
rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(nextValue) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMissingAuditValue(nextValue)) {
|
||||||
|
rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(previousValue), to: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPrimitiveArray(previousValue) && isPrimitiveArray(nextValue)) {
|
||||||
|
const previousItems = new Set(previousValue.map(function (item) { return JSON.stringify(item); }));
|
||||||
|
const nextItems = new Set(nextValue.map(function (item) { return JSON.stringify(item); }));
|
||||||
|
const removedItems = previousValue.filter(function (item) { return !nextItems.has(JSON.stringify(item)); });
|
||||||
|
const addedItems = nextValue.filter(function (item) { return !previousItems.has(JSON.stringify(item)); });
|
||||||
|
if (removedItems.length) {
|
||||||
|
rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(removedItems), to: '' });
|
||||||
|
}
|
||||||
|
if (addedItems.length) {
|
||||||
|
rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(addedItems) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(previousValue) === JSON.stringify(nextValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(previousValue) && isRecord(nextValue)) {
|
||||||
|
const keys = new Set(Object.keys(previousValue).concat(Object.keys(nextValue)));
|
||||||
|
keys.forEach(function (key) {
|
||||||
|
collectAuditChangeRows(previousValue[key], nextValue[key], path ? path + '.' + key : key, rows);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(previousValue) && Array.isArray(nextValue) && previousValue.length === nextValue.length && previousValue.some(isRecord)) {
|
||||||
|
for (let index = 0; index < previousValue.length; index += 1) {
|
||||||
|
collectAuditChangeRows(previousValue[index], nextValue[index], path + '[' + index + ']', rows);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
path: path,
|
||||||
|
direction: 'changed',
|
||||||
|
from: formatAuditValue(previousValue),
|
||||||
|
to: formatAuditValue(nextValue)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuditDetailView(details) {
|
||||||
|
if (!isRecord(details) || !isRecord(details.changes)) {
|
||||||
|
return { hasChanges: false, changeRows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
Object.keys(details.changes).forEach(function (key) {
|
||||||
|
const change = details.changes[key];
|
||||||
|
if (isRecord(change) && Object.prototype.hasOwnProperty.call(change, 'from') && Object.prototype.hasOwnProperty.call(change, 'to')) {
|
||||||
|
collectAuditChangeRows(change.from, change.to, key, rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { hasChanges: rows.length > 0, changeRows: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAuditRow(row, formatDashboardDate) {
|
||||||
|
let details = '';
|
||||||
|
let parsedDetails = null;
|
||||||
|
if (row.details_json) {
|
||||||
|
try {
|
||||||
|
parsedDetails = typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json;
|
||||||
|
details = JSON.stringify(parsedDetails);
|
||||||
|
} catch (_error) {
|
||||||
|
details = String(row.details_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const occurredAt = new Date(row.occurred_at);
|
||||||
|
return Object.assign({}, row, {
|
||||||
|
occurredAtValue: Number.isNaN(occurredAt.getTime()) ? '' : occurredAt.toISOString(),
|
||||||
|
occurredAtLabel: formatDashboardDate(row.occurred_at),
|
||||||
|
actorLabel: row.actor_name || row.actor_username || 'System',
|
||||||
|
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
|
||||||
|
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
|
||||||
|
detailsDisplay: details,
|
||||||
|
auditDetailView: buildAuditDetailView(parsedDetails)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAuditRows(rows, req, formatDashboardDate) {
|
||||||
|
const category = String(req.query.category || '').trim().toLowerCase();
|
||||||
|
const eventType = String(req.query.event_type || '').trim().toLowerCase();
|
||||||
|
const search = getSearchQuery(req);
|
||||||
|
const requestedSort = getSortQuery(req);
|
||||||
|
const sort = requestedSort || 'time';
|
||||||
|
const direction = requestedSort ? getSortDirectionQuery(req) : 'desc';
|
||||||
|
const mappedRows = (rows || []).map(function (row) { return mapAuditRow(row, formatDashboardDate); });
|
||||||
|
const filteredRows = sortRows(mappedRows.filter(function (event) {
|
||||||
|
return (!category || event.category === category) && (!eventType || event.event_type === eventType);
|
||||||
|
}).filter(createSearchMatcher(search, ['category', 'event_type', 'actorLabel', 'targetLabelDisplay', 'ip_address', 'user_agent'])), function (event) {
|
||||||
|
if (sort === 'event') return event.eventLabel;
|
||||||
|
if (sort === 'actor') return event.actorLabel;
|
||||||
|
if (sort === 'target') return event.targetLabelDisplay;
|
||||||
|
if (sort === 'category') return event.category;
|
||||||
|
return event.occurred_at;
|
||||||
|
}, direction);
|
||||||
|
return { rows: filteredRows, category: category, eventType: eventType, search: search, sort: sort, direction: direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuditRows(pool) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT e.id, e.occurred_at, e.category, e.event_type, e.target_type, e.target_id,
|
||||||
|
e.target_label, e.ip_address, e.user_agent, e.details_json,
|
||||||
|
u.name AS actor_name, u.username AS actor_username
|
||||||
|
FROM o_audit_events e
|
||||||
|
LEFT JOIN a_users u ON u.id = e.actor_user_id
|
||||||
|
ORDER BY e.occurred_at DESC, e.id DESC`, []
|
||||||
|
);
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function registerAuditLogRoutes(app, deps) {
|
||||||
|
const pool = deps.pool;
|
||||||
|
const formatDashboardDate = deps.formatDashboardDate;
|
||||||
|
if (!pool || typeof formatDashboardDate !== 'function') {
|
||||||
|
throw new Error('registerAuditLogRoutes requires pool and formatDashboardDate.');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/settings/audit-log', requireAuditLogAccess(deps.setAuthMessageCookie), async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const category = String(req.query.category || '').trim().toLowerCase();
|
||||||
|
const eventType = String(req.query.event_type || '').trim().toLowerCase();
|
||||||
|
const search = getSearchQuery(req);
|
||||||
|
const sort = getSortQuery(req);
|
||||||
|
const direction = getSortDirectionQuery(req);
|
||||||
|
const filtered = filterAuditRows(await fetchAuditRows(pool), req, formatDashboardDate);
|
||||||
|
const filteredEvents = filtered.rows;
|
||||||
|
const eventTypes = Array.from(new Set(filtered.rows.map(function (event) {
|
||||||
|
return String(event.event_type || '').trim();
|
||||||
|
}).filter(Boolean))).sort();
|
||||||
|
const exportQuery = new URLSearchParams({
|
||||||
|
category: filtered.category,
|
||||||
|
event_type: filtered.eventType,
|
||||||
|
search: filtered.search,
|
||||||
|
sort: filtered.sort,
|
||||||
|
direction: filtered.direction
|
||||||
|
}).toString();
|
||||||
|
const pagination = buildPagination(filteredEvents.length, parsePageNumber(req.query.page), 'page', {
|
||||||
|
search: search,
|
||||||
|
sort: sort,
|
||||||
|
direction: direction,
|
||||||
|
category: category,
|
||||||
|
event_type: eventType
|
||||||
|
}, PAGE_SIZE, 'events', 'Audit event pages');
|
||||||
|
const startIndex = (pagination.currentPage - 1) * PAGE_SIZE;
|
||||||
|
const events = filteredEvents.slice(startIndex, startIndex + PAGE_SIZE);
|
||||||
|
res.send(renderView('settings/audit-log/index', {
|
||||||
|
title: 'Audit log',
|
||||||
|
active: 'audit-log',
|
||||||
|
currentUser: req.currentUser,
|
||||||
|
events: events,
|
||||||
|
categories: SORTED_AUDIT_CATEGORY_KEYS,
|
||||||
|
eventTypes: eventTypes,
|
||||||
|
selectedCategory: category,
|
||||||
|
selectedEventType: eventType,
|
||||||
|
search: search,
|
||||||
|
sort: sort,
|
||||||
|
direction: direction,
|
||||||
|
pagination: pagination,
|
||||||
|
exportUrl: '/settings/audit-log/export' + (exportQuery ? '?' + exportQuery : '')
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/settings/audit-log/export', requireAuditExportAccess(deps.setAuthMessageCookie), async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const filtered = filterAuditRows(await fetchAuditRows(pool), req, formatDashboardDate);
|
||||||
|
const lines = [
|
||||||
|
['Occurred At (UTC)', 'Category', 'Event Type', 'Actor', 'Target Type', 'Target ID', 'Target', 'IP Address', 'User Agent', 'Details'].map(csvCell).join(',')
|
||||||
|
];
|
||||||
|
filtered.rows.forEach(function (event) {
|
||||||
|
lines.push([
|
||||||
|
event.occurredAtValue,
|
||||||
|
event.category,
|
||||||
|
event.event_type,
|
||||||
|
event.actorLabel,
|
||||||
|
event.target_type,
|
||||||
|
event.target_id,
|
||||||
|
event.targetLabelDisplay,
|
||||||
|
event.ip_address,
|
||||||
|
event.user_agent,
|
||||||
|
event.detailsDisplay
|
||||||
|
].map(csvCell).join(','));
|
||||||
|
});
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="pulse-signage-audit-log.csv"');
|
||||||
|
res.send('\ufeff' + lines.join('\r\n') + '\r\n');
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -138,7 +138,7 @@ module.exports = function registerSettingsRoutes(app, deps) {
|
|||||||
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, async function (req, res, next) {
|
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const ran = await backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
|
const ran = await backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
|
||||||
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
|
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Task run queued.' : 'Unable to run that task.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Visual-only application settings page.
|
||||||
|
|
||||||
|
const { renderView } = require('../../view');
|
||||||
|
|
||||||
|
module.exports = function renderSettingsPage(settings, suggestedAnnouncementIcons, currentUser, message, mediaSettings, diagnostics) {
|
||||||
|
const iconOptions = Array.isArray(suggestedAnnouncementIcons) ? suggestedAnnouncementIcons : [];
|
||||||
|
return renderView('settings/system/index', {
|
||||||
|
title: 'Settings',
|
||||||
|
active: 'settings',
|
||||||
|
currentUser: currentUser || null,
|
||||||
|
message: message || '',
|
||||||
|
settings: settings || {},
|
||||||
|
suggestedAnnouncementIcons: iconOptions,
|
||||||
|
defaultAnnouncementIconSuggestions: iconOptions.filter(function (option) { return option.isSelected; }),
|
||||||
|
mediaSettings: mediaSettings || {},
|
||||||
|
diagnostics: diagnostics || {},
|
||||||
|
scripts: ['js/icon-picker.js', 'js/settings/settings-page.js']
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -53,17 +53,17 @@ function buildPermissionSections(permissionGroups) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
section.groups.sort(function (left, right) {
|
section.groups.sort(function (left, right) {
|
||||||
const leftIndex = toSortIndex(left.resourceIndex);
|
|
||||||
const rightIndex = toSortIndex(right.resourceIndex);
|
|
||||||
if (leftIndex !== rightIndex) {
|
|
||||||
return leftIndex - rightIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leftOrder = toSortIndex(left.resourceOrder);
|
const leftOrder = toSortIndex(left.resourceOrder);
|
||||||
const rightOrder = toSortIndex(right.resourceOrder);
|
const rightOrder = toSortIndex(right.resourceOrder);
|
||||||
if (leftOrder !== rightOrder) {
|
if (leftOrder !== rightOrder) {
|
||||||
return leftOrder - rightOrder;
|
return leftOrder - rightOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const leftIndex = toSortIndex(left.resourceIndex);
|
||||||
|
const rightIndex = toSortIndex(right.resourceIndex);
|
||||||
|
if (leftIndex !== rightIndex) {
|
||||||
|
return leftIndex - rightIndex;
|
||||||
|
}
|
||||||
return String(left.title || '').localeCompare(String(right.title || ''));
|
return String(left.title || '').localeCompare(String(right.title || ''));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,17 +77,17 @@ function buildPermissionSections(permissionGroups) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
sections.sort(function (left, right) {
|
sections.sort(function (left, right) {
|
||||||
const leftIndex = toSortIndex(left.sourceIndex);
|
|
||||||
const rightIndex = toSortIndex(right.sourceIndex);
|
|
||||||
if (leftIndex !== rightIndex) {
|
|
||||||
return leftIndex - rightIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leftOrder = toSortIndex(left.sectionOrder);
|
const leftOrder = toSortIndex(left.sectionOrder);
|
||||||
const rightOrder = toSortIndex(right.sectionOrder);
|
const rightOrder = toSortIndex(right.sectionOrder);
|
||||||
if (leftOrder !== rightOrder) {
|
if (leftOrder !== rightOrder) {
|
||||||
return leftOrder - rightOrder;
|
return leftOrder - rightOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const leftIndex = toSortIndex(left.sourceIndex);
|
||||||
|
const rightIndex = toSortIndex(right.sourceIndex);
|
||||||
|
if (leftIndex !== rightIndex) {
|
||||||
|
return leftIndex - rightIndex;
|
||||||
|
}
|
||||||
return String(left.title || '').localeCompare(String(right.title || ''));
|
return String(left.title || '').localeCompare(String(right.title || ''));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,9 +101,10 @@ function buildRbacAddViewModel(message, currentUser, formValues, permissionGroup
|
|||||||
isEdit: false,
|
isEdit: false,
|
||||||
usersPresent: true,
|
usersPresent: true,
|
||||||
formId: 'role-create-form',
|
formId: 'role-create-form',
|
||||||
formAction: '/rbac',
|
formAction: '/settings/roles',
|
||||||
footerSaveUrl: '/rbac',
|
footerSaveUrl: '/settings/roles',
|
||||||
footerCancelUrl: '/rbac',
|
footerCancelUrl: '/settings/roles',
|
||||||
|
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
|
||||||
footerDeleteUrl: '',
|
footerDeleteUrl: '',
|
||||||
footerDeleteDisabled: true,
|
footerDeleteDisabled: true,
|
||||||
footerDeleteTitle: 'Delete is available after the role has been created.',
|
footerDeleteTitle: 'Delete is available after the role has been created.',
|
||||||
@@ -116,37 +117,44 @@ function buildRbacAddViewModel(message, currentUser, formValues, permissionGroup
|
|||||||
permissionSections: buildPermissionSections(permissionGroups),
|
permissionSections: buildPermissionSections(permissionGroups),
|
||||||
users: users || [],
|
users: users || [],
|
||||||
pagination: pagination || null,
|
pagination: pagination || null,
|
||||||
roleEditBasePath: '/rbac/new',
|
roleEditBasePath: '/settings/roles/new',
|
||||||
scripts: ['js/rbac-permissions.js'],
|
scripts: ['js/rbac-permissions.js'],
|
||||||
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-redirect="response-url" data-async-save-new-url="/rbac/new"'
|
formAttrs: 'data-async-save data-async-save-close-url="/settings/roles" data-async-save-new-redirect="response-url" data-async-save-new-url="/settings/roles/new"'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination) {
|
function buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination) {
|
||||||
|
const roleKey = String(role && role.role_key || '').trim();
|
||||||
|
const isProtectedRole = roleKey === 'super-admin' || roleKey === 'administrators';
|
||||||
|
const deleteDisabled = isProtectedRole || Boolean(role && role.inUse);
|
||||||
return {
|
return {
|
||||||
title: 'Edit role',
|
title: 'Edit role',
|
||||||
active: 'rbac',
|
active: 'rbac',
|
||||||
isEdit: true,
|
isEdit: true,
|
||||||
usersPresent: true,
|
usersPresent: true,
|
||||||
formId: 'role-edit-form',
|
formId: 'role-edit-form',
|
||||||
formAction: '/rbac/' + Number(role && role.id),
|
formAction: '/settings/roles/' + Number(role && role.id),
|
||||||
footerSaveUrl: '/rbac/' + Number(role && role.id),
|
footerSaveUrl: '/settings/roles/' + Number(role && role.id),
|
||||||
footerCancelUrl: '/rbac',
|
footerCancelUrl: '/settings/roles',
|
||||||
footerDeleteUrl: '/rbac/' + Number(role && role.id) + '/delete',
|
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
|
||||||
footerDeleteDisabled: Boolean(role && role.inUse),
|
footerDeleteUrl: '/settings/roles/' + Number(role && role.id) + '/delete',
|
||||||
footerDeleteTitle: role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : '',
|
footerDeleteDisabled: deleteDisabled,
|
||||||
|
footerDeleteConfirmMessage: 'Delete ' + String(role && role.name || 'this role') + '?',
|
||||||
|
footerDeleteTitle: isProtectedRole
|
||||||
|
? 'The built-in Super Admin role cannot be deleted.'
|
||||||
|
: (role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : ''),
|
||||||
footerShowDelete: true,
|
footerShowDelete: true,
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
role: role,
|
role: Object.assign({}, role, { isProtected: isProtectedRole }),
|
||||||
inUse: Boolean(role && role.inUse),
|
inUse: deleteDisabled,
|
||||||
permissionGroups: permissionGroups || [],
|
permissionGroups: permissionGroups || [],
|
||||||
permissionSections: buildPermissionSections(permissionGroups),
|
permissionSections: buildPermissionSections(permissionGroups),
|
||||||
users: users || [],
|
users: users || [],
|
||||||
pagination: pagination || null,
|
pagination: pagination || null,
|
||||||
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
|
roleEditBasePath: '/settings/roles/' + Number(role && role.id) + '/edit',
|
||||||
scripts: ['js/rbac-permissions.js'],
|
scripts: ['js/rbac-permissions.js'],
|
||||||
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-url="/rbac/new"'
|
formAttrs: 'data-async-save data-async-save-close-url="/settings/roles" data-async-save-new-url="/settings/roles/new"'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user