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:
ske087
2025-11-12 10:00:30 +02:00
commit 244b44f5e0
17 changed files with 3420 additions and 0 deletions

421
app/blueprints/api.py Normal file
View 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