Initial commit: DigiServer v2 with Blueprint Architecture
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
This commit is contained in:
421
app/blueprints/api.py
Normal file
421
app/blueprints/api.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user