From c6ec8f6ad9086ed6ca64f104e9e929ce265c140e Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Tue, 14 Jul 2026 13:56:54 +0100 Subject: [PATCH] docker updates. --- .gitea/workflows/docker-publish.yml | 46 ++++++++++++ README.md | 14 +++- docker-compose.yml | 23 +++--- package.json | 6 +- src/db.js | 111 ++++++++++++++++------------ src/player/player-page.script.html | 17 ++++- 6 files changed, 156 insertions(+), 61 deletions(-) create mode 100644 .gitea/workflows/docker-publish.yml diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml new file mode 100644 index 0000000..2a691b1 --- /dev/null +++ b/.gitea/workflows/docker-publish.yml @@ -0,0 +1,46 @@ +name: Publish Docker Image + +on: + push: + branches: + - main + tags: + - 'v*' + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to container registry + uses: docker/login-action@v3 + with: + registry: git.lzstealth.com + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: git.lzstealth.com/LZStealth/pulse-signage + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=tag + type=sha + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/README.md b/README.md index c77045e..0b15e40 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,19 @@ The app reads its settings from environment variables. The recommended way to run the project is with Docker Compose. ```bash -docker-compose up --build +npm run docker:up ``` +The main Compose file now uses the published Docker image and still reads values from the repository `.env` file. `DB_HOST` comes from that file for the app containers, so you can point the web UI and player at an external database host or change it to `mysql` if you want to use the bundled MySQL service. + +If you only want to test the image build from the current checkout without using Compose, run: + +```bash +npm run docker:build +``` + +To publish the Docker image to the Gitea container registry, push to `main` or create a `v*` tag. The workflow in [.gitea/workflows/docker-publish.yml](.gitea/workflows/docker-publish.yml) builds `git.lzstealth.com/LZStealth/pulse-signage` and pushes `latest`, `sha`, and tagged releases. + This starts: - the web admin app on port `3000` @@ -79,6 +89,8 @@ This starts: The web app and player app share the same uploaded media volume, so files uploaded in the admin UI are available to the player. +If you want to stop the stack later, run `npm run docker:down`. To follow logs, run `npm run docker:logs`. + ## Important Notes - The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`. diff --git a/docker-compose.yml b/docker-compose.yml index f46c7e6..278e188 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,10 @@ -version: "3.9" - services: webui: - build: - context: . - dockerfile: Dockerfile + image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/LZStealth/pulse-signage:latest} container_name: signage-webui restart: unless-stopped + env_file: + - .env ports: - "3000:3000" environment: @@ -20,6 +18,12 @@ services: PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:3001} PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001} UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads} + SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14} + DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000} + DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin} + DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin} + DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin} + PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000} volumes: - uploads:/app/uploads command: ["npm", "run", "start:webui"] @@ -30,11 +34,11 @@ services: - signage-net player: - build: - context: . - dockerfile: Dockerfile + image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/LZStealth/pulse-signage:latest} container_name: signage-player restart: unless-stopped + env_file: + - .env ports: - "3001:3001" environment: @@ -59,6 +63,8 @@ services: image: mysql:8.4 container_name: signage-mysql restart: unless-stopped + env_file: + - .env ports: - "3306:3306" environment: @@ -67,7 +73,6 @@ services: MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password} command: - - --default-authentication-plugin=mysql_native_password - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci volumes: diff --git a/package.json b/package.json index 0401207..d5b6ac3 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,11 @@ "start:webui": "node -r dotenv/config src/webui.js", "start:player": "node -r dotenv/config src/player.js", "dev:webui": "nodemon -r dotenv/config src/webui.js", - "dev:player": "nodemon -r dotenv/config src/player.js" + "dev:player": "nodemon -r dotenv/config src/player.js", + "docker:build": "docker build -t pulse-signage:test .", + "docker:up": "docker-compose up -d", + "docker:down": "docker-compose down", + "docker:logs": "docker-compose logs -f --tail=100" }, "dependencies": { "dotenv": "^17.4.2", diff --git a/src/db.js b/src/db.js index 9b2ea7e..a43982a 100644 --- a/src/db.js +++ b/src/db.js @@ -14,6 +14,23 @@ function createPool() { }); } +async function addColumnIfMissing(pool, tableName, columnName, columnDefinition) { + const [rows] = await pool.query( + `SELECT COUNT(*) AS column_count + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ? + AND column_name = ?`, + [tableName, columnName] + ); + + if (rows.length && Number(rows[0].column_count) > 0) { + return; + } + + await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`); +} + async function ensureSchema(pool) { await pool.query(` CREATE TABLE IF NOT EXISTS canvas_sizes ( @@ -26,9 +43,9 @@ async function ensureSchema(pool) { UNIQUE KEY uq_canvas_sizes_dimensions (width, height) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query('ALTER TABLE canvas_sizes ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); - await pool.query('ALTER TABLE canvas_sizes ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at'); - await pool.query('ALTER TABLE canvas_sizes ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at'); + await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'canvas_sizes', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'canvas_sizes', 'modified_by', 'INT NULL'); await pool.query(` CREATE TABLE IF NOT EXISTS playlists ( @@ -39,10 +56,10 @@ async function ensureSchema(pool) { modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query('ALTER TABLE playlists ADD COLUMN IF NOT EXISTS fade_between_slides TINYINT(1) NOT NULL DEFAULT 0 AFTER name'); - await pool.query('ALTER TABLE playlists ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); - await pool.query('ALTER TABLE playlists ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at'); - await pool.query('ALTER TABLE playlists ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at'); + await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0'); + await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'playlists', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'playlists', 'modified_by', 'INT NULL'); await pool.query(` CREATE TABLE IF NOT EXISTS slide_templates ( @@ -55,11 +72,11 @@ async function ensureSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query('ALTER TABLE slide_templates ADD COLUMN IF NOT EXISTS canvas_size_id INT NULL AFTER name'); - await pool.query('ALTER TABLE slide_templates ADD COLUMN IF NOT EXISTS background_image_path VARCHAR(512) NULL'); - await pool.query('ALTER TABLE slide_templates ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); - await pool.query('ALTER TABLE slide_templates ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at'); - await pool.query('ALTER TABLE slide_templates ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at'); + await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL'); + await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL'); + await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'slide_templates', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'slide_templates', 'modified_by', 'INT NULL'); await pool.query(` INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES @@ -103,10 +120,10 @@ async function ensureSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query('ALTER TABLE slide_template_regions ADD COLUMN IF NOT EXISTS font_family VARCHAR(100) NULL AFTER label'); - await pool.query('ALTER TABLE slide_template_regions ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); - await pool.query('ALTER TABLE slide_template_regions ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at'); - await pool.query('ALTER TABLE slide_template_regions ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at'); + await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL'); + await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'slide_template_regions', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'slide_template_regions', 'modified_by', 'INT NULL'); await pool.query(` CREATE TABLE IF NOT EXISTS slides ( @@ -122,14 +139,14 @@ async function ensureSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS body TEXT NULL AFTER title'); - await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS template_id INT NULL AFTER body'); - await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS content_json JSON NULL AFTER template_id'); - await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS media_path VARCHAR(512) NULL AFTER content_json'); - await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS media_type VARCHAR(100) NULL AFTER media_path'); - await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); - await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at'); - await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at'); + await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL'); + await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL'); + await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL'); + await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL'); + await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL'); + await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'slides', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'slides', 'modified_by', 'INT NULL'); await pool.query(` CREATE TABLE IF NOT EXISTS playlist_slides ( @@ -151,16 +168,16 @@ async function ensureSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS duration_seconds INT NOT NULL DEFAULT 10 AFTER position'); - await pool.query("ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always' AFTER duration_seconds"); - await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_start_datetime DATETIME NULL AFTER schedule_mode'); - await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_end_datetime DATETIME NULL AFTER schedule_start_datetime'); - await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_start_time TIME NULL AFTER schedule_end_datetime'); - await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_end_time TIME NULL AFTER schedule_start_time'); - await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_days_json JSON NULL AFTER schedule_end_time'); - await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); - await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at'); - await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at'); + await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10'); + await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'"); + await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL'); + await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL'); + await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_time', 'TIME NULL'); + await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL'); + await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL'); + await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'playlist_slides', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'playlist_slides', 'modified_by', 'INT NULL'); await pool.query(` CREATE TABLE IF NOT EXISTS screens ( @@ -174,10 +191,10 @@ async function ensureSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query('ALTER TABLE screens ADD COLUMN IF NOT EXISTS playlist_id INT NULL AFTER slug'); - await pool.query('ALTER TABLE screens ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); - await pool.query('ALTER TABLE screens ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at'); - await pool.query('ALTER TABLE screens ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at'); + await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL'); + await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'screens', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'screens', 'modified_by', 'INT NULL'); await pool.query(` CREATE TABLE IF NOT EXISTS users ( @@ -191,13 +208,13 @@ async function ensureSchema(pool) { modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS name VARCHAR(255) NULL AFTER id'); - await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash CHAR(64) NOT NULL AFTER username'); - await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_salt VARCHAR(64) NOT NULL AFTER password_hash'); - await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_iterations INT NOT NULL AFTER password_salt'); - await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); - await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at'); - await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at'); + await addColumnIfMissing(pool, 'users', 'name', 'VARCHAR(255) NULL'); + await addColumnIfMissing(pool, 'users', 'password_hash', 'CHAR(64) NOT NULL'); + await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL'); + await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL'); + await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'users', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'users', 'modified_by', 'INT NULL'); await pool.query(` CREATE TABLE IF NOT EXISTS auth_sessions ( @@ -209,8 +226,8 @@ async function ensureSchema(pool) { CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query('ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at'); - await pool.query('ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER last_used_at'); + await addColumnIfMissing(pool, 'auth_sessions', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'auth_sessions', 'modified_by', 'INT NULL'); const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users'); if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) { diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html index e112870..90da36b 100644 --- a/src/player/player-page.script.html +++ b/src/player/player-page.script.html @@ -1,6 +1,17 @@