Features implemented: - Application factory pattern with environment-based config - 7 modular blueprints (main, auth, admin, players, groups, content, api) - Flask-Caching with Redis support for production - Flask-Login authentication with bcrypt password hashing - API endpoints with rate limiting and Bearer token auth - Comprehensive error handling and logging - CLI commands (init-db, create-admin, seed-db) Blueprint Structure: - main: Dashboard with caching, health check endpoint - auth: Login, register, logout, password change - admin: User management, system settings, theme, logo upload - players: Full CRUD, fullscreen view, bulk operations, playlist management - groups: Group management, player assignments, content management - content: Upload with progress tracking, file management, preview/download - api: RESTful endpoints with authentication, rate limiting, player feedback Performance Optimizations: - Dashboard caching (60s timeout) - Playlist caching (5min timeout) - Redis caching for production - Memoized functions for expensive operations - Cache clearing on data changes Security Features: - Bcrypt password hashing - Flask-Login session management - admin_required decorator for authorization - Player authentication via auth codes - API Bearer token authentication - Rate limiting on API endpoints (60 req/min default) - Input validation and sanitization Documentation: - README.md: Full project documentation with quick start - PROGRESS.md: Detailed progress tracking and roadmap - BLUEPRINT_GUIDE.md: Quick reference for blueprint architecture Pending work: - Models migration from v1 with database indexes - Utils migration from v1 with type hints - Templates migration with updated route references - Docker multi-stage build configuration - Unit tests for all blueprints Ready for models and utils migration from digiserver v1
422 lines
14 KiB
Python
422 lines
14 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
|
|
from typing import Optional, Dict, List
|
|
|
|
from app.extensions import db, cache
|
|
from app.models import Player, Group, 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('/playlists/<int:player_id>', methods=['GET'])
|
|
@rate_limit(max_requests=30, 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()
|
|
|
|
return jsonify({
|
|
'player_id': player_id,
|
|
'player_name': player.name,
|
|
'group_id': player.group_id,
|
|
'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
|
|
|
|
|
|
@cache.memoize(timeout=300)
|
|
def get_cached_playlist(player_id: int) -> List[Dict]:
|
|
"""Get cached playlist for a player."""
|
|
from flask import url_for
|
|
|
|
player = Player.query.get(player_id)
|
|
if not player:
|
|
return []
|
|
|
|
# Get content based on group assignment
|
|
if player.group_id:
|
|
group = Group.query.get(player.group_id)
|
|
contents = group.contents.order_by(Content.position).all() if group else []
|
|
else:
|
|
# Show all content if not in a group
|
|
contents = Content.query.order_by(Content.position).all()
|
|
|
|
# Build playlist
|
|
playlist = []
|
|
for content in contents:
|
|
playlist.append({
|
|
'id': content.id,
|
|
'filename': content.filename,
|
|
'type': content.content_type,
|
|
'duration': content.duration or 10,
|
|
'position': content.position,
|
|
'url': f"/static/uploads/{content.filename}",
|
|
'description': content.description
|
|
})
|
|
|
|
return playlist
|
|
|
|
|
|
@api_bp.route('/player-feedback', methods=['POST'])
|
|
@rate_limit(max_requests=100, window=60)
|
|
@verify_player_auth
|
|
def receive_player_feedback():
|
|
"""Receive feedback/status updates from players.
|
|
|
|
Expected JSON payload:
|
|
{
|
|
"status": "playing|paused|error",
|
|
"current_content_id": 123,
|
|
"message": "Optional status message",
|
|
"error": "Optional error message"
|
|
}
|
|
"""
|
|
try:
|
|
player = request.player
|
|
data = request.json
|
|
|
|
if not data:
|
|
return jsonify({'error': 'No data provided'}), 400
|
|
|
|
# Create feedback record
|
|
feedback = PlayerFeedback(
|
|
player_id=player.id,
|
|
status=data.get('status', 'unknown'),
|
|
current_content_id=data.get('current_content_id'),
|
|
message=data.get('message'),
|
|
error=data.get('error')
|
|
)
|
|
db.session.add(feedback)
|
|
|
|
# Update player's last seen
|
|
player.last_seen = datetime.utcnow()
|
|
player.status = data.get('status', 'unknown')
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Feedback received'
|
|
})
|
|
|
|
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
|
|
|
|
|
|
@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.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
|