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
+126 -3
View File
@@ -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."""