diff --git a/Dockerfile b/Dockerfile index ceb7710..ccc7b61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,10 @@ RUN apt-get update && \ libreoffice-core \ libreoffice-impress \ libreoffice-writer \ + sshpass \ + openssh-client \ + rsync \ + git \ && apt-get clean && \ rm -rf /var/lib/apt/lists/* diff --git a/app/app.py b/app/app.py index 641b527..29c83f6 100644 --- a/app/app.py +++ b/app/app.py @@ -41,6 +41,11 @@ def create_app(config_name=None): # Apply ProxyFix middleware for reverse proxy (Nginx/Caddy) # 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) + + # 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 db.init_app(app) @@ -70,7 +75,15 @@ def create_app(config_name=None): register_commands(app) register_context_processors(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 diff --git a/app/blueprints/admin.py b/app/blueprints/admin.py index e754d5a..7440b7b 100644 --- a/app/blueprints/admin.py +++ b/app/blueprints/admin.py @@ -11,7 +11,6 @@ from app.extensions import db, bcrypt from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig from app.utils.logger import log_action 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') @@ -871,14 +870,10 @@ def https_config(): db.session.commit() 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', config=config, is_https_active=is_https_active, - current_host=current_host, - nginx_status=nginx_status) + current_host=current_host) except Exception as e: log_action('error', f'Error loading HTTPS config page: {str(e)}') flash('Error loading HTTPS configuration page.', 'danger') @@ -1015,3 +1010,149 @@ def https_config_status(): except Exception as e: log_action('error', f'Error getting HTTPS status: {str(e)}') 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')) diff --git a/app/blueprints/api.py b/app/blueprints/api.py index d1e0e02..ec65b9d 100644 --- a/app/blueprints/api.py +++ b/app/blueprints/api.py @@ -3,6 +3,7 @@ from flask import Blueprint, request, jsonify, current_app from functools import wraps from datetime import datetime, timedelta import secrets +import hashlib import bcrypt 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): # Generate full URL for content from flask import request as current_request - # Get server base URL 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({ 'id': content.id, @@ -406,7 +411,7 @@ def get_cached_playlist(player_id: int) -> List[Dict]: 'type': content.content_type, 'duration': content._playlist_duration or content.duration or 10, '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, '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 +# ────────────────────────────────────────────────────────────────────────────── +# 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) def api_not_found(error): """Handle 404 errors in API.""" diff --git a/app/blueprints/content.py b/app/blueprints/content.py index ff45392..9c3d77e 100644 --- a/app/blueprints/content.py +++ b/app/blueprints/content.py @@ -6,6 +6,9 @@ from werkzeug.utils import secure_filename from typing import Optional import os import threading +import uuid +from datetime import datetime +from urllib.parse import urlparse from app.extensions import db, cache from app.models import Content, Playlist, Player @@ -201,8 +204,10 @@ def manage_playlist_content(playlist_id: int): # Get content in playlist (ordered) playlist_content = playlist.get_content_ordered() - # Get all available content not in this playlist - all_content = Content.query.all() + # Get all available content not in this playlist. + # 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} 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)) +@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//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//remove-content/', methods=['POST']) @login_required def remove_content_from_playlist(playlist_id: int, content_id: int): @@ -277,6 +428,12 @@ def remove_content_from_playlist(playlist_id: int, content_id: int): (playlist_content.c.content_id == content_id) ) 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() db.session.commit() diff --git a/app/blueprints/players.py b/app/blueprints/players.py index ebf227b..e8bcead 100644 --- a/app/blueprints/players.py +++ b/app/blueprints/players.py @@ -1,5 +1,5 @@ """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 werkzeug.security import generate_password_hash import secrets @@ -41,7 +41,7 @@ def list(): @players_bp.route('/add', methods=['GET', 'POST']) @login_required def add_player(): - """Add a new player.""" + """Add a new player with optional SSH deployment.""" if request.method == 'GET': playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all() return render_template('players/add_player.html', playlists=playlists) @@ -55,6 +55,13 @@ def add_player(): orientation = request.form.get('orientation', 'Landscape') 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 if not name or len(name) < 3: 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') + # 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 success_msg = f''' Player "{name}" created successfully!
- Auth Code: {auth_code}
+ Auth Code: {auth_code}
Hostname: {hostname}
Quick Connect: {quickconnect_code}
- Configure the player with these credentials in app_config.json ''' + + if deployment_initiated: + success_msg += f'⌛ Deployment in Progress Deploying to {ssh_hostname} in background...
' + success_msg += 'Check player status to see deployment completion
' + + success_msg += 'Configure the player with these credentials in app_config.json' flash(success_msg, 'success') return redirect(url_for('players.list')) @@ -426,9 +501,14 @@ def get_player_playlist(player_id: int) -> List[dict]: # Build playlist playlist = [] 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({ 'id': content.id, - 'url': url_for('static', filename=f'uploads/{content.filename}'), + 'url': item_url, 'type': content.content_type, 'duration': getattr(content, '_playlist_duration', content.duration or 10), 'position': getattr(content, '_playlist_position', 0), diff --git a/app/config.py b/app/config.py index 493b176..fa5f812 100644 --- a/app/config.py +++ b/app/config.py @@ -48,6 +48,10 @@ class Config: DEFAULT_ADMIN_USER = os.getenv('ADMIN_USER', 'admin') 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): """Development configuration""" diff --git a/app/models/content.py b/app/models/content.py index d2644f9..f9cd31d 100644 --- a/app/models/content.py +++ b/app/models/content.py @@ -24,6 +24,9 @@ class Content(db.Model): content_type = db.Column(db.String(50), nullable=False, index=True) duration = db.Column(db.Integer, default=10, 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) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) @@ -61,3 +64,7 @@ class Content(db.Model): def is_pdf(self) -> bool: """Check if content is a 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' diff --git a/app/models/player.py b/app/models/player.py index 83abed2..da462e1 100644 --- a/app/models/player.py +++ b/app/models/player.py @@ -41,6 +41,12 @@ class Player(db.Model): playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'), 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 playlist = db.relationship('Playlist', back_populates='players') feedback = db.relationship('PlayerFeedback', back_populates='player', diff --git a/app/templates/admin/admin.html b/app/templates/admin/admin.html index 70845d5..20f23cf 100644 --- a/app/templates/admin/admin.html +++ b/app/templates/admin/admin.html @@ -95,6 +95,17 @@ + +
+

🚀 Build Player Files

+

Clone/refresh the player code from Gitea and configure the server address for deployment

+ +
+

🎨 Logo Customization

diff --git a/app/templates/admin/build_player.html b/app/templates/admin/build_player.html new file mode 100644 index 0000000..b91a9fe --- /dev/null +++ b/app/templates/admin/build_player.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} + +{% block title %}Build Player Files - DigiServer v2{% endblock %} + +{% block content %} +
+ + + +
+

Current Staged Player Code

+ {% if code_status.available %} +

✅ Ready

+
    +
  • Version (git): {{ code_status.version }}
  • +
  • Size: {{ code_status.size }}
  • + {% if code_status.updated_str %} +
  • Last updated: {{ code_status.updated_str }}
  • + {% endif %} +
  • Path: {{ code_status.path }}
  • +
+ {% else %} +

+ ⚠️ Not staged + {{ code_status.reason }} +

+

Path: {{ code_status.path }}

+ {% endif %} + {% if settings.built_at %} +

+ Last build saved: {{ settings.built_at }} + {% if settings.built_by %}by {{ settings.built_by }}{% endif %} +

+ {% endif %} +
+ +
+ +
+

1. Player Files Repository

+
+ + + Cloned/refreshed into the staged directory on this server. +
+
+ + +
+
+ + +
+

2. Player → Server Configuration

+

+ The player contacts the DigiServer API directly at + {scheme}://{server}:{port}/api/... (no /digiserver path). + Enter the address players can reach this server on. +

+
+ + +
+
+ + + Use 443 for HTTPS, 80 for plain HTTP, or your custom port (e.g. 8080). +
+
+ +
+
+ + Leave off for self-signed certificates. +
+
+ + +
+
+ + +
+

+ ℹ️ The per-player screen name and quick-connect code + are filled in automatically for each device during SSH deployment. +

+
+ + +
+

3. Build

+
+ + + +
+
+
+
+{% endblock %} diff --git a/app/templates/admin/https_config.html b/app/templates/admin/https_config.html index 7eb2b7a..bd215cd 100644 --- a/app/templates/admin/https_config.html +++ b/app/templates/admin/https_config.html @@ -160,93 +160,35 @@
- +
-

🔧 Nginx Reverse Proxy Status

- {% if nginx_status.available %} -
-
- Status: - ✅ Nginx Configured -
- -
- Configuration Path: - {{ nginx_status.path }} -
- - {% if nginx_status.ssl_enabled %} -
- SSL/TLS: - 🔒 Enabled -
+

🔧 Caddy Reverse Proxy Status

+
+
+ Reverse Proxy: + ✅ Caddy +
+
+ SSL/TLS: + {% if config and config.https_enabled %} + 🔒 Enabled — auto-managed by Caddy {% else %} -
- SSL/TLS: - ⚠️ Not Configured -
- {% endif %} - - {% if nginx_status.http_ports %} -
- HTTP Ports: - {{ nginx_status.http_ports|join(', ') }} -
- {% endif %} - - {% if nginx_status.https_ports %} -
- HTTPS Ports: - {{ nginx_status.https_ports|join(', ') }} -
- {% endif %} - - {% if nginx_status.server_names %} -
- Server Names: - {% for name in nginx_status.server_names %} - {{ name }}{% if not loop.last %}
{% endif %} - {% endfor %} -
- {% endif %} - - {% if nginx_status.upstream_servers %} -
- Upstream Servers: - {% for server in nginx_status.upstream_servers %} - {{ server }}{% if not loop.last %}
{% endif %} - {% endfor %} -
- {% endif %} - - {% if nginx_status.ssl_protocols %} -
- SSL Protocols: - {{ nginx_status.ssl_protocols|join(', ') }} -
- {% endif %} - - {% if nginx_status.client_max_body_size %} -
- Max Body Size: - {{ nginx_status.client_max_body_size }} -
- {% endif %} - - {% if nginx_status.gzip_enabled %} -
- Gzip Compression: - ✅ Enabled -
+ ⚠️ HTTP only — enable HTTPS above {% endif %}
- {% else %} -
-

⚠️ Nginx configuration not accessible

-

Error: {{ nginx_status.error|default('Unknown error') }}

-

Path checked: {{ nginx_status.path }}

+
+ Certificate Provider: + Let's Encrypt (automatic)
- {% endif %} +
+ Admin API: + caddy:2019 +
+
+ Upstream: + digiserver-app:5000 +
+
diff --git a/app/templates/content/manage_playlist_content.html b/app/templates/content/manage_playlist_content.html index 3d3e3b8..0b64b45 100644 --- a/app/templates/content/manage_playlist_content.html +++ b/app/templates/content/manage_playlist_content.html @@ -314,11 +314,18 @@ ⋮⋮ {{ loop.index }} - {{ content.filename }} + + {% if content.content_type == 'weblink' %} + {{ content.url }} + {% else %} + {{ content.filename }} + {% endif %} + {% if content.content_type == 'image' %}📷 Image {% elif content.content_type == 'video' %}🎥 Video {% elif content.content_type == 'pdf' %}📄 PDF + {% elif content.content_type == 'weblink' %}🔗 Web Link {% else %}📁 Other{% endif %} @@ -401,6 +408,22 @@

➕ Add Content

+
+

🔗 Add Web Link

+
+ +
+ + + +
+
+
+ {% if available_content %}
{% for content in available_content %} diff --git a/app/templates/content/upload_media.html b/app/templates/content/upload_media.html index 3389ff6..6310448 100644 --- a/app/templates/content/upload_media.html +++ b/app/templates/content/upload_media.html @@ -317,6 +317,66 @@
+ +
+

+ 🌐 Add Web Page Link +

+

+ Add a website URL to display on the player (e.g. a dashboard, live feed, or any public web page). +

+ + +
+ {% endblock %} diff --git a/app/templates/players/add_player.html b/app/templates/players/add_player.html index fd6caf6..2b5342a 100644 --- a/app/templates/players/add_player.html +++ b/app/templates/players/add_player.html @@ -4,135 +4,156 @@ {% block content %} -
+ +

Add New Player

-

- Create a new digital signage player with authentication credentials +

+ Choose how you want to add the player to the system.

-
+ +
+
+
📋
+
Manual Registration
+
+ Register the player in the database and wait for the client to connect. + You configure app_config.json on the device yourself. +
+
+
+
🚀
+
Create & Deploy
+
+ Register the player and automatically push the Linux player + code to the target host via SSH. +
+
+
+ + +
-

- Basic Information -

- +

Basic Information

+
- Unique identifier for this player (must match screen_name in player config) + Unique identifier — must match screen_name in the player's + app_config.json
+ placeholder="e.g., Main Office – Reception Area"> Physical location of the player (optional)
-

- Authentication -

+

Authentication

Choose one authentication method (Quick Connect recommended for easy setup)

- - - Secure password for player authentication (optional if using Quick Connect) - + Secure password for player authentication (optional if using Quick Connect)
@@ -177,13 +195,11 @@ - Easy pairing code for quick setup (must match quickconnect_key in player config) + Easy pairing code — must match quickconnect_key in player config
-

- Display Settings -

+

Display Settings

@@ -199,7 +215,9 @@ Assign player to a playlist (optional) @@ -208,16 +226,16 @@

📋 Setup Instructions

    -
  1. Create the player with the form above
  2. -
  3. Note the generated Auth Code (shown after creation)
  4. -
  5. Configure the player's app_config.json with: +
  6. Create the player with the form above.
  7. +
  8. Note the generated Auth Code shown after creation.
  9. +
  10. Configure the player's app_config.json:
      -
    • server_ip: Your server address
    • -
    • screen_name: Same as Hostname above
    • -
    • quickconnect_key: Same as Quick Connect Code above
    • +
    • server_ip — your server address
    • +
    • screen_name — same as Hostname above
    • +
    • quickconnect_key — same as Quick Connect Code above
  11. -
  12. Start the player - it will authenticate automatically
  13. +
  14. Start the player — it will authenticate automatically.
@@ -225,11 +243,257 @@ - + Cancel
+ + +
+ + +
+

+ 🔌 Step 1 — SSH Connection +

+

+ Test SSH connectivity to the target Linux host before proceeding. +

+ +
+
+ + + IP address or hostname of the target machine +
+
+ + + SSH port (default: 22) +
+
+ +
+
+ + + SSH login username +
+
+ + + SSH login password +
+
+ + + + +
+
+ + +
+
+ + + + + + + +

Step 2 — Basic Information

+ +
+ + + Friendly name for the player +
+ +
+ + + + Unique identifier — will be written to app_config.json + on the remote host automatically + +
+ +
+ + + Physical location (optional) +
+ +

Authentication

+ +
+ + + Secure password (optional if using Quick Connect) +
+ +
+ + + Easy pairing code — deployed automatically to remote config +
+ +

Display Settings

+ +
+ + + Display orientation for the player +
+ +
+ + + Assign player to a playlist (optional) +
+ +
+

🚀 What Happens Next

+
    +
  1. Player Record is created in the database with an Auth Code.
  2. +
  3. Player Code from the Kiwy-Signage repository is pushed to + the target host via SSH.
  4. +
  5. Installation Scripts run on the remote host in the background.
  6. +
  7. Config (app_config.json) is written automatically + with the server address, hostname and Quick Connect Code.
  8. +
+
+ +
+ + + Cancel + +
+
+
+
+ + + {% endblock %} diff --git a/app/utils/background_tasks.py b/app/utils/background_tasks.py new file mode 100644 index 0000000..30d8746 --- /dev/null +++ b/app/utils/background_tasks.py @@ -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)}') diff --git a/app/utils/caddy_manager.py b/app/utils/caddy_manager.py index cebfa0b..841e906 100644 --- a/app/utils/caddy_manager.py +++ b/app/utils/caddy_manager.py @@ -3,12 +3,126 @@ import os from typing import Optional 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: """Generate Caddyfile configuration based on HTTPSConfig.""" - + @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. Args: diff --git a/app/utils/player_build.py b/app/utils/player_build.py new file mode 100644 index 0000000..3086237 --- /dev/null +++ b/app/utils/player_build.py @@ -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, + } diff --git a/app/utils/portal_sso.py b/app/utils/portal_sso.py new file mode 100644 index 0000000..f09e44b --- /dev/null +++ b/app/utils/portal_sso.py @@ -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 diff --git a/app/utils/script_name_fix.py b/app/utils/script_name_fix.py new file mode 100644 index 0000000..d5e6183 --- /dev/null +++ b/app/utils/script_name_fix.py @@ -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) diff --git a/app/utils/ssh_deploy.py b/app/utils/ssh_deploy.py new file mode 100644 index 0000000..e9110f3 --- /dev/null +++ b/app/utils/ssh_deploy.py @@ -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': [] + } diff --git a/docker-compose.yml b/docker-compose.yml index 8152416..3cb1099 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,11 @@ services: build: . container_name: digiserver-v2 # 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: - "5000" + ports: + - "5000:5000" volumes: # Code is in the Docker image - no volume mount needed # Only mount persistent data folders: @@ -27,22 +30,18 @@ services: networks: - digiserver-network - # Nginx reverse proxy with HTTPS support - nginx: - image: nginx:alpine - container_name: digiserver-nginx + # Caddy reverse proxy — auto-provisions Let's Encrypt certs when a real domain is configured + caddy: + image: caddy:2-alpine + container_name: digiserver-caddy ports: - - "80:80" - - "443:443" + - "8080:80" + - "8443:443" volumes: - - ./data/nginx.conf:/etc/nginx/nginx.conf:ro - - ./data/nginx-custom-domains.conf:/etc/nginx/conf.d/custom-domains.conf:rw - - ./data/nginx-ssl:/etc/nginx/ssl:ro - - ./data/nginx-logs:/var/log/nginx - - ./data/certbot:/var/www/certbot:ro # For Let's Encrypt ACME challenges - environment: - - DOMAIN=${DOMAIN:-localhost} - - EMAIL=${EMAIL:-admin@localhost} + - ./data/Caddyfile:/etc/caddy/Caddyfile:rw + - ./data/caddy-data:/data + - ./data/caddy-config:/config + - ./data/caddy-logs:/var/log/caddy depends_on: digiserver-app: condition: service_started diff --git a/migrations/add_deployment_fields_to_player.py b/migrations/add_deployment_fields_to_player.py new file mode 100644 index 0000000..c936c45 --- /dev/null +++ b/migrations/add_deployment_fields_to_player.py @@ -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.") diff --git a/migrations/add_url_to_content.py b/migrations/add_url_to_content.py new file mode 100644 index 0000000..91f9e19 --- /dev/null +++ b/migrations/add_url_to_content.py @@ -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