cdaa17803a
- Added edited_media_report route to players blueprint with tabular report view - Created edited_media_report.html template with summary stats, dark mode, print/PDF support - Created _deploy_badge.html partial template for deployment status display - Added deployment status column and badge CSS to players_list.html - Added 'View Report' button to manage_player.html next to 'View All Edited Media' - Fixed api.py receive_edited_media with robust content lookup: - Fallback content lookup by regex path matching (edited_media/<id>/) - Fallback via PlayerEdit record if direct lookup fails - First-edit original file preservation (moves original to versionized folder) Enterprise-specific features (portal SSO, internal sync, playlist permissions, WAL mode) are preserved unchanged.
1037 lines
38 KiB
Python
1037 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.
|
||
# request.script_root holds the X-Script-Name prefix set by the umbrella
|
||
# nginx (e.g. '/digiserver'), so the URL is correct whether the app runs
|
||
# standalone or behind the portal reverse proxy.
|
||
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 required modules
|
||
import os
|
||
import shutil
|
||
import re
|
||
from werkzeug.utils import secure_filename
|
||
from app.models.player_edit import PlayerEdit
|
||
|
||
# Find the original content by filename
|
||
original_name = metadata['original_name']
|
||
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"
|
||
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
|
||
|
||
# Create versioned folder structure: edited_media/<content_id>/
|
||
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 with version suffix
|
||
version = metadata['version']
|
||
new_filename = metadata['new_name']
|
||
edited_file_path = os.path.join(edited_media_dir, new_filename)
|
||
file.save(edited_file_path)
|
||
|
||
# Save metadata JSON side-car file
|
||
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)
|
||
|
||
# Update the content record to reference the edited version path
|
||
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)
|
||
|
||
# Validation
|
||
if not hostname or not username or not password:
|
||
return jsonify({'error': 'hostname, username, and password are required'}), 400
|
||
|
||
# Test connection
|
||
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 (default: /home/[user]/kiwy-signage)
|
||
repo_url: Git repository URL (default: official Kiwy-Signage repo)
|
||
|
||
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) # Will default to /home/[user]/kiwy-signage
|
||
repo_url = data.get('repo_url', 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git').strip()
|
||
|
||
# Validation
|
||
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
|
||
|
||
# Get server URL for player configuration
|
||
# Use X-Forwarded-Proto and X-Forwarded-Host for proxy, fall back to request host
|
||
scheme = request.headers.get('X-Forwarded-Proto', request.scheme)
|
||
host = request.headers.get('X-Forwarded-Host', request.host)
|
||
server_url = f"{scheme}://{host}/digiserver"
|
||
|
||
# Get or generate API key for the player
|
||
# For now, use a hash of player_name and hostname as a simple key
|
||
import hashlib
|
||
api_key = hashlib.sha256(f'{player_name}:{hostname}'.encode()).hexdigest()[:32]
|
||
|
||
# Execute deployment
|
||
result = deploy_player_to_host(
|
||
hostname=hostname,
|
||
username=username,
|
||
password=password,
|
||
player_name=player_name,
|
||
repo_url=repo_url,
|
||
deploy_path=deploy_path, # Will use /home/[user]/kiwy-signage if None
|
||
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
|