05194a19b6
- New route GET /players/<id>/edited-media-report with tabular report - New template edited_media_report.html with User/Filename/Date/Link columns - Launch Report button (green) next to View All Edited Media button - Fix: on first edit, move original file to versionized folder as original_<name> - Fix: content lookup fallback via PlayerEdit records and path regex - Keep Content.filename pointing to latest edit for player playlist
1029 lines
38 KiB
Python
1029 lines
38 KiB
Python
"""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, 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
|
||
|
||
|
||
@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, group_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
|
||
playlist_version = 1
|
||
if player.playlist_id:
|
||
from app.models import Playlist
|
||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
||
if assigned_playlist:
|
||
playlist_version = assigned_playlist.version
|
||
|
||
# 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/<int:player_id>', 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
|
||
playlist_version = 1
|
||
if player.playlist_id:
|
||
from app.models import Playlist
|
||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
||
if assigned_playlist:
|
||
playlist_version = assigned_playlist.version
|
||
|
||
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/<int:player_id>', 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()
|
||
|
||
return jsonify({
|
||
'player_id': player_id,
|
||
'playlist_version': player.playlist_version,
|
||
'content_count': Content.query.filter_by(player_id=player_id).count()
|
||
})
|
||
|
||
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
|
||
from app.models import Playlist
|
||
|
||
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)
|
||
})
|
||
|
||
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
|
||
|
||
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/<int:player_id>', 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,
|
||
'group_id': player.group_id,
|
||
'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/<upload_id>', 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_groups = Group.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
|
||
},
|
||
'groups': total_groups,
|
||
'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
|
||
|
||
|
||
|
||
# DEPRECATED: Groups functionality has been archived
|
||
# @api_bp.route('/groups', methods=['GET'])
|
||
# @rate_limit(max_requests=60, window=60)
|
||
# def list_groups():
|
||
# """List all groups with basic information."""
|
||
# try:
|
||
# groups = Group.query.order_by(Group.name).all()
|
||
#
|
||
# groups_data = []
|
||
# for group in groups:
|
||
# groups_data.append({
|
||
# 'id': group.id,
|
||
# 'name': group.name,
|
||
# 'description': group.description,
|
||
# 'player_count': group.players.count(),
|
||
# 'content_count': group.contents.count()
|
||
# })
|
||
#
|
||
# return jsonify({
|
||
# 'groups': groups_data,
|
||
# 'count': len(groups_data)
|
||
# })
|
||
#
|
||
# except Exception as e:
|
||
# log_action('error', f'Error listing groups: {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(),
|
||
'group_count': content.groups.count()
|
||
})
|
||
|
||
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/<id>/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)
|
||
|
||
# ── 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
|