"""API blueprint for REST endpoints and player communication.""" 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 from app.extensions import db, cache from app.models import ( Player, Playlist, Content, PlayerFeedback, ServerLog, ) from app.utils.logger import log_action api_bp = Blueprint('api', __name__, url_prefix='/api') # Simple rate limiting (use Redis-based solution in production) rate_limit_storage = {} def rate_limit(max_requests: int = 60, window: int = 60): """Rate limiting decorator. Args: max_requests: Maximum number of requests allowed window: Time window in seconds """ def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): # Get client identifier (IP address or API key) client_id = request.remote_addr auth_header = request.headers.get('Authorization') if auth_header and auth_header.startswith('Bearer '): client_id = auth_header[7:] # Use API key as identifier now = datetime.now() key = f"{client_id}:{f.__name__}" # Clean old entries if key in rate_limit_storage: rate_limit_storage[key] = [ req_time for req_time in rate_limit_storage[key] if now - req_time < timedelta(seconds=window) ] else: rate_limit_storage[key] = [] # Check rate limit if len(rate_limit_storage[key]) >= max_requests: return jsonify({ 'error': 'Rate limit exceeded', 'retry_after': window }), 429 # Add current request rate_limit_storage[key].append(now) return f(*args, **kwargs) return decorated_function return decorator def verify_player_auth(f): """Decorator to verify player authentication via auth code.""" @wraps(f) def decorated_function(*args, **kwargs): auth_header = request.headers.get('Authorization') if not auth_header or not auth_header.startswith('Bearer '): return jsonify({'error': 'Missing or invalid authorization header'}), 401 auth_code = auth_header[7:] # Remove 'Bearer ' prefix # Find player with this auth code player = Player.query.filter_by(auth_code=auth_code).first() if not player: log_action('warning', f'Invalid auth code attempt: {auth_code}') return jsonify({'error': 'Invalid authentication code'}), 403 # Store player in request context request.player = player return f(*args, **kwargs) return decorated_function def get_assigned_playlist(player: Player) -> Optional[Playlist]: """Return the playlist assigned to *player*, or ``None`` if unassigned. Centralises playlist lookup so every endpoint reports the same sync version. Playlist edits bump ``Playlist.version``; players poll that value to decide whether their cached content is stale. A player with no assigned playlist has nothing to sync and resolves to version 0. Args: player: The player whose assigned playlist should be resolved. Returns: The assigned ``Playlist`` instance, or ``None`` when unassigned. """ if not player.playlist_id: return None return db.session.get(Playlist, player.playlist_id) @api_bp.route('/health', methods=['GET']) def health_check(): """API health check endpoint.""" return jsonify({ 'status': 'healthy', 'timestamp': datetime.utcnow().isoformat(), 'version': '2.0.0' }) @api_bp.route('/certificate', methods=['GET']) def get_server_certificate(): """Get server SSL certificate.""" return jsonify({'test': 'certificate_endpoint_works'}), 200 @api_bp.route('/auth/player', methods=['POST']) @rate_limit(max_requests=120, window=60) def authenticate_player(): """Authenticate a player and return auth code and configuration. Request JSON: hostname: Player hostname/identifier (required) password: Player password (optional if using quickconnect) quickconnect_code: Quick connect code (optional if using password) Returns: JSON with auth_code, player_id, playlist_id, and configuration """ data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 hostname = data.get('hostname') password = data.get('password') quickconnect_code = data.get('quickconnect_code') if not hostname: return jsonify({'error': 'Hostname is required'}), 400 if not password and not quickconnect_code: return jsonify({'error': 'Password or quickconnect code required'}), 400 # Authenticate player player = Player.authenticate(hostname, password, quickconnect_code) if not player: log_action('warning', f'Failed authentication attempt for hostname: {hostname}') return jsonify({'error': 'Invalid credentials'}), 401 # Update player status player.update_status('online') db.session.commit() log_action('info', f'Player authenticated: {player.name} ({player.hostname})') # Return authentication response response = { 'success': True, 'player_id': player.id, 'player_name': player.name, 'hostname': player.hostname, 'auth_code': player.auth_code, 'playlist_id': player.playlist_id, 'orientation': player.orientation, 'status': player.status } return jsonify(response), 200 @api_bp.route('/auth/verify', methods=['POST']) @rate_limit(max_requests=300, window=60) def verify_auth_code(): """Verify an auth code and return player information. Request JSON: auth_code: Player authentication code Returns: JSON with player information if valid """ data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 auth_code = data.get('auth_code') if not auth_code: return jsonify({'error': 'Auth code is required'}), 400 # Find player with this auth code player = Player.query.filter_by(auth_code=auth_code).first() if not player: return jsonify({'error': 'Invalid auth code'}), 401 # Update last seen player.update_status(player.status) db.session.commit() response = { 'valid': True, 'player_id': player.id, 'player_name': player.name, 'hostname': player.hostname, 'playlist_id': player.playlist_id, 'orientation': player.orientation, 'status': player.status } return jsonify(response), 200 @api_bp.route('/playlists', methods=['GET']) @rate_limit(max_requests=30, window=60) def get_playlist_by_quickconnect(): """Get playlist using hostname and quickconnect code (Kivy player compatible). Query parameters: hostname: Player hostname/identifier quickconnect_code: Quick connect code for authentication Returns: JSON with playlist, playlist_version, and hashed_quickconnect """ try: import bcrypt hostname = request.args.get('hostname') quickconnect_code = request.args.get('quickconnect_code') if not hostname or not quickconnect_code: return jsonify({ 'error': 'hostname and quickconnect_code are required', 'playlist': [], 'playlist_version': 0 }), 400 # Find player by hostname and validate quickconnect player = Player.query.filter_by(hostname=hostname).first() if not player: log_action('warning', f'Player not found with hostname: {hostname}') return jsonify({ 'error': 'Player not found', 'playlist': [], 'playlist_version': 0 }), 404 # Validate quickconnect code if not player.quickconnect_code: log_action('warning', f'Player {hostname} has no quickconnect code set') return jsonify({ 'error': 'Quickconnect not configured', 'playlist': [], 'playlist_version': 0 }), 403 # Check if quickconnect matches (using bcrypt verification) if not player.check_quickconnect_code(quickconnect_code): log_action('warning', f'Invalid quickconnect code for player: {hostname}') return jsonify({ 'error': 'Invalid quickconnect code', 'playlist': [], 'playlist_version': 0 }), 403 # Get playlist (with caching) playlist = get_cached_playlist(player.id) # Update player's last seen timestamp and status player.last_seen = datetime.utcnow() player.status = 'online' db.session.commit() # Get playlist version from the assigned playlist assigned_playlist = get_assigned_playlist(player) playlist_version = assigned_playlist.version if assigned_playlist else 0 # Hash the quickconnect code for validation on client side hashed_quickconnect = bcrypt.hashpw( quickconnect_code.encode('utf-8'), bcrypt.gensalt() ).decode('utf-8') log_action('info', f'Playlist fetched for player: {player.name} ({hostname})') return jsonify({ 'player_id': player.id, 'player_name': player.name, 'playlist_id': player.playlist_id, 'playlist_version': playlist_version, 'playlist': playlist, 'hashed_quickconnect': hashed_quickconnect, 'count': len(playlist) }), 200 except Exception as e: log_action('error', f'Error getting playlist: {str(e)}') return jsonify({ 'error': 'Internal server error', 'playlist': [], 'playlist_version': 0 }), 500 @api_bp.route('/playlists/', methods=['GET']) @rate_limit(max_requests=300, window=60) @verify_player_auth def get_player_playlist(player_id: int): """Get playlist for a specific player. Requires player authentication via Bearer token. """ try: # Verify the authenticated player matches the requested player_id if request.player.id != player_id: return jsonify({'error': 'Unauthorized access to this player'}), 403 player = request.player # Get playlist (with caching) playlist = get_cached_playlist(player_id) # Update player's last seen timestamp player.last_seen = datetime.utcnow() db.session.commit() # Get playlist version from the assigned playlist assigned_playlist = get_assigned_playlist(player) playlist_version = assigned_playlist.version if assigned_playlist else 0 return jsonify({ 'player_id': player_id, 'player_name': player.name, 'playlist_id': player.playlist_id, 'playlist_version': playlist_version, 'playlist': playlist, 'count': len(playlist) }) except Exception as e: log_action('error', f'Error getting playlist for player {player_id}: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/playlist-version/', methods=['GET']) @verify_player_auth @rate_limit(max_requests=60, window=60) def get_playlist_version(player_id: int): """Get current playlist version for a player. Lightweight endpoint for players to check if playlist needs updating. Requires player authentication via Bearer token. """ try: # Verify the authenticated player matches the requested player_id if request.player.id != player_id: return jsonify({'error': 'Unauthorized access to this player'}), 403 player = request.player # Update last seen player.last_seen = datetime.utcnow() db.session.commit() # Player syncs against the version of its assigned playlist; the # content count comes from that same playlist (Content has no # player_id column - it reaches players through the playlist). assigned_playlist = get_assigned_playlist(player) return jsonify({ 'player_id': player_id, 'playlist_id': player.playlist_id, 'playlist_version': assigned_playlist.version if assigned_playlist else 0, 'content_count': assigned_playlist.contents.count() if assigned_playlist else 0 }) except Exception as e: log_action('error', f'Error getting playlist version for player {player_id}: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @cache.memoize(timeout=300) def get_cached_playlist(player_id: int) -> List[Dict]: """Get cached playlist for a player based on assigned playlist.""" from flask import url_for player = Player.query.get(player_id) if not player or not player.playlist_id: return [] # Get the playlist assigned to this player playlist = Playlist.query.get(player.playlist_id) if not playlist: return [] # Get content from playlist (ordered) content_list = playlist.get_content_ordered() # Build playlist response playlist_data = [] for idx, content in enumerate(content_list, start=1): # Generate full URL for content from flask import request as current_request server_base = current_request.host_url.rstrip('/') 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, 'file_name': content.filename, # Player expects 'file_name' not 'filename' 'type': content.content_type, 'duration': content._playlist_duration or content.duration or 10, 'position': content._playlist_position or idx, '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), 'muted': getattr(content, '_playlist_muted', True), # Player expects 'audio' = "on"/"off" to set playback sound. # The playlist_content.muted column stores True when audio is muted, # so audio is "on" exactly when muted is False. 'audio': 'off' if getattr(content, '_playlist_muted', True) else 'on' }) return playlist_data @api_bp.route('/player-feedback', methods=['POST']) @rate_limit(max_requests=600, window=60) def receive_player_feedback(): """Receive feedback/status updates from players (Kivy player compatible). Expected JSON payload: { "player_name": "Screen1", "quickconnect_code": "ABC123", "status": "playing|paused|error|restarting", "message": "Status message", "playlist_version": 1, "error_details": "Optional error details", "timestamp": "ISO timestamp" } """ try: data = request.json if not data: log_action('warning', 'Player feedback received with no data') return jsonify({'error': 'No data provided'}), 400 player_name = data.get('player_name') hostname = data.get('hostname') # Also accept hostname quickconnect_code = data.get('quickconnect_code') # Find player by hostname first (more reliable), then by name player = None if hostname: player = Player.query.filter_by(hostname=hostname).first() if not player and player_name: player = Player.query.filter_by(name=player_name).first() # If player not found and no credentials provided, try to infer from IP and recent auth if not player and (not quickconnect_code or (not player_name and not hostname)): # Try to find player by recent authentication from same IP client_ip = request.remote_addr # Look for players with matching IP in recent activity (last 5 minutes) recent_time = datetime.utcnow() - timedelta(minutes=5) possible_player = Player.query.filter( Player.last_seen >= recent_time ).order_by(Player.last_seen.desc()).first() if possible_player: player = possible_player log_action('info', f'Inferred player feedback from {player.name} ({player.hostname}) based on recent activity') # Still require quickconnect validation if provided if not player: if not player_name and not hostname: log_action('warning', f'Player feedback missing required fields. Data: {data}') return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400 else: log_action('warning', f'Player feedback from unknown player: {player_name or hostname}') return jsonify({'error': 'Player not found'}), 404 if not player: log_action('warning', f'Player feedback from unknown player: {player_name or hostname}') return jsonify({'error': 'Player not found'}), 404 # Validate quickconnect code if provided (using bcrypt verification) if quickconnect_code and not player.check_quickconnect_code(quickconnect_code): log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})') return jsonify({'error': 'Invalid quickconnect code'}), 403 # Create feedback record status = data.get('status', 'unknown') message = data.get('message', '') error_details = data.get('error_details') feedback = PlayerFeedback( player_id=player.id, status=status, message=message, error=error_details ) db.session.add(feedback) # Update player's last seen and status player.last_seen = datetime.utcnow() player.status = status # A player that is sending feedback is running, so its deployment # finished successfully. Auto-mark it as deployed so the players list # doesn't stay stuck on "pending"/"deploying" after deployment is done. if player.deployment_status in ('pending', 'deploying'): player.deployment_status = 'deployed' player.last_deployment_status = 'success' player.last_deployment_message = ( 'Player is running and sending feedback' ) if not player.last_deployment_at: player.last_deployment_at = datetime.utcnow() db.session.commit() log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}') return jsonify({ 'success': True, 'message': 'Feedback received', 'player_id': player.id }), 200 except Exception as e: db.session.rollback() log_action('error', f'Error receiving player feedback: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/player-status/', methods=['GET']) @rate_limit(max_requests=60, window=60) def get_player_status(player_id: int): """Get current status of a player (public endpoint for monitoring).""" try: player = Player.query.get_or_404(player_id) # Get latest feedback latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\ .order_by(PlayerFeedback.timestamp.desc())\ .first() # Calculate if player is online (seen in last 5 minutes) is_online = False if player.last_seen: is_online = (datetime.utcnow() - player.last_seen).total_seconds() < 300 return jsonify({ 'player_id': player_id, 'name': player.name, 'location': player.location, 'status': player.status, 'is_online': is_online, 'last_seen': player.last_seen.isoformat() if player.last_seen else None, 'latest_feedback': { 'status': latest_feedback.status, 'message': latest_feedback.message, 'timestamp': latest_feedback.timestamp.isoformat() } if latest_feedback else None }) except Exception as e: log_action('error', f'Error getting player status: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/upload-progress/', methods=['GET']) @rate_limit(max_requests=120, window=60) def get_upload_progress(upload_id: str): """Get progress of a file upload.""" from app.utils.uploads import get_upload_progress as get_progress try: progress = get_progress(upload_id) return jsonify(progress) except Exception as e: log_action('error', f'Error getting upload progress: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/system-info', methods=['GET']) @rate_limit(max_requests=30, window=60) def system_info(): """Get system information and statistics.""" try: # Get counts total_players = Player.query.count() total_content = Content.query.count() # Count online players (seen in last 5 minutes) five_min_ago = datetime.utcnow() - timedelta(minutes=5) online_players = Player.query.filter(Player.last_seen >= five_min_ago).count() # Get recent logs count recent_logs = ServerLog.query.filter( ServerLog.timestamp >= datetime.utcnow() - timedelta(hours=24) ).count() return jsonify({ 'players': { 'total': total_players, 'online': online_players }, 'content': total_content, 'logs_24h': recent_logs, 'timestamp': datetime.utcnow().isoformat() }) except Exception as e: log_action('error', f'Error getting system info: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/content', methods=['GET']) @rate_limit(max_requests=60, window=60) def list_content(): """List all content with basic information.""" try: contents = Content.query.order_by(Content.uploaded_at.desc()).all() content_data = [] for content in contents: content_data.append({ 'id': content.id, 'filename': content.filename, 'type': content.content_type, 'duration': content.duration, 'size': content.file_size, 'uploaded_at': content.uploaded_at.isoformat() }) return jsonify({ 'content': content_data, 'count': len(content_data) }) except Exception as e: log_action('error', f'Error listing content: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/logs', methods=['GET']) @rate_limit(max_requests=30, window=60) def get_logs(): """Get recent server logs. Query parameters: limit: Number of logs to return (default: 50, max: 200) level: Filter by log level (info, warning, error) since: ISO timestamp to get logs since that time """ try: # Get query parameters limit = min(request.args.get('limit', 50, type=int), 200) level = request.args.get('level') since_str = request.args.get('since') # Build query query = ServerLog.query if level: query = query.filter_by(level=level) if since_str: try: since = datetime.fromisoformat(since_str) query = query.filter(ServerLog.timestamp >= since) except ValueError: return jsonify({'error': 'Invalid since timestamp format'}), 400 # Get logs logs = query.order_by(ServerLog.timestamp.desc()).limit(limit).all() logs_data = [] for log in logs: logs_data.append({ 'id': log.id, 'level': log.level, 'message': log.message, 'timestamp': log.timestamp.isoformat() }) return jsonify({ 'logs': logs_data, 'count': len(logs_data) }) except Exception as e: log_action('error', f'Error getting logs: {str(e)}') return jsonify({'error': 'Internal server error'}), 500 @api_bp.route('/player-edit-media', methods=['POST']) @rate_limit(max_requests=60, window=60) @verify_player_auth def receive_edited_media(): """Receive edited media from player. Expected multipart/form-data: - image_file: The edited image file - metadata: JSON string with metadata Metadata JSON structure: { "time_of_modification": "ISO timestamp", "original_name": "original_file.jpg", "new_name": "original_file_v1.jpg", "version": 1, "user": "player_user" } """ try: player = request.player # Check if file is present if 'image_file' not in request.files: return jsonify({'error': 'No image file provided'}), 400 file = request.files['image_file'] if file.filename == '': return jsonify({'error': 'No file selected'}), 400 # Get metadata import json metadata_str = request.form.get('metadata') if not metadata_str: return jsonify({'error': 'No metadata provided'}), 400 try: metadata = json.loads(metadata_str) except json.JSONDecodeError: return jsonify({'error': 'Invalid metadata JSON'}), 400 # Validate required metadata fields required_fields = ['time_of_modification', 'original_name', 'new_name', 'version'] for field in required_fields: if field not in metadata: return jsonify({'error': f'Missing required field: {field}'}), 400 import os import shutil from datetime import datetime from werkzeug.utils import secure_filename from app.models.player_edit import PlayerEdit original_name = metadata['original_name'] version = metadata['version'] new_filename = metadata['new_name'] # ── Content lookup ─────────────────────────────────────────────── # If the player sends the current filename (pointing to edited_media/...), # we find the content by its ID embedded in the path. Otherwise do a # direct filename match. content = Content.query.filter_by(filename=original_name).first() if not content: # Try to extract content_id from a path like "edited_media//file" import re m = re.match(r'edited_media/(\d+)/', original_name) if m: content = db.session.get(Content, int(m.group(1))) if not content: # Last resort – look up the most recent PlayerEdit for this # content and use its content_id. fallback_edit = PlayerEdit.query.filter_by(new_name=original_name)\ .order_by(PlayerEdit.created_at.desc()).first() if fallback_edit: content = db.session.get(Content, fallback_edit.content_id) if not content: log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}') return jsonify({'error': f'Original content not found: {original_name}'}), 404 # ── Versionized folder ─────────────────────────────────────────── base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads') edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id)) os.makedirs(edited_media_dir, exist_ok=True) # On the very first edit (v1) move the original file into the # versionized folder so it is never orphaned. is_first_edit = PlayerEdit.query.filter_by(content_id=content.id).count() == 0 if is_first_edit: orig_upload = os.path.join(base_upload_dir, content.filename) # content.filename might already be an edited_media/ path if # this is a re-process; only move if it's a plain filename. if os.path.isfile(orig_upload) and not content.filename.startswith('edited_media/'): orig_stored = f"original_{content.filename}" shutil.move(orig_upload, os.path.join(edited_media_dir, orig_stored)) log_action('info', f'Moved original file "{content.filename}" to versionized folder as "{orig_stored}"') # ── Save the edited file ───────────────────────────────────────── edited_file_path = os.path.join(edited_media_dir, new_filename) file.save(edited_file_path) # Side-car metadata JSON metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json" metadata_path = os.path.join(edited_media_dir, metadata_filename) with open(metadata_path, 'w') as f: json.dump(metadata, f, indent=2) # ── Preserve the original filename ────────────────────────────── # `content.filename` is (re)pointed at the latest edit below so the # player downloads the newest version. The pristine original must be # remembered separately, otherwise the original is lost forever and # the UI shows the edited image as the "original". if not content.original_filename: if not content.filename.startswith('edited_media/'): # Normal case: current filename is still the pristine upload. content.original_filename = content.filename else: # Re-process: content.filename already points at an edited # file. Recover the pristine original from the first (v1) edit. first_edit = PlayerEdit.query.filter_by(content_id=content.id)\ .order_by(PlayerEdit.version.asc(), PlayerEdit.created_at.asc())\ .first() if first_edit and first_edit.original_name: content.original_filename = first_edit.original_name # ── Point Content.filename to the latest edit ──────────────────── # This tells the player to download the latest edited version. old_filename = content.filename content.filename = f"edited_media/{content.id}/{new_filename}" # ── Create edit record ─────────────────────────────────────────── time_of_mod = None if metadata.get('time_of_modification'): try: time_of_mod = datetime.fromisoformat(metadata['time_of_modification'].replace('Z', '+00:00')) except: time_of_mod = datetime.utcnow() # Auto-create PlayerUser record if user code is provided user_code = metadata.get('user_card_data') log_action('debug', f'Metadata user code: {user_code}') if user_code: from app.models.player_user import PlayerUser existing_user = PlayerUser.query.filter_by(user_code=user_code).first() if not existing_user: new_user = PlayerUser(user_code=user_code) db.session.add(new_user) log_action('info', f'Auto-created PlayerUser record for code: {user_code}') else: log_action('debug', f'PlayerUser already exists for code: {user_code}') else: log_action('debug', 'No user code in metadata') edit_record = PlayerEdit( player_id=player.id, content_id=content.id, original_name=original_name, new_name=new_filename, version=version, user=user_code, time_of_modification=time_of_mod, metadata_path=metadata_path, edited_file_path=edited_file_path ) db.session.add(edit_record) # ── Update playlist version to force player refresh ────────────── playlist = None if player.playlist_id: from app.models.playlist import Playlist playlist = db.session.get(Playlist, player.playlist_id) if playlist: playlist.version += 1 # Clear playlist cache cache.delete_memoized(get_cached_playlist, player.id) db.session.commit() log_action('info', f'Player {player.name} uploaded edited media: {old_filename} -> {new_filename} (v{version})') return jsonify({ 'success': True, 'message': 'Edited media received and processed', 'edit_id': edit_record.id, 'version': version, 'old_filename': old_filename, 'new_filename': new_filename, 'new_playlist_version': playlist.version if playlist else None }), 200 except Exception as e: db.session.rollback() log_action('error', f'Error receiving edited media: {str(e)}') 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.""" return jsonify({'error': 'Endpoint not found'}), 404 @api_bp.errorhandler(405) def method_not_allowed(error): """Handle 405 errors in API.""" return jsonify({'error': 'Method not allowed'}), 405 @api_bp.errorhandler(500) def internal_error(error): """Handle 500 errors in API.""" return jsonify({'error': 'Internal server error'}), 500