feat: add weblink playlists, SSH player deployment, Caddy HTTPS, build player page

- Content model: add url column + is_weblink() for web page content items
- Player model: add deployment tracking fields (status, timestamps, message)
- Content blueprint: add add_weblink and add_weblink_to_playlist routes;
  weblinks auto-deleted when removed from playlist
- Players blueprint: add SSH deploy mode in add_player; weblink-aware playlist
- API blueprint: weblink URL served directly in playlist response;
  add /api/deploy/test-ssh and /api/deploy/player endpoints
- Admin blueprint: add build-player page (clone from Gitea, write base config);
  replace nginx status card with Caddy status on HTTPS config page
- caddy_manager: rewritten to generate proper HTTPS/internal-CA Caddyfiles
  and reload Caddy via admin API (/load) for live config updates
- ssh_deploy, background_tasks, player_build: new utils for SSH deployment
- background_tasks: push Flask app context into background thread so DB
  updates after deployment complete correctly
- ssh_deploy: robust install script detection with passwordless sudo injection
  (uses SSH credentials, cleaned up after install)
- Dockerfile: add git, sshpass, openssh-client, rsync
- docker-compose: switch nginx to Caddy on ports 80/443; add port 5000 for dev
- Templates: add_player deploy mode UI, weblink form in playlist/upload pages,
  build_player admin page, Caddy status on HTTPS config page
- Migrations: add_url_to_content, add_deployment_fields_to_player
- app.py: call db.create_all() on startup for schema bootstrap
- config.py: add PLAYER_CODE_DIR and PLAYER_REPO_URL settings
This commit is contained in:
2026-07-13 16:46:19 +03:00
parent ae3b82862d
commit 2af04e1db3
24 changed files with 2571 additions and 248 deletions
+4
View File
@@ -15,6 +15,10 @@ RUN apt-get update && \
libreoffice-core \ libreoffice-core \
libreoffice-impress \ libreoffice-impress \
libreoffice-writer \ libreoffice-writer \
sshpass \
openssh-client \
rsync \
git \
&& apt-get clean && \ && apt-get clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
+13
View File
@@ -42,6 +42,11 @@ def create_app(config_name=None):
# This ensures proper handling of X-Forwarded-* headers # This ensures proper handling of X-Forwarded-* headers
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
# ScriptNameFix: reads X-Script-Name header set by the umbrella nginx
# (e.g. /digiserver) so that url_for() generates correct full paths.
from app.utils.script_name_fix import ScriptNameFix
app.wsgi_app = ScriptNameFix(app.wsgi_app)
# Initialize extensions # Initialize extensions
db.init_app(app) db.init_app(app)
bcrypt.init_app(app) bcrypt.init_app(app)
@@ -71,6 +76,14 @@ def create_app(config_name=None):
register_context_processors(app) register_context_processors(app)
register_template_filters(app) register_template_filters(app)
# Portal SSO: auto-login users arriving via the umbrella nginx gateway
from app.utils.portal_sso import init_portal_sso
init_portal_sso(app)
# Ensure DB schema exists (idempotent; safe to call even with migrate)
with app.app_context():
db.create_all()
return app return app
+147 -6
View File
@@ -11,7 +11,6 @@ from app.extensions import db, bcrypt
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
from app.utils.logger import log_action from app.utils.logger import log_action
from app.utils.caddy_manager import CaddyConfigGenerator from app.utils.caddy_manager import CaddyConfigGenerator
from app.utils.nginx_config_reader import get_nginx_status
admin_bp = Blueprint('admin', __name__, url_prefix='/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -871,14 +870,10 @@ def https_config():
db.session.commit() db.session.commit()
log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)') log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)')
# Get Nginx configuration status
nginx_status = get_nginx_status()
return render_template('admin/https_config.html', return render_template('admin/https_config.html',
config=config, config=config,
is_https_active=is_https_active, is_https_active=is_https_active,
current_host=current_host, current_host=current_host)
nginx_status=nginx_status)
except Exception as e: except Exception as e:
log_action('error', f'Error loading HTTPS config page: {str(e)}') log_action('error', f'Error loading HTTPS config page: {str(e)}')
flash('Error loading HTTPS configuration page.', 'danger') flash('Error loading HTTPS configuration page.', 'danger')
@@ -1015,3 +1010,149 @@ def https_config_status():
except Exception as e: except Exception as e:
log_action('error', f'Error getting HTTPS status: {str(e)}') log_action('error', f'Error getting HTTPS status: {str(e)}')
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# ============================================================================
# Player Build & Deployment Routes
# ============================================================================
def _player_build_meta_path() -> str:
"""Path to the persisted player-build settings (in the instance folder)."""
from app.utils.player_build import BUILD_META_FILENAME
return os.path.join(current_app.instance_path, BUILD_META_FILENAME)
@admin_bp.route('/build-player', methods=['GET'])
@login_required
@admin_required
def build_player():
"""Display the 'Build player files for deployment' admin page."""
from app.utils.ssh_deploy import get_local_player_code_status
from app.utils.player_build import load_build_settings
player_code_dir = current_app.config['PLAYER_CODE_DIR']
settings = load_build_settings(_player_build_meta_path()) or {}
# Prefill server address from saved settings, else from HTTPS config.
if not settings.get('server_ip'):
https_cfg = HTTPSConfig.get_config()
if https_cfg and (https_cfg.domain or https_cfg.ip_address):
settings.setdefault('server_ip', https_cfg.domain or https_cfg.ip_address)
settings.setdefault('port', str(https_cfg.port or 443))
settings.setdefault('use_https', bool(https_cfg.https_enabled))
# Sensible defaults.
settings.setdefault('repo_url', current_app.config.get('PLAYER_REPO_URL', ''))
settings.setdefault('branch', 'main')
default_host = request.host.split(':')[0]
if default_host in ('localhost', '127.0.0.1', '') or default_host.startswith('127.'):
from app.utils.ssh_deploy import detect_server_ip
default_host = detect_server_ip() or default_host
settings.setdefault('server_ip', default_host)
settings.setdefault('port', '443')
settings.setdefault('use_https', True)
settings.setdefault('verify_ssl', False)
settings.setdefault('orientation', 'Landscape')
settings.setdefault('max_resolution', '1920x1080')
code_status = get_local_player_code_status(player_code_dir)
if code_status.get('updated'):
code_status['updated_str'] = datetime.fromtimestamp(
code_status['updated']).strftime('%Y-%m-%d %H:%M:%S')
return render_template(
'admin/build_player.html',
settings=settings,
code_status=code_status,
player_code_dir=player_code_dir,
)
@admin_bp.route('/build-player', methods=['POST'])
@login_required
@admin_required
def build_player_action():
"""Build/refresh the staged player code and/or write its base config."""
from app.utils.player_build import (
build_player_files, write_base_config, get_short_head,
save_build_settings, make_build_record,
)
player_code_dir = current_app.config['PLAYER_CODE_DIR']
action = request.form.get('action', 'build_and_config')
repo_url = request.form.get('repo_url', '').strip()
branch = request.form.get('branch', 'main').strip() or 'main'
server_ip = request.form.get('server_ip', '').strip()
port = request.form.get('port', '443').strip()
use_https = request.form.get('use_https') == 'on'
verify_ssl = request.form.get('verify_ssl') == 'on'
orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape'
max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080'
# Validation
errors = []
if action in ('build_files', 'build_and_config') and not repo_url:
errors.append('Repository URL is required to build player files.')
if action in ('save_config', 'build_and_config') and not server_ip:
errors.append('Server IP / domain is required for the player configuration.')
try:
port_num = int(port)
if port_num < 1 or port_num > 65535:
errors.append('Port must be between 1 and 65535.')
except ValueError:
errors.append('Port must be a valid number.')
if errors:
for err in errors:
flash(err, 'warning')
return redirect(url_for('admin.build_player'))
messages = []
success = True
version = None
# Step 1: build/refresh files from the repository.
if action in ('build_files', 'build_and_config'):
result = build_player_files(player_code_dir, repo_url, branch)
version = result.get('version')
messages.append(result['message'])
if not result['success']:
success = False
log_action('error', f'Player build failed by {current_user.username}: {result["message"]}')
# Step 2: write the base config (only if the previous step didn't fail).
if success and action in ('save_config', 'build_and_config'):
cfg_result = write_base_config(
player_code_dir=player_code_dir,
server_ip=server_ip,
port=port,
use_https=use_https,
verify_ssl=verify_ssl,
orientation=orientation,
max_resolution=max_resolution,
)
messages.append(cfg_result['message'])
if not cfg_result['success']:
success = False
# Persist settings so deployment uses the same server address.
if version is None:
version = get_short_head(player_code_dir)
save_build_settings(
_player_build_meta_path(),
make_build_record(
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
max_resolution=max_resolution, version=version, built_by=current_user.username,
),
)
summary = ' '.join(messages) if messages else 'No action performed.'
if success:
log_action('info', f'Player files built by {current_user.username} (version {version})')
flash(f'{summary}', 'success')
else:
flash(f'⚠️ {summary}', 'danger')
return redirect(url_for('admin.build_player'))
+126 -3
View File
@@ -3,6 +3,7 @@ from flask import Blueprint, request, jsonify, current_app
from functools import wraps from functools import wraps
from datetime import datetime, timedelta from datetime import datetime, timedelta
import secrets import secrets
import hashlib
import bcrypt import bcrypt
from typing import Optional, Dict, List from typing import Optional, Dict, List
@@ -396,9 +397,13 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
for idx, content in enumerate(content_list, start=1): for idx, content in enumerate(content_list, start=1):
# Generate full URL for content # Generate full URL for content
from flask import request as current_request from flask import request as current_request
# Get server base URL
server_base = current_request.host_url.rstrip('/') server_base = current_request.host_url.rstrip('/')
content_url = f"{server_base}/static/uploads/{content.filename}" script_root = current_request.script_root.rstrip('/')
content_url = f"{server_base}{script_root}/static/uploads/{content.filename}"
# Web links carry the page URL directly instead of a file download URL.
is_weblink = content.content_type == 'weblink'
item_url = content.url if is_weblink else content_url
playlist_data.append({ playlist_data.append({
'id': content.id, 'id': content.id,
@@ -406,7 +411,7 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
'type': content.content_type, 'type': content.content_type,
'duration': content._playlist_duration or content.duration or 10, 'duration': content._playlist_duration or content.duration or 10,
'position': content._playlist_position or idx, 'position': content._playlist_position or idx,
'url': content_url, # Full URL for downloads 'url': item_url, # Web page URL for weblinks, file download URL otherwise
'description': content.description, 'description': content.description,
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False) 'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
}) })
@@ -857,6 +862,124 @@ def receive_edited_media():
return jsonify({'error': 'Internal server error'}), 500 return jsonify({'error': 'Internal server error'}), 500
# ──────────────────────────────────────────────────────────────────────────────
# SSH/Deployment Endpoints - For player provisioning and code deployment
# ──────────────────────────────────────────────────────────────────────────────
@api_bp.route('/deploy/test-ssh', methods=['POST'])
@rate_limit(max_requests=30, window=60)
def test_ssh_connection():
"""Test SSH connection to a remote host.
Request JSON:
hostname: Target hostname or IP (required)
username: SSH username (required)
password: SSH password (required)
port: SSH port (default: 22)
Returns:
JSON with connection test result
"""
try:
from app.utils.ssh_deploy import test_ssh_connection as test_ssh
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
hostname = data.get('hostname', '').strip()
username = data.get('username', '').strip()
password = data.get('password', '').strip()
port = data.get('port', 22)
if not hostname or not username or not password:
return jsonify({'error': 'hostname, username, and password are required'}), 400
result = test_ssh(hostname, username, password, port)
log_action('info', f'SSH test for {username}@{hostname}: {result["message"]}')
return jsonify(result), 200 if result['success'] else 400
except Exception as e:
log_action('error', f'Error testing SSH connection: {str(e)}')
return jsonify({
'success': False,
'error': str(e),
'message': f'SSH test error: {str(e)}'
}), 500
@api_bp.route('/deploy/player', methods=['POST'])
@rate_limit(max_requests=20, window=60)
def deploy_player():
"""Deploy player code to a remote host via SSH.
Request JSON:
hostname: Target hostname or IP (required)
username: SSH username (required)
password: SSH password (required)
player_name: Name for the player instance (required)
port: SSH port (default: 22)
deploy_path: Deployment path on remote host
repo_url: Git repository URL
Returns:
JSON with deployment status and step details
"""
try:
from app.utils.ssh_deploy import deploy_player_to_host
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
hostname = data.get('hostname', '').strip()
username = data.get('username', '').strip()
password = data.get('password', '').strip()
player_name = data.get('player_name', '').strip()
port = data.get('port', 22)
deploy_path = data.get('deploy_path', None)
repo_url = data.get('repo_url', 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git').strip()
if not hostname or not username or not password:
return jsonify({'error': 'hostname, username, and password are required'}), 400
if not player_name:
return jsonify({'error': 'player_name is required'}), 400
scheme = request.headers.get('X-Forwarded-Proto', request.scheme)
host = request.headers.get('X-Forwarded-Host', request.host)
server_url = f"{scheme}://{host}"
api_key = hashlib.sha256(f'{player_name}:{hostname}'.encode()).hexdigest()[:32]
result = deploy_player_to_host(
hostname=hostname,
username=username,
password=password,
player_name=player_name,
repo_url=repo_url,
deploy_path=deploy_path,
port=port,
server_url=server_url,
server_api_key=api_key
)
log_action('info', f'Player deployment for {player_name} on {hostname}: success={result["success"]}')
return jsonify(result), 200 if result['success'] else 400
except Exception as e:
log_action('error', f'Error deploying player: {str(e)}')
return jsonify({
'success': False,
'error': str(e),
'message': f'Deployment error: {str(e)}',
'steps': []
}), 500
@api_bp.errorhandler(404) @api_bp.errorhandler(404)
def api_not_found(error): def api_not_found(error):
"""Handle 404 errors in API.""" """Handle 404 errors in API."""
+159 -2
View File
@@ -6,6 +6,9 @@ from werkzeug.utils import secure_filename
from typing import Optional from typing import Optional
import os import os
import threading import threading
import uuid
from datetime import datetime
from urllib.parse import urlparse
from app.extensions import db, cache from app.extensions import db, cache
from app.models import Content, Playlist, Player from app.models import Content, Playlist, Player
@@ -201,8 +204,10 @@ def manage_playlist_content(playlist_id: int):
# Get content in playlist (ordered) # Get content in playlist (ordered)
playlist_content = playlist.get_content_ordered() playlist_content = playlist.get_content_ordered()
# Get all available content not in this playlist # Get all available content not in this playlist.
all_content = Content.query.all() # Web links are created on demand per playlist, so they are not offered
# as reusable library items here.
all_content = Content.query.filter(Content.content_type != 'weblink').all()
playlist_content_ids = {c.id for c in playlist_content} playlist_content_ids = {c.id for c in playlist_content}
available_content = [c for c in all_content if c.id not in playlist_content_ids] available_content = [c for c in all_content if c.id not in playlist_content_ids]
@@ -262,6 +267,152 @@ def add_content_to_playlist(playlist_id: int):
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id)) return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
@content_bp.route('/add-weblink', methods=['POST'])
@login_required
def add_weblink():
"""Create a web link content item from the Upload Media page.
Optionally adds it directly to a playlist if playlist_id is supplied.
Returns JSON when the request carries Accept: application/json, otherwise
redirects back to the upload page.
"""
use_json = 'application/json' in request.accept_mimetypes.best or \
request.headers.get('X-Requested-With') == 'XMLHttpRequest'
try:
web_url = (request.form.get('url') or '').strip()
duration = request.form.get('duration', type=int, default=30)
description = (request.form.get('description') or '').strip() or None
playlist_id = request.form.get('playlist_id', type=int)
parsed = urlparse(web_url)
if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc:
if use_json:
return jsonify({'success': False, 'error': 'Please enter a valid http:// or https:// web address.'}), 400
flash('Please enter a valid http:// or https:// web address.', 'warning')
return redirect(url_for('content.upload_media_page'))
if not duration or duration < 1:
duration = 30
content = Content(
filename=f'weblink-{uuid.uuid4().hex[:12]}',
content_type='weblink',
url=web_url,
duration=duration,
description=description or web_url,
uploaded_at=datetime.utcnow(),
)
db.session.add(content)
db.session.flush()
if playlist_id:
playlist = Playlist.query.get(playlist_id)
if playlist:
from sqlalchemy import select, func
max_pos = db.session.execute(
select(func.max(playlist_content.c.position)).where(
playlist_content.c.playlist_id == playlist_id
)
).scalar() or 0
db.session.execute(
playlist_content.insert().values(
playlist_id=playlist_id,
content_id=content.id,
position=max_pos + 1,
duration=duration,
)
)
playlist.increment_version()
log_action('info', f'Web link "{web_url}" added to playlist "{playlist.name}"')
else:
log_action('warning', f'Web link "{web_url}" created; playlist {playlist_id} not found')
else:
log_action('info', f'Web link "{web_url}" added to media library')
db.session.commit()
cache.clear()
if use_json:
return jsonify({'success': True, 'content_id': content.id, 'message': 'Web link added successfully.'})
flash('Web link added successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding web link: {str(e)}')
if use_json:
return jsonify({'success': False, 'error': 'Failed to add web link.'}), 500
flash('Error adding web link.', 'danger')
return redirect(url_for('content.upload_media_page'))
@content_bp.route('/playlist/<int:playlist_id>/add-weblink', methods=['POST'])
@login_required
def add_weblink_to_playlist(playlist_id: int):
"""Create a web link content item and add it to the playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
web_url = (request.form.get('url') or '').strip()
duration = request.form.get('duration', type=int, default=30)
description = (request.form.get('description') or '').strip() or None
# Validate the URL: only http/https schemes are allowed (avoid file://, etc.)
parsed = urlparse(web_url)
if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc:
flash('Please enter a valid http:// or https:// web address.', 'warning')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
if duration is None or duration < 1:
duration = 30
# Create a weblink Content row. filename is a synthetic unique label
# (no file on disk); the real target lives in the url column.
content = Content(
filename=f'weblink-{uuid.uuid4().hex[:12]}',
content_type='weblink',
url=web_url,
duration=duration,
description=description or web_url,
uploaded_at=datetime.utcnow(),
)
db.session.add(content)
db.session.flush() # assign content.id
# Append to the end of the playlist
from sqlalchemy import select, func
max_pos = db.session.execute(
select(func.max(playlist_content.c.position)).where(
playlist_content.c.playlist_id == playlist_id
)
).scalar() or 0
db.session.execute(
playlist_content.insert().values(
playlist_id=playlist_id,
content_id=content.id,
position=max_pos + 1,
duration=duration,
)
)
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Added web link "{web_url}" to playlist "{playlist.name}"')
flash('Web link added to playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding web link to playlist: {str(e)}')
flash('Error adding web link to playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
@content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST']) @content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST'])
@login_required @login_required
def remove_content_from_playlist(playlist_id: int, content_id: int): def remove_content_from_playlist(playlist_id: int, content_id: int):
@@ -278,6 +429,12 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
) )
db.session.execute(stmt) db.session.execute(stmt)
# Web link items are playlist-specific and have no media-library
# presence, so delete the orphan Content row when it is removed.
content = db.session.get(Content, content_id)
if content is not None and content.content_type == 'weblink':
db.session.delete(content)
playlist.increment_version() playlist.increment_version()
db.session.commit() db.session.commit()
cache.clear() cache.clear()
+85 -5
View File
@@ -1,5 +1,5 @@
"""Players blueprint for player management and display.""" """Players blueprint for player management and display."""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
from flask_login import login_required from flask_login import login_required
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
import secrets import secrets
@@ -41,7 +41,7 @@ def list():
@players_bp.route('/add', methods=['GET', 'POST']) @players_bp.route('/add', methods=['GET', 'POST'])
@login_required @login_required
def add_player(): def add_player():
"""Add a new player.""" """Add a new player with optional SSH deployment."""
if request.method == 'GET': if request.method == 'GET':
playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all() playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all()
return render_template('players/add_player.html', playlists=playlists) return render_template('players/add_player.html', playlists=playlists)
@@ -55,6 +55,13 @@ def add_player():
orientation = request.form.get('orientation', 'Landscape') orientation = request.form.get('orientation', 'Landscape')
playlist_id = request.form.get('playlist_id', '').strip() playlist_id = request.form.get('playlist_id', '').strip()
# Get SSH deployment info if provided
ssh_hostname = request.form.get('ssh_hostname', '').strip()
ssh_username = request.form.get('ssh_username', '').strip()
ssh_password = request.form.get('ssh_password', '').strip()
ssh_port = int(request.form.get('ssh_port', '22')) if request.form.get('ssh_port') else 22
deploy_player = request.form.get('deploy_player', '').strip()
# Validation # Validation
if not name or len(name) < 3: if not name or len(name) < 3:
flash('Player name must be at least 3 characters long.', 'warning') flash('Player name must be at least 3 characters long.', 'warning')
@@ -102,14 +109,82 @@ def add_player():
log_action('info', f'Player "{name}" (hostname: {hostname}) created') log_action('info', f'Player "{name}" (hostname: {hostname}) created')
# If deployment requested and SSH credentials provided, trigger background deployment
deployment_initiated = False
if deploy_player and ssh_hostname and ssh_username and ssh_password:
try:
from app.utils.background_tasks import background_player_deployment, run_background_task
# Determine the server address the player should contact.
from flask import request as flask_request
from app.models.https_config import HTTPSConfig
server_url = None
try:
import os
from app.utils.player_build import get_player_server_settings, BUILD_META_FILENAME
meta_path = os.path.join(current_app.instance_path, BUILD_META_FILENAME)
build_srv = get_player_server_settings(meta_path)
if build_srv:
scheme = 'https' if build_srv['use_https'] else 'http'
server_url = f"{scheme}://{build_srv['server_ip']}:{build_srv['port']}"
except Exception:
server_url = None
if not server_url:
https_cfg = HTTPSConfig.get_config()
if https_cfg and https_cfg.https_enabled and (https_cfg.domain or https_cfg.ip_address):
host = https_cfg.domain or https_cfg.ip_address
cfg_port = https_cfg.port or 443
server_url = f"https://{host}:{cfg_port}"
else:
host = flask_request.host
hostname_only = host.split(':')[0]
if hostname_only in ('localhost', '127.0.0.1', '') or hostname_only.startswith('127.'):
from app.utils.ssh_deploy import detect_server_ip
detected_ip = detect_server_ip()
if detected_ip:
port_part = host.split(':', 1)[1] if ':' in host else ''
host = f"{detected_ip}:{port_part}" if port_part else detected_ip
server_url = f"{flask_request.scheme}://{host}"
# Generate API key for player authentication
import hashlib
api_key = hashlib.sha256(f'{name}:{hostname}'.encode()).hexdigest()[:32]
# Start deployment in background thread
run_background_task(
background_player_deployment,
hostname=ssh_hostname,
username=ssh_username,
password=ssh_password,
player_name=name,
player_id=new_player.id,
port=ssh_port,
server_url=server_url,
server_api_key=api_key,
player_hostname=hostname,
quickconnect_code=quickconnect_code,
orientation=orientation
)
deployment_initiated = True
log_action('info', f'Background deployment initiated for player "{name}" on {ssh_hostname}')
except Exception as deploy_err:
log_action('error', f'Failed to initiate background deployment for player "{name}": {str(deploy_err)}')
# Flash detailed success message # Flash detailed success message
success_msg = f''' success_msg = f'''
Player "{name}" created successfully!<br> Player "{name}" created successfully!<br>
<strong>Auth Code:</strong> {auth_code}<br> <strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
<strong>Hostname:</strong> {hostname}<br> <strong>Hostname:</strong> {hostname}<br>
<strong>Quick Connect:</strong> {quickconnect_code}<br> <strong>Quick Connect:</strong> {quickconnect_code}<br>
<small>Configure the player with these credentials in app_config.json</small>
''' '''
if deployment_initiated:
success_msg += f'<strong style="color: #0275d8;">&#8987; Deployment in Progress</strong> Deploying to {ssh_hostname} in background...<br>'
success_msg += '<small>Check player status to see deployment completion</small><br>'
success_msg += '<small>Configure the player with these credentials in app_config.json</small>'
flash(success_msg, 'success') flash(success_msg, 'success')
return redirect(url_for('players.list')) return redirect(url_for('players.list'))
@@ -426,9 +501,14 @@ def get_player_playlist(player_id: int) -> List[dict]:
# Build playlist # Build playlist
playlist = [] playlist = []
for content in ordered_content: for content in ordered_content:
# For weblinks, serve the actual URL directly; for files, serve static path
if content.content_type == 'weblink' and content.url:
item_url = content.url
else:
item_url = url_for('static', filename=f'uploads/{content.filename}')
playlist.append({ playlist.append({
'id': content.id, 'id': content.id,
'url': url_for('static', filename=f'uploads/{content.filename}'), 'url': item_url,
'type': content.content_type, 'type': content.content_type,
'duration': getattr(content, '_playlist_duration', content.duration or 10), 'duration': getattr(content, '_playlist_duration', content.duration or 10),
'position': getattr(content, '_playlist_position', 0), 'position': getattr(content, '_playlist_position', 0),
+4
View File
@@ -48,6 +48,10 @@ class Config:
DEFAULT_ADMIN_USER = os.getenv('ADMIN_USER', 'admin') DEFAULT_ADMIN_USER = os.getenv('ADMIN_USER', 'admin')
DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Initial01!') DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Initial01!')
# Player deployment — staged code directory and default repo
PLAYER_CODE_DIR = os.getenv('PLAYER_CODE_DIR', '/app/data/player')
PLAYER_REPO_URL = os.getenv('PLAYER_REPO_URL', '')
class DevelopmentConfig(Config): class DevelopmentConfig(Config):
"""Development configuration""" """Development configuration"""
+7
View File
@@ -24,6 +24,9 @@ class Content(db.Model):
content_type = db.Column(db.String(50), nullable=False, index=True) content_type = db.Column(db.String(50), nullable=False, index=True)
duration = db.Column(db.Integer, default=10, nullable=True) duration = db.Column(db.Integer, default=10, nullable=True)
file_size = db.Column(db.BigInteger, nullable=True) file_size = db.Column(db.BigInteger, nullable=True)
# For 'weblink' content this holds the web page URL to display on the player.
# NULL for file-based content (image/video/pdf).
url = db.Column(db.String(2048), nullable=True)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
nullable=False, index=True) nullable=False, index=True)
@@ -61,3 +64,7 @@ class Content(db.Model):
def is_pdf(self) -> bool: def is_pdf(self) -> bool:
"""Check if content is a PDF.""" """Check if content is a PDF."""
return self.content_type == 'pdf' return self.content_type == 'pdf'
def is_weblink(self) -> bool:
"""Check if content is a web link (URL) rather than an uploaded file."""
return self.content_type == 'weblink'
+6
View File
@@ -41,6 +41,12 @@ class Player(db.Model):
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'), playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
nullable=True, index=True) nullable=True, index=True)
# Deployment tracking
deployment_status = db.Column(db.String(50), default='pending', nullable=True) # pending, deployed, failed
last_deployment_at = db.Column(db.DateTime, nullable=True)
last_deployment_status = db.Column(db.String(50), nullable=True) # success, failed
last_deployment_message = db.Column(db.Text, nullable=True)
# Relationships # Relationships
playlist = db.relationship('Playlist', back_populates='players') playlist = db.relationship('Playlist', back_populates='players')
feedback = db.relationship('PlayerFeedback', back_populates='player', feedback = db.relationship('PlayerFeedback', back_populates='player',
+11
View File
@@ -95,6 +95,17 @@
</div> </div>
</div> </div>
<!-- Build Player Files Card (Admin Only) -->
<div class="card management-card" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);">
<h2>🚀 Build Player Files</h2>
<p>Clone/refresh the player code from Gitea and configure the server address for deployment</p>
<div class="card-actions">
<a href="{{ url_for('admin.build_player') }}" class="btn btn-primary">
Build Player Files
</a>
</div>
</div>
<!-- Logo Customization Card (Admin Only) --> <!-- Logo Customization Card (Admin Only) -->
<div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);"> <div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
<h2>🎨 Logo Customization</h2> <h2>🎨 Logo Customization</h2>
+130
View File
@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}Build Player Files - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<a href="{{ url_for('admin.admin_panel') }}" class="back-link">← Back to Admin Panel</a>
<h1>🧰 Build Player Files for Deployment</h1>
<p style="color: #6c757d; margin-top: 6px;">
Pull the latest player source from a repository onto this server and bake the
configuration it needs to talk to this server. SSH deployments ship exactly
this staged copy, so the version you build here is what players run.
</p>
</div>
<!-- Current staged code status -->
<div class="card status-card">
<h2>Current Staged Player Code</h2>
{% if code_status.available %}
<p><span class="badge badge-success">✅ Ready</span></p>
<ul style="line-height: 1.8;">
<li><strong>Version (git):</strong> <code>{{ code_status.version }}</code></li>
<li><strong>Size:</strong> {{ code_status.size }}</li>
{% if code_status.updated_str %}
<li><strong>Last updated:</strong> {{ code_status.updated_str }}</li>
{% endif %}
<li><strong>Path:</strong> <code>{{ code_status.path }}</code></li>
</ul>
{% else %}
<p>
<span class="badge badge-warning">⚠️ Not staged</span>
{{ code_status.reason }}
</p>
<p style="color: #6c757d;">Path: <code>{{ code_status.path }}</code></p>
{% endif %}
{% if settings.built_at %}
<p style="color: #6c757d; margin-top: 8px;">
Last build saved: {{ settings.built_at }}
{% if settings.built_by %}by {{ settings.built_by }}{% endif %}
</p>
{% endif %}
</div>
<form method="POST" action="{{ url_for('admin.build_player_action') }}">
<!-- Repository -->
<div class="card">
<h2>1. Player Files Repository</h2>
<div class="form-group">
<label for="repo_url">Git Repository URL</label>
<input type="text" id="repo_url" name="repo_url" class="form-control"
value="{{ settings.repo_url }}"
placeholder="https://gitea.example.com/org/Kiwy-Signage.git">
<small style="color: #6c757d;">Cloned/refreshed into the staged directory on this server.</small>
</div>
<div class="form-group">
<label for="branch">Branch</label>
<input type="text" id="branch" name="branch" class="form-control"
value="{{ settings.branch }}" placeholder="main">
</div>
</div>
<!-- Server configuration -->
<div class="card">
<h2>2. Player → Server Configuration</h2>
<p style="color: #6c757d;">
The player contacts the DigiServer API directly at
<code>{scheme}://{server}:{port}/api/...</code> (no <code>/digiserver</code> path).
Enter the address players can reach this server on.
</p>
<div class="form-group">
<label for="server_ip">Server IP / Domain</label>
<input type="text" id="server_ip" name="server_ip" class="form-control"
value="{{ settings.server_ip }}" placeholder="signage.example.com or 192.168.0.50">
</div>
<div class="form-group">
<label for="port">Port</label>
<input type="number" id="port" name="port" class="form-control"
value="{{ settings.port }}" min="1" max="65535">
<small style="color: #6c757d;">Use 443 for HTTPS, 80 for plain HTTP, or your custom port (e.g. 8080).</small>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" name="use_https" {% if settings.use_https %}checked{% endif %}>
Use HTTPS
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" name="verify_ssl" {% if settings.verify_ssl %}checked{% endif %}>
Verify SSL certificate
</label>
<small style="color: #6c757d;">Leave off for self-signed certificates.</small>
</div>
<div class="form-group">
<label for="orientation">Orientation</label>
<select id="orientation" name="orientation" class="form-control">
<option value="Landscape" {% if settings.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
<option value="Portrait" {% if settings.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
</select>
</div>
<div class="form-group">
<label for="max_resolution">Max Resolution</label>
<input type="text" id="max_resolution" name="max_resolution" class="form-control"
value="{{ settings.max_resolution }}" placeholder="1920x1080">
</div>
<p style="color: #6c757d; font-size: 13px;">
️ The per-player <strong>screen name</strong> and <strong>quick-connect code</strong>
are filled in automatically for each device during SSH deployment.
</p>
</div>
<!-- Actions -->
<div class="card">
<h2>3. Build</h2>
<div class="card-actions" style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="submit" name="action" value="build_and_config" class="btn btn-primary">
⬇️ Build files &amp; write config
</button>
<button type="submit" name="action" value="build_files" class="btn btn-secondary">
Build files only
</button>
<button type="submit" name="action" value="save_config" class="btn btn-secondary">
Write config only
</button>
</div>
</div>
</form>
</div>
{% endblock %}
+15 -73
View File
@@ -160,93 +160,35 @@
</form> </form>
</div> </div>
<!-- Nginx Status Card --> <!-- Caddy Reverse Proxy Status Card -->
<div class="card nginx-status-card"> <div class="card nginx-status-card">
<h2>🔧 Nginx Reverse Proxy Status</h2> <h2>🔧 Caddy Reverse Proxy Status</h2>
{% if nginx_status.available %}
<div class="nginx-status-content"> <div class="nginx-status-content">
<div class="status-item"> <div class="status-item">
<strong>Status:</strong> <strong>Reverse Proxy:</strong>
<span class="badge badge-success">Nginx Configured</span> <span class="badge badge-success">Caddy</span>
</div> </div>
<div class="status-item">
<strong>Configuration Path:</strong>
<code>{{ nginx_status.path }}</code>
</div>
{% if nginx_status.ssl_enabled %}
<div class="status-item"> <div class="status-item">
<strong>SSL/TLS:</strong> <strong>SSL/TLS:</strong>
<span class="badge badge-success">🔒 Enabled</span> {% if config and config.https_enabled %}
</div> <span class="badge badge-success">🔒 Enabled — auto-managed by Caddy</span>
{% else %} {% else %}
<span class="badge badge-warning">⚠️ HTTP only — enable HTTPS above</span>
{% endif %}
</div>
<div class="status-item"> <div class="status-item">
<strong>SSL/TLS:</strong> <strong>Certificate Provider:</strong>
<span class="badge badge-warning">⚠️ Not Configured</span> <code>Let's Encrypt (automatic)</code>
</div> </div>
{% endif %}
{% if nginx_status.http_ports %}
<div class="status-item"> <div class="status-item">
<strong>HTTP Ports:</strong> <strong>Admin API:</strong>
<code>{{ nginx_status.http_ports|join(', ') }}</code> <code>caddy:2019</code>
</div> </div>
{% endif %}
{% if nginx_status.https_ports %}
<div class="status-item"> <div class="status-item">
<strong>HTTPS Ports:</strong> <strong>Upstream:</strong>
<code>{{ nginx_status.https_ports|join(', ') }}</code> <code>digiserver-app:5000</code>
</div> </div>
{% endif %}
{% if nginx_status.server_names %}
<div class="status-item">
<strong>Server Names:</strong>
{% for name in nginx_status.server_names %}
<code>{{ name }}</code>{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div> </div>
{% endif %}
{% if nginx_status.upstream_servers %}
<div class="status-item">
<strong>Upstream Servers:</strong>
{% for server in nginx_status.upstream_servers %}
<code>{{ server }}</code>{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
{% if nginx_status.ssl_protocols %}
<div class="status-item">
<strong>SSL Protocols:</strong>
<code>{{ nginx_status.ssl_protocols|join(', ') }}</code>
</div>
{% endif %}
{% if nginx_status.client_max_body_size %}
<div class="status-item">
<strong>Max Body Size:</strong>
<code>{{ nginx_status.client_max_body_size }}</code>
</div>
{% endif %}
{% if nginx_status.gzip_enabled %}
<div class="status-item">
<strong>Gzip Compression:</strong>
<span class="badge badge-success">✅ Enabled</span>
</div>
{% endif %}
</div>
{% else %}
<div class="status-disabled">
<p>⚠️ <strong>Nginx configuration not accessible</strong></p>
<p>Error: {{ nginx_status.error|default('Unknown error') }}</p>
<p style="font-size: 12px; color: #666;">Path checked: {{ nginx_status.path }}</p>
</div>
{% endif %}
</div> </div>
<!-- Information Section --> <!-- Information Section -->
@@ -314,11 +314,18 @@
</td> </td>
<td><span class="drag-handle">⋮⋮</span></td> <td><span class="drag-handle">⋮⋮</span></td>
<td>{{ loop.index }}</td> <td>{{ loop.index }}</td>
<td>{{ content.filename }}</td> <td>
{% if content.content_type == 'weblink' %}
<a href="{{ content.url }}" target="_blank" rel="noopener noreferrer">{{ content.url }}</a>
{% else %}
{{ content.filename }}
{% endif %}
</td>
<td> <td>
{% if content.content_type == 'image' %}📷 Image {% if content.content_type == 'image' %}📷 Image
{% elif content.content_type == 'video' %}🎥 Video {% elif content.content_type == 'video' %}🎥 Video
{% elif content.content_type == 'pdf' %}📄 PDF {% elif content.content_type == 'pdf' %}📄 PDF
{% elif content.content_type == 'weblink' %}🔗 Web Link
{% else %}📁 Other{% endif %} {% else %}📁 Other{% endif %}
</td> </td>
<td> <td>
@@ -401,6 +408,22 @@
<div class="card"> <div class="card">
<h2 style="margin-bottom: 20px;"> Add Content</h2> <h2 style="margin-bottom: 20px;"> Add Content</h2>
<div style="margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e0e0e0;">
<h3 style="margin-bottom: 12px; font-size: 16px;">🔗 Add Web Link</h3>
<form method="POST"
action="{{ url_for('content.add_weblink_to_playlist', playlist_id=playlist.id) }}">
<input type="url" name="url" required
placeholder="https://example.com/dashboard"
style="width: 100%; padding: 8px; margin-bottom: 8px; box-sizing: border-box;">
<div style="display: flex; gap: 8px; align-items: center;">
<label style="font-size: 13px; color: #666;">Duration (s):</label>
<input type="number" name="duration" value="30" min="1"
style="width: 80px; padding: 6px;">
<button type="submit" class="btn btn-primary btn-sm">+ Add Link</button>
</div>
</form>
</div>
{% if available_content %} {% if available_content %}
<div class="available-content"> <div class="available-content">
{% for content in available_content %} {% for content in available_content %}
+98
View File
@@ -317,6 +317,66 @@
</form> </form>
</div> </div>
<!-- ── Web Link Card ─────────────────────────────────────────────────────── -->
<div class="card" style="margin-top: 20px;">
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
🌐 Add Web Page Link
</h2>
<p style="color: #6c757d; font-size: 13px; margin-bottom: 16px;">
Add a website URL to display on the player (e.g. a dashboard, live feed, or any public web page).
</p>
<form id="weblink-form" method="POST" action="{{ request.script_root }}/content/add-weblink">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<!-- URL -->
<div class="form-group" style="grid-column: 1 / -1;">
<label for="wl-url">Web Page URL <span style="color:#e53e3e;">*</span></label>
<input type="url" id="wl-url" name="url" class="form-control"
placeholder="https://example.com" required>
<small style="color:#6c757d; font-size:11px;">Must start with http:// or https://</small>
</div>
<!-- Description -->
<div class="form-group">
<label for="wl-description">Label / Description</label>
<input type="text" id="wl-description" name="description" class="form-control"
placeholder="e.g. Live Dashboard">
<small style="color:#6c757d; font-size:11px;">Optional — shown in the media library</small>
</div>
<!-- Duration -->
<div class="form-group">
<label for="wl-duration">Display Duration (seconds)</label>
<input type="number" id="wl-duration" name="duration" class="form-control"
value="30" min="5" max="3600">
<small style="color:#6c757d; font-size:11px;">How long to show the page per loop</small>
</div>
<!-- Playlist -->
<div class="form-group" style="grid-column: 1 / -1;">
<label for="wl-playlist">Add to Playlist (Optional)</label>
<select id="wl-playlist" name="playlist_id" class="form-control">
<option value="">-- Media Library Only --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) — {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
</div>
</div>
<div style="display:flex; align-items:center; gap:12px; margin-top:8px;">
<button type="submit" class="btn-upload" id="wl-submit-btn"
style="display:inline-flex; align-items:center; gap:0.5rem; padding:10px 24px;">
🌐 Add Web Link
</button>
<span id="wl-status" style="font-size:13px; display:none;"></span>
</div>
</form>
</div>
<script> <script>
const uploadZone = document.getElementById('upload-zone'); const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input'); const fileInput = document.getElementById('file-input');
@@ -509,6 +569,44 @@
uploadBtn.disabled = true; uploadBtn.disabled = true;
uploadBtn.innerHTML = '⏳ Uploading...'; uploadBtn.innerHTML = '⏳ Uploading...';
}); });
// ── Web Link form — AJAX submit ────────────────────────────────────────
const wlForm = document.getElementById('weblink-form');
const wlBtn = document.getElementById('wl-submit-btn');
const wlStatus = document.getElementById('wl-status');
wlForm.addEventListener('submit', async (e) => {
e.preventDefault();
wlBtn.disabled = true;
wlBtn.textContent = '⏳ Adding…';
wlStatus.style.display = 'none';
try {
const resp = await fetch(wlForm.action, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: new FormData(wlForm),
});
const data = await resp.json();
if (data.success) {
wlStatus.style.color = '#38a169';
wlStatus.textContent = '✓ ' + data.message;
wlForm.reset();
document.getElementById('wl-duration').value = 30;
} else {
wlStatus.style.color = '#e53e3e';
wlStatus.textContent = '✗ ' + (data.error || 'Failed to add web link.');
}
} catch (err) {
wlStatus.style.color = '#e53e3e';
wlStatus.textContent = '✗ Network error — please try again.';
}
wlStatus.style.display = 'inline';
wlBtn.disabled = false;
wlBtn.innerHTML = '🌐 Add Web Link';
});
</script> </script>
{% endblock %} {% endblock %}
+387 -123
View File
@@ -4,134 +4,155 @@
{% block content %} {% block content %}
<style> <style>
.form-group { .form-group { margin-bottom: 1rem; }
margin-bottom: 1rem; .form-group label { font-weight: bold; display: block; margin-bottom: 0.5rem; }
} body.dark-mode .form-group label { color: #e2e8f0; }
.form-group label {
font-weight: bold;
display: block;
margin-bottom: 0.5rem;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
.form-control { .form-control {
width: 100%; width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;
padding: 0.5rem; box-sizing: border-box;
border: 1px solid #ddd;
border-radius: 4px;
} }
body.dark-mode .form-control { body.dark-mode .form-control {
background: #1a202c; background: #1a202c; border-color: #4a5568; color: #e2e8f0;
border-color: #4a5568;
color: #e2e8f0;
} }
body.dark-mode .form-control:focus { border-color: #7c3aed; outline: none; }
body.dark-mode .form-control:focus { .form-help { color: #6c757d; font-size: 0.875rem; }
border-color: #7c3aed; body.dark-mode .form-help { color: #718096; }
outline: none;
}
.form-help {
color: #6c757d;
font-size: 0.875rem;
}
body.dark-mode .form-help {
color: #718096;
}
.section-header { .section-header {
margin-top: 2rem; margin-top: 2rem; padding-bottom: 0.5rem; border-bottom: 2px solid;
padding-bottom: 0.5rem;
border-bottom: 2px solid;
} }
.section-header.blue { border-color: #007bff; }
body.dark-mode .section-header.blue { border-color: #667eea; }
.section-header.green { border-color: #28a745; }
body.dark-mode .section-header.green { border-color: #48bb78; }
.section-header.yellow { border-color: #ffc107; }
body.dark-mode .section-header.yellow { border-color: #ecc94b; }
.section-header.purple { border-color: #9b59b6; }
body.dark-mode .section-header.purple { border-color: #b794f6; }
.section-header.blue { body.dark-mode h1, body.dark-mode h3, body.dark-mode h4 { color: #e2e8f0; }
border-color: #007bff; body.dark-mode p { color: #a0aec0; }
}
body.dark-mode .section-header.blue {
border-color: #667eea;
}
.section-header.green {
border-color: #28a745;
}
body.dark-mode .section-header.green {
border-color: #48bb78;
}
.section-header.yellow {
border-color: #ffc107;
}
body.dark-mode .section-header.yellow {
border-color: #ecc94b;
}
body.dark-mode h1,
body.dark-mode h3,
body.dark-mode h4 {
color: #e2e8f0;
}
body.dark-mode p {
color: #a0aec0;
}
.info-box { .info-box {
background-color: #e7f3ff; background-color: #e7f3ff; border-left: 4px solid #007bff; padding: 1rem; margin: 2rem 0;
border-left: 4px solid #007bff;
padding: 1rem;
margin: 2rem 0;
} }
body.dark-mode .info-box { background-color: #1a365d; border-left-color: #667eea; }
.info-box h4 { margin-top: 0; color: #007bff; }
body.dark-mode .info-box h4 { color: #667eea; }
.info-box code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
body.dark-mode .info-box code { background: #2d3748; color: #e2e8f0; }
body.dark-mode small { color: #718096; }
body.dark-mode .info-box { /* Mode chooser cards */
background-color: #1a365d; .mode-chooser {
border-left-color: #667eea; display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;
} }
@media (max-width: 700px) { .mode-chooser { grid-template-columns: 1fr; } }
.info-box h4 { .mode-card {
margin-top: 0; border: 2px solid #ddd; border-radius: 8px; padding: 1.5rem;
color: #007bff; cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s;
background: #fff; text-align: center;
} }
body.dark-mode .mode-card { background: #2d3748; border-color: #4a5568; }
body.dark-mode .info-box h4 { .mode-card:hover { border-color: #007bff; box-shadow: 0 4px 12px rgba(0,123,255,0.15); }
color: #667eea; body.dark-mode .mode-card:hover { border-color: #667eea; }
}
.info-box code { .mode-card.active { border-color: #007bff; box-shadow: 0 4px 12px rgba(0,123,255,0.2); }
background: #f4f4f4; body.dark-mode .mode-card.active { border-color: #667eea; box-shadow: 0 4px 12px rgba(102,126,234,0.25); }
padding: 2px 6px;
border-radius: 3px;
}
body.dark-mode .info-box code { .mode-card.active-deploy { border-color: #28a745; box-shadow: 0 4px 12px rgba(40,167,69,0.2); }
background: #2d3748; body.dark-mode .mode-card.active-deploy { border-color: #48bb78; }
color: #e2e8f0;
}
body.dark-mode small { .mode-icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
color: #718096; .mode-title { font-size: 1.15rem; font-weight: bold; margin-bottom: 0.4rem; }
body.dark-mode .mode-title { color: #e2e8f0; }
.mode-desc { font-size: 0.875rem; color: #6c757d; }
body.dark-mode .mode-desc { color: #a0aec0; }
/* Panel visibility */
.mode-panel { display: none; }
.mode-panel.active { display: block; }
/* SSH section */
.ssh-section {
background-color: #f8f9fa; padding: 1.5rem; border-radius: 6px; margin-bottom: 2rem;
} }
body.dark-mode .ssh-section { background-color: #1e2a38; }
.connection-status {
padding: 1rem; border-radius: 4px; margin-top: 1rem; display: none;
}
.connection-status.success {
background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; display: block;
}
.connection-status.error {
background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; display: block;
}
body.dark-mode .connection-status.success { background-color: #22543d; border-color: #2f855a; color: #9ae6b4; }
body.dark-mode .connection-status.error { background-color: #742a2a; border-color: #c53030; color: #fc8181; }
/* Deploy-form hidden until SSH verified */
.deploy-player-form { display: none; }
.deploy-player-form.active { display: block; }
.btn {
padding: 0.5rem 1rem; margin-right: 0.5rem; margin-bottom: 0.5rem;
border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem;
text-decoration: none; display: inline-block;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background-color: #007bff; color: white; }
.btn-primary:hover:not(:disabled) { background-color: #0056b3; }
.btn-success { background-color: #28a745; color: white; }
.btn-success:hover:not(:disabled) { background-color: #218838; }
.btn-secondary { background-color: #6c757d; color: white; }
.btn-secondary:hover:not(:disabled) { background-color: #5a6268; }
.loading-spinner {
display: inline-block; width: 1rem; height: 1rem;
border: 2px solid rgba(255,255,255,0.3); border-radius: 50%;
border-top-color: white; animation: spin 1s linear infinite; margin-right: 0.5rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
.row2col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 768px) { .row2col { grid-template-columns: 1fr; } }
</style> </style>
<div class="container" style="max-width: 800px; margin-top: 2rem;">
<div class="container" style="max-width: 960px; margin-top: 2rem;">
<h1>Add New Player</h1> <h1>Add New Player</h1>
<p style="color: #6c757d; margin-bottom: 2rem;"> <p style="color: #6c757d; margin-bottom: 1.5rem;">
Create a new digital signage player with authentication credentials Choose how you want to add the player to the system.
</p> </p>
<div class="card"> <!-- ───────────────────────────── Mode selector ───────────────────────────── -->
<div class="mode-chooser">
<div class="mode-card active" id="card_manual" onclick="selectMode('manual')">
<div class="mode-icon">📋</div>
<div class="mode-title">Manual Registration</div>
<div class="mode-desc">
Register the player in the database and wait for the client to connect.
You configure <code>app_config.json</code> on the device yourself.
</div>
</div>
<div class="mode-card" id="card_deploy" onclick="selectMode('deploy')">
<div class="mode-icon">🚀</div>
<div class="mode-title">Create &amp; Deploy</div>
<div class="mode-desc">
Register the player <em>and</em> automatically push the Linux player
code to the target host via SSH.
</div>
</div>
</div>
<!-- ─────────────────────── Mode 1 — Manual Registration ─────────────────── -->
<div class="card mode-panel active" id="panel_manual">
<form method="POST"> <form method="POST">
<h3 class="section-header blue" style="margin-top: 0;"> <h3 class="section-header blue" style="margin-top: 0;">Basic Information</h3>
Basic Information
</h3>
<div class="form-group"> <div class="form-group">
<label>Display Name *</label> <label>Display Name *</label>
@@ -145,31 +166,28 @@
<input type="text" name="hostname" required class="form-control" <input type="text" name="hostname" required class="form-control"
placeholder="e.g., office-player-001"> placeholder="e.g., office-player-001">
<small class="form-help"> <small class="form-help">
Unique identifier for this player (must match screen_name in player config) Unique identifier must match <code>screen_name</code> in the player's
<code>app_config.json</code>
</small> </small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Location</label> <label>Location</label>
<input type="text" name="location" class="form-control" <input type="text" name="location" class="form-control"
placeholder="e.g., Main Office - Reception Area"> placeholder="e.g., Main Office Reception Area">
<small class="form-help">Physical location of the player (optional)</small> <small class="form-help">Physical location of the player (optional)</small>
</div> </div>
<h3 class="section-header green"> <h3 class="section-header green">Authentication</h3>
Authentication
</h3>
<p class="form-help" style="margin-bottom: 1rem;"> <p class="form-help" style="margin-bottom: 1rem;">
Choose one authentication method (Quick Connect recommended for easy setup) Choose one authentication method (Quick Connect recommended for easy setup)
</p> </p>
<div class="form-group"> <div class="form-group">
<label>Password</label> <label>Password</label>
<input type="password" name="password" id="password" class="form-control" <input type="password" name="password" class="form-control"
placeholder="Leave empty to use Quick Connect only"> placeholder="Leave empty to use Quick Connect only">
<small class="form-help"> <small class="form-help">Secure password for player authentication (optional if using Quick Connect)</small>
Secure password for player authentication (optional if using Quick Connect)
</small>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -177,13 +195,11 @@
<input type="text" name="quickconnect_code" required class="form-control" <input type="text" name="quickconnect_code" required class="form-control"
placeholder="e.g., OFFICE123"> placeholder="e.g., OFFICE123">
<small class="form-help"> <small class="form-help">
Easy pairing code for quick setup (must match quickconnect_key in player config) Easy pairing code must match <code>quickconnect_key</code> in player config
</small> </small>
</div> </div>
<h3 class="section-header yellow"> <h3 class="section-header yellow">Display Settings</h3>
Display Settings
</h3>
<div class="form-group"> <div class="form-group">
<label>Orientation</label> <label>Orientation</label>
@@ -199,7 +215,9 @@
<select name="playlist_id" class="form-control"> <select name="playlist_id" class="form-control">
<option value="">No Playlist (Unassigned)</option> <option value="">No Playlist (Unassigned)</option>
{% for playlist in playlists %} {% for playlist in playlists %}
<option value="{{ playlist.id }}">{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items</option> <option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) {{ playlist.content_count }} items
</option>
{% endfor %} {% endfor %}
</select> </select>
<small class="form-help">Assign player to a playlist (optional)</small> <small class="form-help">Assign player to a playlist (optional)</small>
@@ -208,16 +226,16 @@
<div class="info-box"> <div class="info-box">
<h4>📋 Setup Instructions</h4> <h4>📋 Setup Instructions</h4>
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;"> <ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
<li>Create the player with the form above</li> <li>Create the player with the form above.</li>
<li>Note the generated <strong>Auth Code</strong> (shown after creation)</li> <li>Note the generated <strong>Auth Code</strong> shown after creation.</li>
<li>Configure the player's <code>app_config.json</code> with: <li>Configure the player's <code>app_config.json</code>:
<ul style="margin-top: 0.5rem;"> <ul style="margin-top: 0.5rem;">
<li><code>server_ip</code>: Your server address</li> <li><code>server_ip</code> — your server address</li>
<li><code>screen_name</code>: Same as <strong>Hostname</strong> above</li> <li><code>screen_name</code> — same as <strong>Hostname</strong> above</li>
<li><code>quickconnect_key</code>: Same as <strong>Quick Connect Code</strong> above</li> <li><code>quickconnect_key</code> — same as <strong>Quick Connect Code</strong> above</li>
</ul> </ul>
</li> </li>
<li>Start the player - it will authenticate automatically</li> <li>Start the player it will authenticate automatically.</li>
</ol> </ol>
</div> </div>
@@ -225,11 +243,257 @@
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;"> <button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
✓ Create Player ✓ Create Player
</button> </button>
<a href="{{ url_for('players.list') }}" class="btn" style="padding: 0.75rem 2rem; margin-left: 1rem;"> <a href="{{ url_for('players.list') }}" class="btn btn-secondary"
style="padding: 0.75rem 2rem; margin-left: 0.5rem;">
Cancel Cancel
</a> </a>
</div> </div>
</form> </form>
</div> </div>
<!-- ──────────────────────── Mode 2 — Create & Deploy ────────────────────── -->
<div class="card mode-panel" id="panel_deploy">
<!-- Step 1 — SSH connection test -->
<div class="ssh-section">
<h3 class="section-header purple" style="margin-top: 0;">
🔌 Step 1 — SSH Connection
</h3>
<p class="form-help">
Test SSH connectivity to the target Linux host before proceeding.
</p>
<div class="row2col">
<div class="form-group">
<label>Target Hostname / IP *</label>
<input type="text" id="ssh_hostname" class="form-control"
placeholder="e.g., 192.168.1.100">
<small class="form-help">IP address or hostname of the target machine</small>
</div> </div>
<div class="form-group">
<label>SSH Port</label>
<input type="number" id="ssh_port" class="form-control"
value="22" min="1" max="65535">
<small class="form-help">SSH port (default: 22)</small>
</div>
</div>
<div class="row2col">
<div class="form-group">
<label>SSH Username *</label>
<input type="text" id="ssh_username" class="form-control"
placeholder="e.g., pi or ubuntu">
<small class="form-help">SSH login username</small>
</div>
<div class="form-group">
<label>SSH Password *</label>
<input type="password" id="ssh_password" class="form-control"
placeholder="SSH password">
<small class="form-help">SSH login password</small>
</div>
</div>
<button type="button" id="test_ssh_btn" class="btn btn-primary"
onclick="testSSHConnection()">
✓ Test SSH Connection
</button>
<button type="button" id="clear_ssh_btn" class="btn btn-secondary"
onclick="clearSSHForm()" style="display: none;">
🔄 Clear
</button>
<div id="connection_status" class="connection-status"></div>
</div>
<!-- Step 2 — Player info (shown after successful SSH test) -->
<div id="deploy_player_form_section" class="deploy-player-form">
<form method="POST" id="add_player_form">
<!-- Hidden SSH fields -->
<input type="hidden" id="form_ssh_hostname" name="ssh_hostname" value="">
<input type="hidden" id="form_ssh_username" name="ssh_username" value="">
<input type="hidden" id="form_ssh_password" name="ssh_password" value="">
<input type="hidden" id="form_ssh_port" name="ssh_port" value="">
<input type="hidden" name="deploy_player" value="1">
<h3 class="section-header blue">Step 2 — Basic Information</h3>
<div class="form-group">
<label>Display Name *</label>
<input type="text" name="name" required class="form-control"
placeholder="e.g., Office Reception Player">
<small class="form-help">Friendly name for the player</small>
</div>
<div class="form-group">
<label>Hostname *</label>
<input type="text" name="hostname" required class="form-control"
placeholder="e.g., office-player-001">
<small class="form-help">
Unique identifier — will be written to <code>app_config.json</code>
on the remote host automatically
</small>
</div>
<div class="form-group">
<label>Location</label>
<input type="text" name="location" class="form-control"
placeholder="e.g., Main Office Reception Area">
<small class="form-help">Physical location (optional)</small>
</div>
<h3 class="section-header green">Authentication</h3>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" class="form-control"
placeholder="Leave empty to use Quick Connect only">
<small class="form-help">Secure password (optional if using Quick Connect)</small>
</div>
<div class="form-group">
<label>Quick Connect Code *</label>
<input type="text" name="quickconnect_code" required class="form-control"
placeholder="e.g., OFFICE123">
<small class="form-help">Easy pairing code — deployed automatically to remote config</small>
</div>
<h3 class="section-header yellow">Display Settings</h3>
<div class="form-group">
<label>Orientation</label>
<select name="orientation" class="form-control">
<option value="Landscape" selected>Landscape</option>
<option value="Portrait">Portrait</option>
</select>
<small class="form-help">Display orientation for the player</small>
</div>
<div class="form-group">
<label>Assign Playlist</label>
<select name="playlist_id" class="form-control">
<option value="">No Playlist (Unassigned)</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
<small class="form-help">Assign player to a playlist (optional)</small>
</div>
<div class="info-box">
<h4>🚀 What Happens Next</h4>
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
<li><strong>Player Record</strong> is created in the database with an Auth Code.</li>
<li><strong>Player Code</strong> from the Kiwy-Signage repository is pushed to
<strong id="deploy_host_info">the target host</strong> via SSH.</li>
<li><strong>Installation Scripts</strong> run on the remote host in the background.</li>
<li><strong>Config</strong> (<code>app_config.json</code>) is written automatically
with the server address, hostname and Quick Connect Code.</li>
</ol>
</div>
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd;">
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
⚙️ Create &amp; Deploy Player
</button>
<a href="{{ url_for('players.list') }}" class="btn btn-secondary"
style="padding: 0.75rem 2rem; margin-left: 0.5rem;">
Cancel
</a>
</div>
</form>
</div>
</div><!-- /panel_deploy -->
</div>
<script>
// ── Mode switching ─────────────────────────────────────────────────────────
function selectMode(mode) {
// cards
document.getElementById('card_manual').classList.remove('active', 'active-deploy');
document.getElementById('card_deploy').classList.remove('active', 'active-deploy');
if (mode === 'manual') {
document.getElementById('card_manual').classList.add('active');
} else {
document.getElementById('card_deploy').classList.add('active-deploy');
}
// panels
document.getElementById('panel_manual').classList.toggle('active', mode === 'manual');
document.getElementById('panel_deploy').classList.toggle('active', mode === 'deploy');
}
// ── SSH flow ───────────────────────────────────────────────────────────────
function testSSHConnection() {
const hostname = document.getElementById('ssh_hostname').value.trim();
const username = document.getElementById('ssh_username').value.trim();
const password = document.getElementById('ssh_password').value.trim();
const port = parseInt(document.getElementById('ssh_port').value) || 22;
if (!hostname || !username || !password) {
alert('Please fill in all SSH connection fields.');
return;
}
const btn = document.getElementById('test_ssh_btn');
const statusDiv = document.getElementById('connection_status');
btn.disabled = true;
btn.innerHTML = '<span class="loading-spinner"></span>Testing connection…';
fetch('{{ url_for("api.test_ssh_connection") }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hostname, username, password, port})
})
.then(r => r.json())
.then(data => {
statusDiv.className = 'connection-status ' + (data.success ? 'success' : 'error');
statusDiv.innerHTML = '<strong>' + (data.success ? '✓ Connected!' : '✗ Connection Failed')
+ '</strong><br>' + data.message;
if (data.success) {
// Lock SSH fields
['ssh_hostname','ssh_username','ssh_password','ssh_port'].forEach(id => {
document.getElementById(id).disabled = true;
});
document.getElementById('test_ssh_btn').style.display = 'none';
document.getElementById('clear_ssh_btn').style.display = 'inline-block';
// Copy credentials to hidden form fields
document.getElementById('form_ssh_hostname').value = hostname;
document.getElementById('form_ssh_username').value = username;
document.getElementById('form_ssh_password').value = password;
document.getElementById('form_ssh_port').value = port;
// Reveal player form and update host label
document.getElementById('deploy_player_form_section').classList.add('active');
document.getElementById('deploy_host_info').textContent = hostname;
}
btn.disabled = false;
btn.innerHTML = '✓ Test SSH Connection';
})
.catch(err => {
statusDiv.className = 'connection-status error';
statusDiv.innerHTML = '<strong>✗ Error:</strong> ' + err.message;
btn.disabled = false;
btn.innerHTML = '✓ Test SSH Connection';
});
}
function clearSSHForm() {
['ssh_hostname','ssh_username','ssh_password','ssh_port'].forEach(id => {
const el = document.getElementById(id);
el.value = id === 'ssh_port' ? '22' : '';
el.disabled = false;
});
document.getElementById('test_ssh_btn').style.display = 'inline-block';
document.getElementById('clear_ssh_btn').style.display = 'none';
document.getElementById('connection_status').className = 'connection-status';
document.getElementById('deploy_player_form_section').classList.remove('active');
document.getElementById('add_player_form').reset();
}
</script>
{% endblock %} {% endblock %}
+101
View File
@@ -0,0 +1,101 @@
"""Background task execution for long-running operations."""
import threading
import logging
from typing import Callable, Any, Dict
logger = logging.getLogger(__name__)
def run_background_task(task_func: Callable, *args, **kwargs) -> threading.Thread:
"""Run a function in a background thread, with a Flask app context pushed."""
from flask import current_app
# Capture the app instance now (in the request context) so the thread can use it
app = current_app._get_current_object()
def wrapper():
with app.app_context():
try:
logger.info(f"Starting background task: {task_func.__name__}")
task_func(*args, **kwargs)
logger.info(f"Completed background task: {task_func.__name__}")
except Exception as e:
logger.error(f"Background task failed ({task_func.__name__}): {str(e)}", exc_info=True)
thread = threading.Thread(target=wrapper, daemon=True)
thread.start()
return thread
def background_player_deployment(
hostname: str,
username: str,
password: str,
player_name: str,
player_id: int,
port: int = 22,
server_url: str = None,
server_api_key: str = None,
player_hostname: str = None,
quickconnect_code: str = None,
orientation: str = 'Landscape',
verify_ssl: bool = False
) -> None:
"""
Deploy player code to host in background.
Args:
hostname: SSH hostname/IP
username: SSH username
password: SSH password
player_name: Player name
player_id: Player database ID
port: SSH port
server_url: DigiServer URL for player
server_api_key: API key for player
player_hostname: Player screen identity used for auth (Player.hostname)
quickconnect_code: Quick connect code used for auth
orientation: Player orientation (Landscape/Portrait)
verify_ssl: Whether the player should verify the server TLS certificate
"""
from app.utils.ssh_deploy import deploy_player_to_host
from app.models import Player
from app.extensions import db
from app.utils.logger import log_action
try:
# Execute deployment
result = deploy_player_to_host(
hostname=hostname,
username=username,
password=password,
player_name=player_name,
port=port,
server_url=server_url,
server_api_key=server_api_key,
player_hostname=player_hostname,
quickconnect_code=quickconnect_code,
orientation=orientation,
verify_ssl=verify_ssl
)
# Update player with deployment status
from datetime import datetime
player = Player.query.get(player_id)
if player:
player.last_deployment_at = datetime.utcnow()
if result.get('success'):
player.deployment_status = 'deployed'
player.last_deployment_status = 'success'
player.last_deployment_message = result.get('message', 'Deployment successful')
log_action('info', f'Background deployment completed for player "{player_name}": {result["message"]}')
else:
player.deployment_status = 'failed'
player.last_deployment_status = 'failed'
player.last_deployment_message = result.get('error', result.get('message', 'Deployment failed'))
log_action('error', f'Background deployment failed for player "{player_name}": {result.get("error", result.get("message"))}')
db.session.commit()
except Exception as e:
logger.error(f"Background deployment error for player '{player_name}': {str(e)}", exc_info=True)
log_action('error', f'Background deployment error for player "{player_name}": {str(e)}')
+115 -1
View File
@@ -3,12 +3,126 @@ import os
from typing import Optional from typing import Optional
from app.models.https_config import HTTPSConfig from app.models.https_config import HTTPSConfig
# Shared reverse-proxy snippet used in every Caddy site block
_PROXY_SNIPPET = """\
reverse_proxy digiserver-app:5000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
transport http {
read_timeout 300s
write_timeout 300s
}
}
request_body {
max_size 2GB
}
encode gzip
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
}
log {
output file /var/log/caddy/access.log
}
"""
class CaddyConfigGenerator: class CaddyConfigGenerator:
"""Generate Caddyfile configuration based on HTTPSConfig.""" """Generate Caddyfile configuration based on HTTPSConfig."""
@staticmethod @staticmethod
def generate_caddyfile(config: Optional[HTTPSConfig] = None) -> str: def generate_caddyfile(config: Optional['HTTPSConfig'] = None) -> str:
"""Generate a complete Caddyfile.
Behaviour:
- HTTPS disabled / no domain HTTP-only on port 80 (initial deploy mode).
- HTTPS enabled + real domain Caddy auto-provisions a Let's Encrypt cert
for that domain; HTTP redirects to HTTPS automatically.
- HTTPS enabled + IP only (no domain) TLS with Caddy's internal CA
(self-signed, trusted within the Docker network).
"""
if config is None:
config = HTTPSConfig.get_config()
email = (config.email or "admin@localhost") if config else "admin@localhost"
https_enabled = config.https_enabled if config else False
domain = (config.domain or "").strip() if config else ""
ip_address = (config.ip_address or "").strip() if config else ""
global_block = f"""{{\n admin 0.0.0.0:2019\n email {email}\n}}\n\n"""
if https_enabled and domain:
# Caddy handles Let's Encrypt + HTTP→HTTPS redirect automatically
# when a plain hostname (no scheme) is used.
caddyfile = global_block
caddyfile += f"{domain} {{\n{_PROXY_SNIPPET}}}\n"
# Also accept requests on the raw IP (HTTP only, no cert needed)
if ip_address:
caddyfile += f"\nhttp://{ip_address} {{\n{_PROXY_SNIPPET}}}\n"
elif https_enabled and ip_address:
# No public domain — use Caddy's internal CA (self-signed)
caddyfile = global_block
caddyfile += f"https://{ip_address} {{\n tls internal\n{_PROXY_SNIPPET}}}\n"
caddyfile += f"\nhttp://{ip_address} {{\n redir https://{ip_address}{{uri}} 301\n}}\n"
else:
# HTTP-only fallback (first deploy, before HTTPS is configured)
caddyfile = "{\n admin 0.0.0.0:2019\n}\n\n"
caddyfile += f":80 {{\n{_PROXY_SNIPPET}}}\n"
return caddyfile
@staticmethod
def write_caddyfile(caddyfile_content: str,
path: str = '/etc/caddy/Caddyfile') -> bool:
"""Write Caddyfile to disk.
The default path is /etc/caddy/Caddyfile the standard location inside
the caddy:2-alpine container when a volume is mounted there.
"""
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
f.write(caddyfile_content)
return True
except Exception as e:
print(f"Error writing Caddyfile: {str(e)}")
return False
@staticmethod
def reload_caddy() -> bool:
"""Push the current Caddyfile to Caddy via its admin API (/load).
Caddy applies the new config live without dropping connections.
"""
try:
import urllib.request
caddyfile_path = '/etc/caddy/Caddyfile'
if not os.path.exists(caddyfile_path):
print(f"Caddyfile not found at {caddyfile_path}")
return False
with open(caddyfile_path, 'rb') as f:
caddyfile_bytes = f.read()
req = urllib.request.Request(
'http://caddy:2019/load',
data=caddyfile_bytes,
headers={'Content-Type': 'text/caddyfile'},
method='POST',
)
response = urllib.request.urlopen(req, timeout=10)
return response.status == 200
except Exception as e:
print(f"Caddy reload error: {str(e)}")
return False
"""Generate complete Caddyfile content. """Generate complete Caddyfile content.
Args: Args:
+223
View File
@@ -0,0 +1,223 @@
"""Utilities for building/staging the player files on the server.
Admins use the "Build player files" admin page to:
* clone/refresh the player source code from a git repository into a local
staged directory (``PLAYER_CODE_DIR``), and
* write a base ``config/app_config.json`` so the staged code already knows how
to reach this server.
The SSH deployment flow then ships this staged directory to player devices, so
the version admins build here is exactly what gets deployed.
"""
import os
import json
import shutil
import subprocess
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from app.utils.ssh_deploy import generate_app_config
logger = logging.getLogger(__name__)
# Metadata file name stored in the Flask instance folder.
BUILD_META_FILENAME = 'player_build.json'
def _run_git(args, cwd=None, timeout=300) -> subprocess.CompletedProcess:
return subprocess.run(
['git'] + args,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
)
def get_short_head(player_code_dir: str) -> str:
"""Return the short git commit of the staged code, or 'unknown'."""
try:
result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return 'unknown'
def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main') -> Dict[str, Any]:
"""Clone or refresh the player source into ``player_code_dir``.
If the directory is already a git checkout of ``repo_url`` it is updated in
place (fetch + hard reset to the chosen branch). Otherwise it is cloned
fresh (an existing non-git directory is replaced).
Returns a dict: ``success`` (bool), ``message`` (str), ``version`` (str),
``branch`` (str).
"""
branch = (branch or 'main').strip()
repo_url = (repo_url or '').strip()
if not repo_url:
return {'success': False, 'message': 'Repository URL is required.', 'version': None, 'branch': branch}
try:
git_dir = os.path.join(player_code_dir, '.git')
is_git_repo = os.path.isdir(git_dir)
if is_git_repo:
# Update existing checkout in place.
fetch = _run_git(['-C', player_code_dir, 'fetch', '--prune', 'origin'])
if fetch.returncode != 0:
return {
'success': False,
'message': f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
# Point origin at the requested URL in case it changed.
_run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url])
checkout = _run_git(['-C', player_code_dir, 'checkout', branch])
if checkout.returncode != 0:
return {
'success': False,
'message': f'git checkout {branch} failed: {checkout.stderr.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}'])
if reset.returncode != 0:
return {
'success': False,
'message': f'git reset failed: {reset.stderr.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
action = 'Updated'
else:
# Fresh clone. Replace any existing (non-git) directory.
parent = os.path.dirname(player_code_dir.rstrip('/'))
os.makedirs(parent, exist_ok=True)
if os.path.exists(player_code_dir):
shutil.rmtree(player_code_dir)
clone = _run_git(['clone', '--branch', branch, repo_url, player_code_dir])
if clone.returncode != 0:
return {
'success': False,
'message': f'git clone failed: {clone.stderr.strip() or clone.stdout.strip()}',
'version': None,
'branch': branch,
}
action = 'Cloned'
version = get_short_head(player_code_dir)
logger.info('%s player code from %s (%s) -> %s', action, repo_url, branch, version)
return {
'success': True,
'message': f'{action} player code from {branch} (version {version}).',
'version': version,
'branch': branch,
}
except subprocess.TimeoutExpired:
return {'success': False, 'message': 'Git operation timed out.', 'version': None, 'branch': branch}
except Exception as e:
logger.exception('build_player_files failed')
return {'success': False, 'message': f'Build failed: {str(e)}', 'version': None, 'branch': branch}
def write_base_config(
player_code_dir: str,
server_ip: str,
port: str,
use_https: bool = True,
verify_ssl: bool = False,
orientation: str = 'Landscape',
max_resolution: str = '1920x1080',
) -> Dict[str, Any]:
"""Write a base ``config/app_config.json`` into the staged player code.
``screen_name`` and ``quickconnect_key`` are left blank on purpose: they are
per-player and get filled in by the SSH deploy step for each device.
"""
try:
config_dir = os.path.join(player_code_dir, 'config')
os.makedirs(config_dir, exist_ok=True)
content = generate_app_config(
server_ip=server_ip,
port=str(port),
screen_name='',
quickconnect_code='',
orientation=orientation,
use_https=use_https,
verify_ssl=verify_ssl,
max_resolution=max_resolution,
)
config_path = os.path.join(config_dir, 'app_config.json')
with open(config_path, 'w', encoding='utf-8') as f:
f.write(content)
logger.info('Wrote base player config -> %s', config_path)
return {'success': True, 'message': 'Base player config written.', 'path': config_path}
except Exception as e:
logger.exception('write_base_config failed')
return {'success': False, 'message': f'Failed to write config: {str(e)}'}
def load_build_settings(meta_path: str) -> Optional[Dict[str, Any]]:
"""Load saved build settings from ``meta_path`` (or None if absent/invalid)."""
try:
if os.path.isfile(meta_path):
with open(meta_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.warning('Could not read build settings: %s', e)
return None
def save_build_settings(meta_path: str, data: Dict[str, Any]) -> bool:
"""Persist build settings to ``meta_path``."""
try:
os.makedirs(os.path.dirname(meta_path), exist_ok=True)
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
return True
except Exception as e:
logger.warning('Could not save build settings: %s', e)
return False
def get_player_server_settings(meta_path: str) -> Optional[Dict[str, Any]]:
"""Return the saved server address settings for deployment, if available.
Returns a dict with ``server_ip``, ``port`` (str), ``use_https`` (bool) and
``verify_ssl`` (bool), or None when no usable build settings are saved.
"""
settings = load_build_settings(meta_path)
if not settings:
return None
server_ip = (settings.get('server_ip') or '').strip()
if not server_ip:
return None
return {
'server_ip': server_ip,
'port': str(settings.get('port') or ('443' if settings.get('use_https', True) else '80')),
'use_https': bool(settings.get('use_https', True)),
'verify_ssl': bool(settings.get('verify_ssl', False)),
}
def make_build_record(repo_url, branch, server_ip, port, use_https, verify_ssl,
orientation, max_resolution, version, built_by) -> Dict[str, Any]:
"""Assemble the metadata record to persist after a build."""
return {
'repo_url': repo_url,
'branch': branch,
'server_ip': server_ip,
'port': str(port),
'use_https': bool(use_https),
'verify_ssl': bool(verify_ssl),
'orientation': orientation,
'max_resolution': max_resolution,
'built_version': version,
'built_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
'built_by': built_by,
}
+52
View File
@@ -0,0 +1,52 @@
"""
Portal SSO middleware for DigiServer v2.
When the umbrella nginx verifies the portal JWT it sets two headers:
X-Auth-Username the portal username
X-Auth-Role 'admin' or 'user'
This before_request handler reads those headers and auto-logs in the
corresponding local DigiServer user, creating them on first access if
needed. The local session is then maintained normally by Flask-Login.
"""
import secrets
from flask import request
from flask_login import login_user, current_user
def init_portal_sso(app):
"""Register the SSO before_request handler on the given Flask app."""
@app.before_request
def _portal_sso():
if current_user.is_authenticated:
return
username = request.headers.get('X-Auth-Username', '').strip()
if not username:
return
role = request.headers.get('X-Auth-Role', 'user').strip()
user = _get_or_create_user(username, role)
if user:
login_user(user, remember=False)
def _get_or_create_user(username, role):
from app.models.user import User
from app.extensions import db, bcrypt
try:
user = User.query.filter_by(username=username).first()
if not user:
hashed_pw = bcrypt.generate_password_hash(secrets.token_hex(32)).decode('utf-8')
user = User(
username=username,
password=hashed_pw,
role='admin' if role == 'admin' else 'user',
)
db.session.add(user)
db.session.commit()
return user
except Exception:
return None
+26
View File
@@ -0,0 +1,26 @@
"""
ScriptNameFix WSGI middleware.
When nginx strips the path prefix before forwarding to a Flask app it also
sets the X-Script-Name header (e.g. /digiserver). This middleware reads
that header and sets SCRIPT_NAME in the WSGI environ so that Flask's
url_for() generates absolute URLs with the correct prefix.
Usage in the app factory:
from app.utils.script_name_fix import ScriptNameFix
app.wsgi_app = ScriptNameFix(app.wsgi_app)
"""
class ScriptNameFix:
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
script_name = environ.get('HTTP_X_SCRIPT_NAME', '').rstrip('/')
if script_name:
environ['SCRIPT_NAME'] = script_name
path_info = environ.get('PATH_INFO', '/')
if path_info.startswith(script_name):
environ['PATH_INFO'] = path_info[len(script_name):] or '/'
return self.app(environ, start_response)
+753
View File
@@ -0,0 +1,753 @@
"""SSH deployment utilities for player provisioning."""
import subprocess
import logging
import os
import json
from typing import Tuple, Dict, Any, Optional
from datetime import datetime
logger = logging.getLogger(__name__)
# Pre-staged player code location in container
LOCAL_PLAYER_CODE_DIR = '/app/data/player'
def get_local_player_code_status(player_code_dir: Optional[str] = None) -> Dict[str, Any]:
"""
Check status of pre-staged player code.
Args:
player_code_dir: Optional override for the staged code path. Defaults to
``LOCAL_PLAYER_CODE_DIR`` (the container location).
Returns:
Dict with availability, version, and path info
"""
code_dir = player_code_dir or LOCAL_PLAYER_CODE_DIR
try:
if not os.path.isdir(code_dir):
return {
'available': False,
'reason': 'Directory not found',
'path': code_dir
}
# Check if git repository
git_dir = os.path.join(code_dir, '.git')
if not os.path.isdir(git_dir):
return {
'available': False,
'reason': 'Not a git repository',
'path': code_dir
}
# Get current git version
try:
result = subprocess.run(
['git', '-C', code_dir, 'rev-parse', '--short', 'HEAD'],
capture_output=True,
text=True,
timeout=5
)
version = result.stdout.strip() if result.returncode == 0 else 'unknown'
except:
version = 'unknown'
# Get directory size
try:
result = subprocess.run(
['du', '-sh', code_dir],
capture_output=True,
text=True,
timeout=5
)
size = result.stdout.split()[0] if result.returncode == 0 else 'unknown'
except:
size = 'unknown'
return {
'available': True,
'path': code_dir,
'version': version,
'size': size,
'updated': os.path.getmtime(git_dir),
'reason': 'Pre-staged code ready for deployment'
}
except Exception as e:
logger.warning(f'Error checking player code status: {str(e)}')
return {
'available': False,
'reason': f'Status check failed: {str(e)}',
'path': code_dir
}
def test_ssh_connection(hostname: str, username: str, password: str, port: int = 22) -> Dict[str, Any]:
"""
Test SSH connection to a remote host.
Args:
hostname: Target hostname or IP
username: SSH username
password: SSH password
port: SSH port (default 22)
Returns:
Dict with status, message, and timestamp
"""
try:
# Use sshpass to test connection without interactive prompt
cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-o', 'ConnectTimeout=10',
'-p', str(port),
f'{username}@{hostname}',
'echo "SSH connection successful"'
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=15
)
if result.returncode == 0:
return {
'success': True,
'message': f'SSH connection successful to {hostname}',
'timestamp': datetime.now().isoformat(),
'output': result.stdout.strip()
}
else:
error_msg = result.stderr.strip() or result.stdout.strip()
return {
'success': False,
'message': f'SSH connection failed: {error_msg}',
'timestamp': datetime.now().isoformat(),
'error': error_msg
}
except subprocess.TimeoutExpired:
return {
'success': False,
'message': f'SSH connection timeout to {hostname}',
'timestamp': datetime.now().isoformat(),
'error': 'Connection timeout (10s)'
}
except Exception as e:
logger.error(f'SSH test error: {str(e)}')
return {
'success': False,
'message': f'SSH connection error: {str(e)}',
'timestamp': datetime.now().isoformat(),
'error': str(e)
}
def generate_player_config(
player_name: str,
server_url: str,
api_key: str,
player_id: str = None,
location: str = None
) -> str:
"""
Generate player configuration JSON for connecting to DigiServer.
Args:
player_name: Name of the player
server_url: DigiServer base URL (e.g., http://localhost/digiserver)
api_key: API authentication key
player_id: Optional player ID (defaults to player_name)
location: Optional player location/description
Returns:
JSON configuration string
"""
config = {
"player": {
"name": player_name,
"id": player_id or player_name,
"location": location or "",
"version": "2.0"
},
"server": {
"url": server_url,
"api_endpoint": f"{server_url}/api",
"authentication": {
"type": "api_key",
"key": api_key
},
"endpoints": {
"playlists": f"{server_url}/api/playlists",
"content": f"{server_url}/api/content",
"schedule": f"{server_url}/api/schedule",
"heartbeat": f"{server_url}/api/player/heartbeat",
"logs": f"{server_url}/api/player/logs"
}
},
"playback": {
"audio_enabled": True,
"video_enabled": True,
"max_resolution": "4K",
"refresh_interval": 60,
"rotation": "0"
},
"networking": {
"timeout": 30,
"retry_count": 3,
"retry_delay": 5
}
}
return json.dumps(config, indent=2)
def detect_server_ip() -> Optional[str]:
"""Best-effort detection of this server's primary LAN IP address.
Opens a UDP socket toward a public address (no packets are actually sent)
and reads the local socket address, which resolves to the IP of the
interface used for outbound traffic. Returns None on failure.
"""
import socket
s = None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
if ip and not ip.startswith('127.'):
return ip
except Exception:
pass
finally:
if s is not None:
try:
s.close()
except Exception:
pass
# Fallback via hostname resolution.
try:
ip = socket.gethostbyname(socket.gethostname())
if ip and not ip.startswith('127.'):
return ip
except Exception:
pass
return None
def parse_server_address(server_url: str) -> Dict[str, Any]:
"""Derive the values the player needs from a DigiServer URL.
The player's config/app_config.json stores server_ip + port + use_https and
builds requests as ``{scheme}://{server_ip}:{port}/api/...`` (it does NOT use
any URL path prefix such as ``/digiserver``). This helper extracts the host,
port and scheme from a server URL and drops any path component.
Args:
server_url: e.g. ``https://signage.example.com/digiserver`` or
``http://192.168.0.50:8080``
Returns:
Dict with ``server_ip`` (str), ``port`` (str) and ``use_https`` (bool).
"""
from urllib.parse import urlparse
parsed = urlparse(server_url if '://' in (server_url or '') else f'//{server_url}')
use_https = (parsed.scheme or 'https') == 'https'
host = parsed.hostname or ''
port = parsed.port
if port is None:
port = 443 if use_https else 80
return {'server_ip': host, 'port': str(port), 'use_https': use_https}
def generate_app_config(
server_ip: str,
port: str,
screen_name: str,
quickconnect_code: str,
orientation: str = 'Landscape',
use_https: bool = True,
verify_ssl: bool = False,
max_resolution: str = '1920x1080',
) -> str:
"""Generate the config/app_config.json the player actually reads.
This is what binds a deployed player to the real server and its assigned
playlist: the player authenticates with ``screen_name`` + ``quickconnect_code``
and the server returns the playlist assigned to that player.
Args:
server_ip: Server IP or domain the player should contact.
port: Server port as a string.
screen_name: Player hostname / screen identity (matches Player.hostname).
quickconnect_code: Quick connect code (matches Player.quickconnect_code).
orientation: Landscape or Portrait.
use_https: Whether the player should use HTTPS.
verify_ssl: Whether the player should verify the TLS certificate.
max_resolution: Maximum playback resolution.
Returns:
JSON configuration string.
"""
config = {
'server_ip': server_ip,
'port': str(port),
'screen_name': screen_name,
'quickconnect_key': quickconnect_code,
'orientation': orientation or 'Landscape',
'touch': 'True',
'max_resolution': max_resolution,
'edit_feature_enabled': True,
'use_https': bool(use_https),
'verify_ssl': bool(verify_ssl),
}
return json.dumps(config, indent=2)
def deploy_player_to_host(
hostname: str,
username: str,
password: str,
player_name: str,
repo_url: str = 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git',
deploy_path: str = None, # Default: /home/[user]/kiwy-signage
port: int = 22,
server_url: str = None, # DigiServer URL for player to connect to
server_api_key: str = None, # API key for player authentication
player_hostname: str = None, # Player screen identity (Player.hostname)
quickconnect_code: str = None, # Player quick connect code
orientation: str = 'Landscape', # Player orientation
verify_ssl: bool = False, # Whether the player should verify TLS
) -> Dict[str, Any]:
"""
Deploy player code to remote host.
Args:
hostname: Target hostname or IP
username: SSH username
password: SSH password
player_name: Name for the player instance
repo_url: Git repository URL
deploy_path: Path where to deploy on remote host (default: /home/[user]/kiwy-signage)
port: SSH port (default 22)
server_url: DigiServer URL for player connection
server_api_key: API key for player authentication
player_hostname: Player screen identity used for auth (Player.hostname)
quickconnect_code: Quick connect code used for auth (Player.quickconnect_code)
orientation: Player orientation (Landscape/Portrait)
verify_ssl: Whether the player should verify the server TLS certificate
Returns:
Dict with deployment status and output
"""
# Set default deployment path to user's home directory
if deploy_path is None:
deploy_path = f'/home/{username}/kiwy-signage'
try:
# Step 1: Verify host accessibility
test_result = test_ssh_connection(hostname, username, password, port)
if not test_result['success']:
return {
'success': False,
'message': 'Cannot deploy: SSH connection failed',
'timestamp': datetime.now().isoformat(),
'error': test_result['message'],
'steps': []
}
steps = [
{
'step': 'SSH Connection Test',
'status': 'completed',
'message': 'SSH connection successful',
'timestamp': datetime.now().isoformat()
}
]
# Step 2: Create deployment directory
try:
mkdir_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'mkdir -p {deploy_path}'
]
result = subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=30)
steps.append({
'step': 'Create Deploy Directory',
'status': 'completed' if result.returncode == 0 else 'failed',
'message': f'Directory {deploy_path} created',
'timestamp': datetime.now().isoformat()
})
except Exception as e:
steps.append({
'step': 'Create Deploy Directory',
'status': 'failed',
'message': f'Failed: {str(e)}',
'timestamp': datetime.now().isoformat()
})
return {
'success': False,
'message': f'Deployment failed at step: Create Deploy Directory',
'timestamp': datetime.now().isoformat(),
'error': str(e),
'steps': steps
}
# Step 3: Deploy code (use local if available, otherwise clone from git)
try:
code_status = get_local_player_code_status()
if code_status['available']:
# Use pre-staged player code via rsync
logger.info(f'Using pre-staged player code (version: {code_status.get("version", "unknown")})')
rsync_cmd = [
'sshpass', '-p', password,
'rsync', '-avz',
'--delete',
'-e', f'ssh -o StrictHostKeyChecking=no -p {port}',
f'{LOCAL_PLAYER_CODE_DIR}/',
f'{username}@{hostname}:{deploy_path}/'
]
result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=300)
steps.append({
'step': 'Deploy Code',
'status': 'completed' if result.returncode == 0 else 'failed',
'message': f'Code deployed via rsync (version: {code_status.get("version", "local")})',
'timestamp': datetime.now().isoformat()
})
if result.returncode != 0:
logger.warning(f'Rsync failed, falling back to git clone: {result.stderr}')
# Fall back to git clone
raise Exception('Rsync failed, retrying with git')
else:
# No local code, clone from repository
logger.info(f'No pre-staged code available ({code_status.get("reason", "unknown")}), cloning from repository')
raise Exception('Local code not available')
except Exception as rsync_error:
# Fallback: Clone or pull repository
try:
logger.info(f'Deploying via git: {rsync_error}')
# Check if repo already exists
check_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'[ -d {deploy_path}/.git ]'
]
result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
# Repo exists, pull latest
git_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'cd {deploy_path} && git pull origin main 2>&1'
]
git_msg = 'Pull latest code'
else:
# Clone repository
git_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'git clone {repo_url} {deploy_path} 2>&1'
]
git_msg = 'Clone repository'
result = subprocess.run(git_cmd, capture_output=True, text=True, timeout=120)
steps.append({
'step': 'Deploy Code',
'status': 'completed' if result.returncode == 0 else 'failed',
'message': f'{git_msg}: {result.stdout.split(chr(10))[0][:100]}',
'timestamp': datetime.now().isoformat()
})
if result.returncode != 0:
return {
'success': False,
'message': f'Deployment failed at step: Deploy Code',
'timestamp': datetime.now().isoformat(),
'error': result.stderr or result.stdout,
'steps': steps
}
except Exception as e:
steps.append({
'step': 'Deploy Code',
'status': 'failed',
'message': f'Failed: {str(e)}',
'timestamp': datetime.now().isoformat()
})
return {
'success': False,
'message': f'Deployment failed at step: Deploy Code',
'timestamp': datetime.now().isoformat(),
'error': str(e),
'steps': steps
}
# Step 3.5: Generate player configuration (config/app_config.json)
# This is the file the player actually reads to learn the server address
# and its screen identity. Authenticating with that identity is what binds
# the player to its assigned playlist on the real server.
install_env_prefix = ''
try:
screen_name = player_hostname or player_name
if server_url and screen_name and quickconnect_code:
addr = parse_server_address(server_url)
app_config_content = generate_app_config(
server_ip=addr['server_ip'],
port=addr['port'],
screen_name=screen_name,
quickconnect_code=quickconnect_code,
orientation=orientation or 'Landscape',
use_https=addr['use_https'],
verify_ssl=verify_ssl,
)
# Build an env prefix so install.sh's configure_player() also runs
# (single, consistent configuration path on the player side).
import shlex
env_pairs = {
'KIWY_SERVER_IP': addr['server_ip'],
'KIWY_PORT': addr['port'],
'KIWY_SCREEN_NAME': screen_name,
'KIWY_QUICKCONNECT': quickconnect_code,
'KIWY_ORIENTATION': orientation or 'Landscape',
'KIWY_USE_HTTPS': 'true' if addr['use_https'] else 'false',
'KIWY_VERIFY_SSL': 'true' if verify_ssl else 'false',
}
install_env_prefix = ' '.join(
f'{k}={shlex.quote(str(v))}' for k, v in env_pairs.items()
) + ' '
# Write config/app_config.json directly (robust even if install.sh
# is missing or fails), and clear any stale baked-in auth.
remote_cmd = (
f'mkdir -p {deploy_path}/config && '
f"cat > {deploy_path}/config/app_config.json << 'EOF'\n"
f'{app_config_content}\n'
f'EOF\n'
f'rm -f {deploy_path}/player_auth.json {deploy_path}/src/player_auth.json 2>/dev/null || true'
)
write_config_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
remote_cmd
]
result = subprocess.run(write_config_cmd, capture_output=True, text=True, timeout=30)
steps.append({
'step': 'Configure Player',
'status': 'completed' if result.returncode == 0 else 'warning',
'message': (
f'Wrote config/app_config.json '
f'(server {addr["server_ip"]}:{addr["port"]}, screen {screen_name})'
),
'timestamp': datetime.now().isoformat()
})
else:
steps.append({
'step': 'Configure Player',
'status': 'skipped',
'message': 'Missing server_url / player hostname / quickconnect; player not auto-configured',
'timestamp': datetime.now().isoformat()
})
except Exception as e:
logger.warning(f'Failed to create player config: {str(e)}')
steps.append({
'step': 'Configure Player',
'status': 'warning',
'message': f'Failed to write player config: {str(e)}',
'timestamp': datetime.now().isoformat()
})
# Step 4: Run installation script
# Before running the install script, grant the SSH user temporary
# passwordless sudo so that any 'sudo apt-get / pip install' calls inside
# install.sh don't hang waiting for an interactive password prompt.
# The sudoers entry is removed automatically after the script finishes.
try:
sudoers_file = f'/etc/sudoers.d/kiwy_deploy_{username}'
setup_sudo_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
(
f'echo {password!r} | sudo -S bash -c '
f'"echo \\"{username} ALL=(ALL) NOPASSWD:ALL\\" '
f'> {sudoers_file} && chmod 440 {sudoers_file}" 2>&1'
)
]
sudo_result = subprocess.run(setup_sudo_cmd, capture_output=True, text=True, timeout=15)
sudo_configured = sudo_result.returncode == 0
if not sudo_configured:
logger.warning(f'Could not configure passwordless sudo: {sudo_result.stderr[:200]}')
except Exception as e:
sudo_configured = False
logger.warning(f'Passwordless sudo setup failed: {str(e)}')
try:
# Build a shell one-liner: run the first install script found.
# Priority: install.sh > setup.sh > install_player.sh > any *.sh except start.sh
run_install_cmd = (
f'cd {deploy_path} && '
f'INSTALL_SCRIPT="" && '
f'for s in install.sh setup.sh install_player.sh; do '
f' if [ -f "$s" ]; then INSTALL_SCRIPT="$s"; break; fi; '
f'done && '
f'if [ -z "$INSTALL_SCRIPT" ]; then '
f' INSTALL_SCRIPT=$(ls *.sh 2>/dev/null | grep -v "^start.sh$" | head -1); '
f'fi && '
f'if [ -n "$INSTALL_SCRIPT" ]; then '
f' chmod +x "$INSTALL_SCRIPT" && '
f' echo "Running $INSTALL_SCRIPT" && '
f' {install_env_prefix}bash "$INSTALL_SCRIPT" 2>&1; '
f' echo "Exit code: $?"; '
f'else '
f' echo "No install script found"; '
f'fi'
)
install_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
run_install_cmd
]
result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=600)
output = (result.stdout or '').strip()
if 'No install script found' in output:
steps.append({
'step': 'Run Installation Script',
'status': 'skipped',
'message': 'No install script found in deploy directory',
'timestamp': datetime.now().isoformat()
})
else:
script_line = next((l for l in output.splitlines() if l.startswith('Running ')), '')
script_name = script_line.replace('Running ', '').strip() or 'install script'
steps.append({
'step': 'Run Installation Script',
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
'message': f'Executed {script_name} (exit {result.returncode})',
'timestamp': datetime.now().isoformat()
})
if result.returncode != 0:
logger.warning(f'Install script exited {result.returncode}: {result.stderr[:200]}')
else:
logger.info(f'Install script completed successfully on {hostname}')
except subprocess.TimeoutExpired:
steps.append({
'step': 'Run Installation Script',
'status': 'completed_with_warnings',
'message': 'Install script timed out after 600s — it may still be running on the device',
'timestamp': datetime.now().isoformat()
})
except Exception as e:
steps.append({
'step': 'Run Installation Script',
'status': 'error',
'message': f'Error running installation: {str(e)}',
'timestamp': datetime.now().isoformat()
})
logger.error(f'Installation script error: {str(e)}')
finally:
# Always clean up the temporary sudoers entry
if sudo_configured:
try:
cleanup_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'sudo rm -f {sudoers_file} 2>/dev/null || true'
]
subprocess.run(cleanup_cmd, capture_output=True, text=True, timeout=10)
except Exception:
pass
# Step 5: Start player service (execute start.sh)
try:
# Check if start.sh exists
check_start = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'[ -f {deploy_path}/start.sh ]'
]
start_check = subprocess.run(check_start, capture_output=True, text=True, timeout=10)
if start_check.returncode == 0:
# Make sure start.sh is executable and run it
start_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'cd {deploy_path} && chmod +x start.sh && bash start.sh 2>&1'
]
result = subprocess.run(start_cmd, capture_output=True, text=True, timeout=300)
# Capture first line of output for feedback
output_msg = result.stdout.split('\n')[0][:100] if result.stdout else 'Started'
steps.append({
'step': 'Start Player Service',
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
'message': f'Player service started: {output_msg}',
'timestamp': datetime.now().isoformat()
})
logger.info(f'Player service started on {hostname} at {deploy_path}')
else:
steps.append({
'step': 'Start Player Service',
'status': 'warning',
'message': 'start.sh not found - player may require manual startup',
'timestamp': datetime.now().isoformat()
})
logger.warning(f'start.sh not found at {deploy_path}/start.sh on {hostname}')
except Exception as e:
steps.append({
'step': 'Start Player Service',
'status': 'error',
'message': f'Error starting player service: {str(e)}',
'timestamp': datetime.now().isoformat()
})
logger.error(f'Failed to start player service: {str(e)}')
return {
'success': True,
'message': f'Player "{player_name}" deployed successfully to {hostname}',
'timestamp': datetime.now().isoformat(),
'deploy_path': deploy_path,
'steps': steps
}
except Exception as e:
logger.error(f'Deployment error: {str(e)}')
return {
'success': False,
'message': f'Unexpected deployment error: {str(e)}',
'timestamp': datetime.now().isoformat(),
'error': str(e),
'steps': []
}
+13 -14
View File
@@ -5,8 +5,11 @@ services:
build: . build: .
container_name: digiserver-v2 container_name: digiserver-v2
# Don't expose directly; use Caddy reverse proxy instead # Don't expose directly; use Caddy reverse proxy instead
# Port 5000 is also mapped directly for dev/testing access when Caddy isn't running
expose: expose:
- "5000" - "5000"
ports:
- "5000:5000"
volumes: volumes:
# Code is in the Docker image - no volume mount needed # Code is in the Docker image - no volume mount needed
# Only mount persistent data folders: # Only mount persistent data folders:
@@ -27,22 +30,18 @@ services:
networks: networks:
- digiserver-network - digiserver-network
# Nginx reverse proxy with HTTPS support # Caddy reverse proxy — auto-provisions Let's Encrypt certs when a real domain is configured
nginx: caddy:
image: nginx:alpine image: caddy:2-alpine
container_name: digiserver-nginx container_name: digiserver-caddy
ports: ports:
- "80:80" - "8080:80"
- "443:443" - "8443:443"
volumes: volumes:
- ./data/nginx.conf:/etc/nginx/nginx.conf:ro - ./data/Caddyfile:/etc/caddy/Caddyfile:rw
- ./data/nginx-custom-domains.conf:/etc/nginx/conf.d/custom-domains.conf:rw - ./data/caddy-data:/data
- ./data/nginx-ssl:/etc/nginx/ssl:ro - ./data/caddy-config:/config
- ./data/nginx-logs:/var/log/nginx - ./data/caddy-logs:/var/log/caddy
- ./data/certbot:/var/www/certbot:ro # For Let's Encrypt ACME challenges
environment:
- DOMAIN=${DOMAIN:-localhost}
- EMAIL=${EMAIL:-admin@localhost}
depends_on: depends_on:
digiserver-app: digiserver-app:
condition: service_started condition: service_started
@@ -0,0 +1,30 @@
"""Add deployment tracking columns to player table."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app()
with app.app_context():
print("Adding deployment tracking columns to player table...")
columns = [
("deployment_status", "VARCHAR(50) DEFAULT 'pending'"),
("last_deployment_at", "DATETIME"),
("last_deployment_status", "VARCHAR(50)"),
("last_deployment_message", "TEXT"),
]
for col_name, col_type in columns:
try:
db.session.execute(text(f"ALTER TABLE player ADD COLUMN {col_name} {col_type}"))
db.session.commit()
print(f"'{col_name}' column added to player table.")
except Exception as e:
if 'duplicate column' in str(e).lower() or 'already exists' in str(e).lower():
print(f"'{col_name}' column already exists, skipping.")
else:
print(f"✗ Error adding '{col_name}': {e}")
raise
print("Done.")
+22
View File
@@ -0,0 +1,22 @@
"""Add url column to content table for weblink support."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app()
with app.app_context():
print("Adding 'url' column to content table...")
try:
db.session.execute(text("ALTER TABLE content ADD COLUMN url VARCHAR(2048)"))
db.session.commit()
print("'url' column added to content table.")
except Exception as e:
if 'duplicate column' in str(e).lower() or 'already exists' in str(e).lower():
print("'url' column already exists, skipping.")
else:
print(f"✗ Error: {e}")
raise