ef85a3ca42
Two issues found while investigating repeated "Re-authentication required for
Test player (rpi-test1): token_reset" entries appearing every ~20s.
1) Log spam. The heartbeat handler logged an INFO row every time it asked a
player to re-authenticate. A player heartbeats every ~15s and the request
repeats until the player complies, so a pending request wrote a server_log
row every few seconds (~230 rows in ~1.3h here) and buried the dashboard.
The per-heartbeat log line is removed. The request is now recorded once, at
the moment it is raised: the admin action already logs when the button is
pressed, and the automatic interval now logs at its transition. The
outstanding request stays visible in Admin -> Maintenance as
"awaiting re-auth".
2) Credential leak. verify_player_auth logged the rejected value in full
("Invalid auth code attempt: <token>"). server_log is readable in the UI and
the database, so this wrote a live bearer token to a readable store. It now
logs a 6-character prefix plus the client IP, which is enough to correlate
failures without exposing the credential.
1142 lines
44 KiB
Python
1142 lines
44 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, 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:
|
||
# Never log the whole token: server_log is readable in the UI (and
|
||
# the DB), so writing a bearer value there would leak a credential.
|
||
# A short prefix is enough to correlate repeated failures.
|
||
token_hint = f'{auth_code[:6]}…' if auth_code else '(empty)'
|
||
log_action('warning',
|
||
f'Invalid auth code attempt (token {token_hint}, '
|
||
f'client {request.remote_addr})')
|
||
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)
|
||
|
||
|
||
def player_needs_reauth(player: Player):
|
||
"""Decide whether a heartbeat should force the player to re-authenticate.
|
||
|
||
Two independent triggers are combined:
|
||
|
||
* an explicit reset flag (``auth_reset_required``) set from the admin
|
||
maintenance page, individually or for every player; and
|
||
* the optional automatic expiry interval configured on that page — a token
|
||
older than ``reset_interval_days`` asks the player to authenticate again.
|
||
|
||
Both are *requests*, not enforcement: the flag is raised here and the token
|
||
is only replaced once the player actually re-authenticates (see
|
||
``Player.mark_authenticated``). That keeps a player which cannot handle the
|
||
request working instead of silently losing playlist sync.
|
||
|
||
Args:
|
||
player: The player sending the heartbeat.
|
||
|
||
Returns:
|
||
``(needs_reauth, reason)`` where reason is one of ``'token_reset'``,
|
||
``'token_expired'`` or ``None``.
|
||
"""
|
||
from app.models import PlayerAuthConfig
|
||
|
||
if getattr(player, 'auth_reset_required', False):
|
||
return True, 'token_reset'
|
||
|
||
interval_days = PlayerAuthConfig.get_interval_days()
|
||
if interval_days <= 0:
|
||
return False, None
|
||
|
||
issued = getattr(player, 'auth_token_issued_at', None)
|
||
if not issued:
|
||
return False, None
|
||
|
||
if datetime.utcnow() - issued >= timedelta(days=interval_days):
|
||
# Raise the request and record it ONCE, at the transition.
|
||
#
|
||
# This deliberately does not log per request: a player heartbeats every
|
||
# ~15s, so logging each time it is asked would add a server_log row
|
||
# every few seconds and drown the dashboard. The pending request stays
|
||
# visible in Admin -> Maintenance as "awaiting re-auth", which is the
|
||
# place to look, and the admin-triggered path is logged where the admin
|
||
# presses the button.
|
||
player.request_reauth()
|
||
db.session.commit()
|
||
log_action('info',
|
||
f'Auth token interval elapsed for {player.name} '
|
||
f'({player.hostname}) — re-authentication requested')
|
||
return True, 'token_expired'
|
||
|
||
return False, None
|
||
|
||
|
||
@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 and finalise the token. If a re-authentication had
|
||
# been requested (admin reset or expiry interval), this is where the old
|
||
# token is retired: mark_authenticated() issues a brand-new one. The value
|
||
# is returned below as `auth_code`, so the player picks it up here. A
|
||
# player that re-authenticated with no request pending simply gets its
|
||
# issue time refreshed.
|
||
player.mark_authenticated()
|
||
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/<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
|
||
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/<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()
|
||
|
||
# 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
|
||
|
||
# ------------------------------------------------------------------
|
||
# Re-authentication request.
|
||
#
|
||
# When an admin requests a token reset (or an automatic reset interval
|
||
# elapses), the heartbeat is answered with 401 + auth_required so the
|
||
# client restarts its authentication flow. The player's existing token
|
||
# is still valid at this point — it is replaced only once the client
|
||
# actually re-authenticates — so a player that does not understand this
|
||
# response keeps syncing instead of breaking.
|
||
#
|
||
# This runs before any feedback is recorded: the heartbeat is not a
|
||
# meaningful status update while the client is being asked to
|
||
# re-authenticate. Note there is intentionally NO log line here — the
|
||
# request repeats on every heartbeat (~15s) until the player complies,
|
||
# which would flood server_log. The outstanding request is visible in
|
||
# Admin -> Maintenance ("awaiting re-auth") and is logged once where it
|
||
# is raised (admin action, or the interval transition above).
|
||
# ------------------------------------------------------------------
|
||
needs_reauth, reauth_reason = player_needs_reauth(player)
|
||
if needs_reauth:
|
||
return jsonify({
|
||
'success': False,
|
||
'auth_required': True,
|
||
'reason': reauth_reason,
|
||
'message': 'Re-authentication required. Please authenticate again.',
|
||
}), 401
|
||
|
||
# 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/<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,
|
||
'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_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/<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)
|
||
|
||
# ── 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
|