6b0e51898c
The maintenance-page reset used to rotate auth_code immediately. Because the token also guards the playlist endpoints, that stopped a player from syncing content the instant the button was pressed -- before the player ever had a chance to react -- and clients that do not understand the heartbeat's 401 auth_required would have stayed broken until redeployed. Split the model behaviour: * request_reauth() -- sets the flag only (used by the admin reset endpoints and the automatic interval). The current token stays valid, so there is no outage while the player is being asked to re-authenticate. * issue_new_token() -- replaces the token now (used by the existing "regenerate auth code" action, where the admin is shown the new value). * mark_authenticated() -- if a reset was pending, this is where the old token is retired: a new token is issued and returned to the player in the /api/auth/player response. Net effect: the reset is a request the player acts on, never a forced logout. A player that ignores it keeps working and shows as "awaiting re-auth" in the UI; one that handles it gets a fresh token and the old one stops working. Maintenance page wording updated to match (request vs reset; "awaiting re-auth" badge), plus a themed info note explaining it is not a forced logout. Adds docs/player-auth-reset.md describing the protocol and exactly what the player client (Kiwy-Signage) still needs to implement: detect 401 / auth_required on the heartbeat (and on the token-guarded endpoints), re-run POST /api/auth/player with the stored quick-connect code, save the new auth_code and retry. No password or SSH redeploy required. Verified end-to-end: token unchanged and still accepted after the request (200), heartbeat answers 401 auth_required=token_reset, re-auth issues a different token, the old token is then rejected (403) and the new one accepted (200), flag cleared; the interval path only flags without rotating.
1660 lines
63 KiB
Python
1660 lines
63 KiB
Python
"""Admin blueprint for user management and system settings."""
|
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
|
|
from flask_login import login_required, current_user
|
|
from werkzeug.utils import secure_filename
|
|
from functools import wraps
|
|
import os
|
|
import shutil
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
from sqlalchemy import text
|
|
|
|
from app.extensions import db, bcrypt
|
|
from app.models import (User, Player, Content, ServerLog, Playlist, HTTPSConfig,
|
|
PlayerAuthConfig)
|
|
from app.utils.logger import log_action
|
|
from app.utils.caddy_manager import CaddyConfigGenerator
|
|
|
|
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
|
|
|
|
|
def admin_required(f):
|
|
"""Decorator to require admin role for route access."""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
if not current_user.is_authenticated:
|
|
flash('Please login to access this page.', 'warning')
|
|
return redirect(url_for('auth.login'))
|
|
if current_user.role != 'admin':
|
|
log_action('warning', f'Unauthorized admin access attempt by {current_user.username}')
|
|
flash('You do not have permission to access this page.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
|
|
|
|
@admin_bp.route('/')
|
|
@login_required
|
|
def admin_panel():
|
|
"""Display admin panel with system overview."""
|
|
try:
|
|
# Get statistics
|
|
total_users = User.query.count()
|
|
total_players = Player.query.count()
|
|
total_playlists = Playlist.query.count()
|
|
total_content = Content.query.count()
|
|
|
|
# Get recent logs
|
|
recent_logs = ServerLog.query.order_by(ServerLog.timestamp.desc()).limit(10).all()
|
|
|
|
# Get all users
|
|
users = User.query.all()
|
|
|
|
# Calculate storage usage
|
|
upload_folder = current_app.config['UPLOAD_FOLDER']
|
|
total_size = 0
|
|
if os.path.exists(upload_folder):
|
|
for dirpath, dirnames, filenames in os.walk(upload_folder):
|
|
for filename in filenames:
|
|
filepath = os.path.join(dirpath, filename)
|
|
if os.path.exists(filepath):
|
|
total_size += os.path.getsize(filepath)
|
|
|
|
storage_mb = round(total_size / (1024 * 1024), 2)
|
|
|
|
return render_template('admin/admin.html',
|
|
total_users=total_users,
|
|
total_players=total_players,
|
|
total_playlists=total_playlists,
|
|
total_content=total_content,
|
|
storage_mb=storage_mb,
|
|
users=users,
|
|
recent_logs=recent_logs)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading admin panel: {str(e)}')
|
|
flash('Error loading admin panel.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
|
|
@admin_bp.route('/user/create', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def create_user():
|
|
"""Create a new user account."""
|
|
try:
|
|
username = request.form.get('username', '').strip()
|
|
password = request.form.get('password', '').strip()
|
|
role = request.form.get('role', 'user').strip()
|
|
|
|
# Validation
|
|
if not username or len(username) < 3:
|
|
flash('Username must be at least 3 characters long.', 'warning')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
if not password or len(password) < 6:
|
|
flash('Password must be at least 6 characters long.', 'warning')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
if role not in ['user', 'admin']:
|
|
flash('Invalid role specified.', 'danger')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
# Check if username exists
|
|
existing_user = User.query.filter_by(username=username).first()
|
|
if existing_user:
|
|
flash(f'Username "{username}" already exists.', 'warning')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
# Create user
|
|
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
|
new_user = User(username=username, password=hashed_password, role=role)
|
|
db.session.add(new_user)
|
|
db.session.commit()
|
|
|
|
log_action('info', f'User {username} created by admin {current_user.username}')
|
|
flash(f'User "{username}" created successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error creating user: {str(e)}')
|
|
flash('Error creating user. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
|
|
@admin_bp.route('/user/<int:user_id>/role', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def change_user_role(user_id: int):
|
|
"""Change user role between user and admin."""
|
|
try:
|
|
user = User.query.get_or_404(user_id)
|
|
new_role = request.form.get('role', '').strip()
|
|
|
|
# Validation
|
|
if new_role not in ['user', 'admin']:
|
|
flash('Invalid role specified.', 'danger')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
# Prevent changing own role
|
|
if user.id == current_user.id:
|
|
flash('You cannot change your own role.', 'warning')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
old_role = user.role
|
|
user.role = new_role
|
|
db.session.commit()
|
|
|
|
log_action('info', f'User {user.username} role changed from {old_role} to {new_role} by {current_user.username}')
|
|
flash(f'User "{user.username}" role changed to {new_role}.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error changing user role: {str(e)}')
|
|
flash('Error changing user role. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
|
|
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def delete_user(user_id: int):
|
|
"""Delete a user account."""
|
|
try:
|
|
user = User.query.get_or_404(user_id)
|
|
|
|
# Prevent deleting own account
|
|
if user.id == current_user.id:
|
|
flash('You cannot delete your own account.', 'warning')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
username = user.username
|
|
db.session.delete(user)
|
|
db.session.commit()
|
|
|
|
log_action('info', f'User {username} deleted by admin {current_user.username}')
|
|
flash(f'User "{username}" deleted successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error deleting user: {str(e)}')
|
|
flash('Error deleting user. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
|
|
@admin_bp.route('/theme', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def change_theme():
|
|
"""Change application theme."""
|
|
try:
|
|
theme = request.form.get('theme', 'light').strip()
|
|
|
|
if theme not in ['light', 'dark']:
|
|
flash('Invalid theme specified.', 'danger')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
# Store theme preference (you can extend this to save to database)
|
|
# For now, just log the action
|
|
log_action('info', f'Theme changed to {theme} by {current_user.username}')
|
|
flash(f'Theme changed to {theme} mode.', 'success')
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error changing theme: {str(e)}')
|
|
flash('Error changing theme. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
|
|
@admin_bp.route('/logo/upload', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def upload_logo():
|
|
"""Upload custom logo for application."""
|
|
try:
|
|
if 'logo' not in request.files:
|
|
flash('No logo file provided.', 'warning')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
file = request.files['logo']
|
|
|
|
if file.filename == '':
|
|
flash('No file selected.', 'warning')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
# Validate file type
|
|
allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'svg'}
|
|
filename = secure_filename(file.filename)
|
|
if not ('.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions):
|
|
flash('Invalid file type. Please upload an image file (PNG, JPG, GIF, SVG).', 'danger')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
# Save logo
|
|
static_folder = current_app.config.get('STATIC_FOLDER', 'app/static')
|
|
logo_path = os.path.join(static_folder, 'logo.png')
|
|
|
|
# Create static folder if it doesn't exist
|
|
os.makedirs(static_folder, exist_ok=True)
|
|
|
|
file.save(logo_path)
|
|
|
|
log_action('info', f'Logo uploaded by admin {current_user.username}')
|
|
flash('Logo uploaded successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error uploading logo: {str(e)}')
|
|
flash('Error uploading logo. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
|
|
@admin_bp.route('/logs/clear', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def clear_logs():
|
|
"""Clear all server logs."""
|
|
try:
|
|
ServerLog.query.delete()
|
|
db.session.commit()
|
|
|
|
log_action('info', f'All logs cleared by admin {current_user.username}')
|
|
flash('All logs cleared successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error clearing logs: {str(e)}')
|
|
flash('Error clearing logs. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
|
|
@admin_bp.route('/users')
|
|
@login_required
|
|
@admin_required
|
|
def user_management():
|
|
"""Display user management page."""
|
|
try:
|
|
users = User.query.order_by(User.created_at.desc()).all()
|
|
return render_template('admin/user_management.html', users=users)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading user management: {str(e)}')
|
|
flash('Error loading user management page.', 'danger')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
|
|
@admin_bp.route('/user/<int:user_id>/password', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def reset_user_password(user_id: int):
|
|
"""Reset user password."""
|
|
try:
|
|
user = User.query.get_or_404(user_id)
|
|
new_password = request.form.get('password', '').strip()
|
|
|
|
# Validation
|
|
if not new_password or len(new_password) < 6:
|
|
flash('Password must be at least 6 characters long.', 'warning')
|
|
return redirect(url_for('admin.user_management'))
|
|
|
|
# Prevent changing own password through this route
|
|
if user.id == current_user.id:
|
|
flash('Use the change password option to update your own password.', 'warning')
|
|
return redirect(url_for('admin.user_management'))
|
|
|
|
# Update password
|
|
hashed_password = bcrypt.generate_password_hash(new_password).decode('utf-8')
|
|
user.password = hashed_password
|
|
db.session.commit()
|
|
|
|
log_action('info', f'Password reset for user {user.username} by admin {current_user.username}')
|
|
flash(f'Password reset successfully for user "{user.username}".', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error resetting password: {str(e)}')
|
|
flash('Error resetting password. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('admin.user_management'))
|
|
|
|
|
|
@admin_bp.route('/system/info')
|
|
@login_required
|
|
@admin_required
|
|
def system_info():
|
|
"""Get system information as JSON."""
|
|
try:
|
|
import platform
|
|
import psutil
|
|
|
|
# Get system info
|
|
info = {
|
|
'system': platform.system(),
|
|
'release': platform.release(),
|
|
'version': platform.version(),
|
|
'machine': platform.machine(),
|
|
'processor': platform.processor(),
|
|
'cpu_count': psutil.cpu_count(),
|
|
'cpu_percent': psutil.cpu_percent(interval=1),
|
|
'memory_total': round(psutil.virtual_memory().total / (1024**3), 2), # GB
|
|
'memory_used': round(psutil.virtual_memory().used / (1024**3), 2), # GB
|
|
'memory_percent': psutil.virtual_memory().percent,
|
|
'disk_total': round(psutil.disk_usage('/').total / (1024**3), 2), # GB
|
|
'disk_used': round(psutil.disk_usage('/').used / (1024**3), 2), # GB
|
|
'disk_percent': psutil.disk_usage('/').percent
|
|
}
|
|
|
|
return jsonify(info)
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error getting system info: {str(e)}')
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@admin_bp.route('/leftover-media')
|
|
@login_required
|
|
def leftover_media():
|
|
"""Display leftover media files not assigned to any playlist."""
|
|
from app.models.playlist import playlist_content
|
|
from sqlalchemy import select
|
|
|
|
try:
|
|
# Get all content IDs that are in playlists
|
|
stmt = select(playlist_content.c.content_id).distinct()
|
|
content_in_playlists = set(row[0] for row in db.session.execute(stmt))
|
|
|
|
# Get all content
|
|
all_content = Content.query.all()
|
|
|
|
# Filter content not in any playlist
|
|
leftover_content = [c for c in all_content if c.id not in content_in_playlists]
|
|
|
|
# Separate by type
|
|
leftover_images = [c for c in leftover_content if c.content_type == 'image']
|
|
leftover_videos = [c for c in leftover_content if c.content_type == 'video']
|
|
leftover_pdfs = [c for c in leftover_content if c.content_type == 'pdf']
|
|
leftover_pptx = [c for c in leftover_content if c.content_type == 'pptx']
|
|
|
|
# Calculate storage (handle None values)
|
|
def safe_file_size(content_list):
|
|
return sum(c.file_size or 0 for c in content_list)
|
|
|
|
total_leftover_size = safe_file_size(leftover_content)
|
|
images_size = safe_file_size(leftover_images)
|
|
videos_size = safe_file_size(leftover_videos)
|
|
pdfs_size = safe_file_size(leftover_pdfs)
|
|
pptx_size = safe_file_size(leftover_pptx)
|
|
|
|
return render_template('admin/leftover_media.html',
|
|
leftover_images=leftover_images,
|
|
leftover_videos=leftover_videos,
|
|
leftover_pdfs=leftover_pdfs,
|
|
leftover_pptx=leftover_pptx,
|
|
total_leftover=len(leftover_content),
|
|
total_leftover_size_mb=total_leftover_size / (1024 * 1024),
|
|
images_size_mb=images_size / (1024 * 1024),
|
|
videos_size_mb=videos_size / (1024 * 1024),
|
|
pdfs_size_mb=pdfs_size / (1024 * 1024),
|
|
pptx_size_mb=pptx_size / (1024 * 1024))
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error loading leftover media: {str(e)}')
|
|
flash('Error loading leftover media.', 'danger')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
|
|
@admin_bp.route('/delete-leftover-images', methods=['POST'])
|
|
@login_required
|
|
def delete_leftover_images():
|
|
"""Delete all leftover images that are not part of any playlist"""
|
|
from app.models.playlist import playlist_content
|
|
|
|
try:
|
|
# Find all leftover image content
|
|
leftover_images = db.session.query(Content).filter(
|
|
Content.content_type == 'image',
|
|
~Content.id.in_(
|
|
db.session.query(playlist_content.c.content_id)
|
|
)
|
|
).all()
|
|
|
|
deleted_count = 0
|
|
errors = []
|
|
|
|
for content in leftover_images:
|
|
try:
|
|
# Delete physical file
|
|
if content.filename:
|
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
|
if os.path.exists(file_path):
|
|
os.remove(file_path)
|
|
|
|
# Delete edited media archive folder if it exists
|
|
import shutil
|
|
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
|
if os.path.exists(edited_media_dir):
|
|
shutil.rmtree(edited_media_dir)
|
|
|
|
# Delete associated player edit records first
|
|
from app.models.player_edit import PlayerEdit
|
|
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
|
|
|
# Delete database record
|
|
db.session.delete(content)
|
|
deleted_count += 1
|
|
except Exception as e:
|
|
errors.append(f"Error deleting {content.filename}: {str(e)}")
|
|
|
|
db.session.commit()
|
|
|
|
if errors:
|
|
flash(f'Deleted {deleted_count} images with {len(errors)} errors', 'warning')
|
|
else:
|
|
flash(f'Successfully deleted {deleted_count} leftover images', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
flash(f'Error deleting leftover images: {str(e)}', 'danger')
|
|
|
|
return redirect(url_for('admin.leftover_media'))
|
|
|
|
@admin_bp.route('/delete-leftover-videos', methods=['POST'])
|
|
@login_required
|
|
def delete_leftover_videos():
|
|
"""Delete all leftover videos that are not part of any playlist"""
|
|
from app.models.playlist import playlist_content
|
|
|
|
try:
|
|
# Find all leftover video content
|
|
leftover_videos = db.session.query(Content).filter(
|
|
Content.content_type == 'video',
|
|
~Content.id.in_(
|
|
db.session.query(playlist_content.c.content_id)
|
|
)
|
|
).all()
|
|
|
|
deleted_count = 0
|
|
errors = []
|
|
|
|
for content in leftover_videos:
|
|
try:
|
|
# Delete physical file
|
|
if content.filename:
|
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
|
if os.path.exists(file_path):
|
|
os.remove(file_path)
|
|
|
|
# Delete edited media archive folder if it exists
|
|
import shutil
|
|
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
|
if os.path.exists(edited_media_dir):
|
|
shutil.rmtree(edited_media_dir)
|
|
|
|
# Delete associated player edit records first
|
|
from app.models.player_edit import PlayerEdit
|
|
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
|
|
|
# Delete database record
|
|
db.session.delete(content)
|
|
deleted_count += 1
|
|
except Exception as e:
|
|
errors.append(f"Error deleting {content.filename}: {str(e)}")
|
|
|
|
db.session.commit()
|
|
|
|
if errors:
|
|
flash(f'Deleted {deleted_count} videos with {len(errors)} errors', 'warning')
|
|
else:
|
|
flash(f'Successfully deleted {deleted_count} leftover videos', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
flash(f'Error deleting leftover videos: {str(e)}', 'danger')
|
|
|
|
return redirect(url_for('admin.leftover_media'))
|
|
|
|
@admin_bp.route('/delete-single-leftover/<int:content_id>', methods=['POST'])
|
|
@login_required
|
|
def delete_single_leftover(content_id):
|
|
"""Delete a single leftover content file"""
|
|
try:
|
|
content = Content.query.get_or_404(content_id)
|
|
|
|
# Delete physical file
|
|
if content.filename:
|
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
|
if os.path.exists(file_path):
|
|
os.remove(file_path)
|
|
|
|
# Delete edited media archive folder if it exists
|
|
import shutil
|
|
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
|
if os.path.exists(edited_media_dir):
|
|
shutil.rmtree(edited_media_dir)
|
|
|
|
# Delete associated player edit records first
|
|
from app.models.player_edit import PlayerEdit
|
|
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
|
|
|
# Delete database record
|
|
db.session.delete(content)
|
|
db.session.commit()
|
|
|
|
flash(f'Successfully deleted {content.filename}', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
flash(f'Error deleting file: {str(e)}', 'danger')
|
|
|
|
return redirect(url_for('admin.leftover_media'))
|
|
|
|
|
|
@admin_bp.route('/dependencies')
|
|
@login_required
|
|
@admin_required
|
|
def dependencies():
|
|
"""Show system dependencies status."""
|
|
import subprocess
|
|
|
|
# Check LibreOffice
|
|
libreoffice_installed = False
|
|
libreoffice_version = "Not installed"
|
|
try:
|
|
result = subprocess.run(['libreoffice', '--version'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5)
|
|
if result.returncode == 0:
|
|
libreoffice_installed = True
|
|
libreoffice_version = result.stdout.strip()
|
|
except Exception:
|
|
pass
|
|
|
|
# Check Poppler (for PDF)
|
|
poppler_installed = False
|
|
poppler_version = "Not installed"
|
|
try:
|
|
result = subprocess.run(['pdftoppm', '-v'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5)
|
|
if result.returncode == 0 or 'pdftoppm' in result.stderr:
|
|
poppler_installed = True
|
|
poppler_version = "Installed"
|
|
except Exception:
|
|
pass
|
|
|
|
# Check FFmpeg (for video)
|
|
ffmpeg_installed = False
|
|
ffmpeg_version = "Not installed"
|
|
try:
|
|
result = subprocess.run(['ffmpeg', '-version'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5)
|
|
if result.returncode == 0:
|
|
ffmpeg_installed = True
|
|
ffmpeg_version = result.stdout.split('\n')[0]
|
|
except Exception:
|
|
pass
|
|
|
|
# Check Emoji Fonts
|
|
emoji_installed = False
|
|
emoji_version = 'Not installed'
|
|
try:
|
|
result = subprocess.run(['dpkg', '-l', 'fonts-noto-color-emoji'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5)
|
|
if result.returncode == 0 and 'ii' in result.stdout:
|
|
emoji_installed = True
|
|
# Get version from dpkg output
|
|
lines = result.stdout.split('\n')
|
|
for line in lines:
|
|
if 'fonts-noto-color-emoji' in line and line.startswith('ii'):
|
|
parts = line.split()
|
|
if len(parts) >= 3:
|
|
emoji_version = f'Noto Color Emoji {parts[2]}'
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
return render_template('admin/dependencies.html',
|
|
libreoffice_installed=libreoffice_installed,
|
|
libreoffice_version=libreoffice_version,
|
|
poppler_installed=poppler_installed,
|
|
poppler_version=poppler_version,
|
|
ffmpeg_installed=ffmpeg_installed,
|
|
ffmpeg_version=ffmpeg_version,
|
|
emoji_installed=emoji_installed,
|
|
emoji_version=emoji_version)
|
|
|
|
|
|
@admin_bp.route('/install-libreoffice', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def install_libreoffice():
|
|
"""Install LibreOffice for PPTX conversion."""
|
|
import subprocess
|
|
|
|
try:
|
|
# Run installation script
|
|
script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
|
'install_libreoffice.sh')
|
|
|
|
if not os.path.exists(script_path):
|
|
flash('Installation script not found', 'danger')
|
|
return redirect(url_for('admin.dependencies'))
|
|
|
|
result = subprocess.run(['sudo', '-n', script_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300)
|
|
|
|
if result.returncode == 0:
|
|
log_action('info', 'LibreOffice installed successfully')
|
|
flash('LibreOffice installed successfully! You can now convert PPTX files.', 'success')
|
|
else:
|
|
log_action('error', f'LibreOffice installation failed: {result.stderr}')
|
|
flash(f'Installation failed: {result.stderr}', 'danger')
|
|
|
|
except subprocess.TimeoutExpired:
|
|
flash('Installation timeout. Please try again.', 'warning')
|
|
except Exception as e:
|
|
log_action('error', f'Error installing LibreOffice: {str(e)}')
|
|
flash(f'Error: {str(e)}', 'danger')
|
|
|
|
return redirect(url_for('admin.dependencies'))
|
|
|
|
|
|
@admin_bp.route('/install-emoji-fonts', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def install_emoji_fonts():
|
|
"""Install Emoji Fonts for better UI display."""
|
|
import subprocess
|
|
|
|
try:
|
|
# Run installation script
|
|
script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
|
'install_emoji_fonts.sh')
|
|
|
|
if not os.path.exists(script_path):
|
|
flash('Installation script not found', 'danger')
|
|
return redirect(url_for('admin.dependencies'))
|
|
|
|
result = subprocess.run(['sudo', '-n', script_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=180)
|
|
|
|
if result.returncode == 0:
|
|
log_action('info', 'Emoji fonts installed successfully')
|
|
flash('Emoji fonts installed successfully! Please restart your browser to see changes.', 'success')
|
|
else:
|
|
log_action('error', f'Emoji fonts installation failed: {result.stderr}')
|
|
flash(f'Installation failed: {result.stderr}', 'danger')
|
|
|
|
except subprocess.TimeoutExpired:
|
|
flash('Installation timeout. Please try again.', 'warning')
|
|
except Exception as e:
|
|
log_action('error', f'Error installing emoji fonts: {str(e)}')
|
|
flash(f'Error: {str(e)}', 'danger')
|
|
|
|
return redirect(url_for('admin.dependencies'))
|
|
|
|
|
|
@admin_bp.route('/customize-logos')
|
|
@login_required
|
|
@admin_required
|
|
def customize_logos():
|
|
"""Logo customization page."""
|
|
import time
|
|
return render_template('admin/customize_logos.html', version=int(time.time()))
|
|
|
|
|
|
@admin_bp.route('/upload-header-logo', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def upload_header_logo():
|
|
"""Upload header logo."""
|
|
try:
|
|
if 'header_logo' not in request.files:
|
|
flash('No file selected', 'warning')
|
|
return redirect(url_for('admin.customize_logos'))
|
|
|
|
file = request.files['header_logo']
|
|
if file.filename == '':
|
|
flash('No file selected', 'warning')
|
|
return redirect(url_for('admin.customize_logos'))
|
|
|
|
if file:
|
|
# Save as header_logo.png
|
|
filename = 'header_logo.png'
|
|
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
|
file.save(filepath)
|
|
|
|
log_action('info', f'Header logo uploaded: {filename}')
|
|
flash('Header logo uploaded successfully!', 'success')
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error uploading header logo: {str(e)}')
|
|
flash(f'Error uploading logo: {str(e)}', 'danger')
|
|
|
|
return redirect(url_for('admin.customize_logos'))
|
|
|
|
|
|
@admin_bp.route('/upload-login-logo', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def upload_login_logo():
|
|
"""Upload login page logo."""
|
|
try:
|
|
if 'login_logo' not in request.files:
|
|
flash('No file selected', 'warning')
|
|
return redirect(url_for('admin.customize_logos'))
|
|
|
|
file = request.files['login_logo']
|
|
if file.filename == '':
|
|
flash('No file selected', 'warning')
|
|
return redirect(url_for('admin.customize_logos'))
|
|
|
|
if file:
|
|
# Save as login_logo.png
|
|
filename = 'login_logo.png'
|
|
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
|
file.save(filepath)
|
|
|
|
log_action('info', f'Login logo uploaded: {filename}')
|
|
flash('Login logo uploaded successfully!', 'success')
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error uploading login logo: {str(e)}')
|
|
flash(f'Error uploading logo: {str(e)}', 'danger')
|
|
|
|
return redirect(url_for('admin.customize_logos'))
|
|
|
|
|
|
@admin_bp.route('/editing-users')
|
|
@login_required
|
|
def manage_editing_users():
|
|
"""Display and manage users that edit images on players."""
|
|
try:
|
|
from app.models.player_user import PlayerUser
|
|
from app.models.player_edit import PlayerEdit
|
|
|
|
# Get all editing users
|
|
users = PlayerUser.query.order_by(PlayerUser.created_at.desc()).all()
|
|
|
|
# Get edit counts for each user
|
|
user_stats = {}
|
|
for user in users:
|
|
edit_count = PlayerEdit.query.filter_by(user=user.user_code).count()
|
|
user_stats[user.user_code] = edit_count
|
|
|
|
return render_template('admin/editing_users.html',
|
|
users=users,
|
|
user_stats=user_stats)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading editing users: {str(e)}')
|
|
flash('Error loading editing users.', 'danger')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
|
|
@admin_bp.route('/editing-users/<int:user_id>/update', methods=['POST'])
|
|
@login_required
|
|
def update_editing_user(user_id: int):
|
|
"""Update editing user name."""
|
|
try:
|
|
from app.models.player_user import PlayerUser
|
|
|
|
user = PlayerUser.query.get_or_404(user_id)
|
|
user_name = request.form.get('user_name', '').strip()
|
|
|
|
user.user_name = user_name if user_name else None
|
|
user.updated_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
log_action('info', f'Updated editing user {user.user_code} name to: {user_name or "None"}')
|
|
flash('User name updated successfully!', 'success')
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error updating editing user: {str(e)}')
|
|
flash(f'Error updating user: {str(e)}', 'danger')
|
|
|
|
return redirect(url_for('admin.manage_editing_users'))
|
|
|
|
|
|
@admin_bp.route('/editing-users/<int:user_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_editing_user(user_id: int):
|
|
"""Delete editing user."""
|
|
try:
|
|
from app.models.player_user import PlayerUser
|
|
|
|
user = PlayerUser.query.get_or_404(user_id)
|
|
user_code = user.user_code
|
|
|
|
db.session.delete(user)
|
|
db.session.commit()
|
|
|
|
log_action('info', f'Deleted editing user: {user_code}')
|
|
flash('User deleted successfully!', 'success')
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error deleting editing user: {str(e)}')
|
|
flash(f'Error deleting user: {str(e)}', 'danger')
|
|
|
|
return redirect(url_for('admin.manage_editing_users'))
|
|
|
|
|
|
# ============================================================================
|
|
# HTTPS Configuration Management Routes
|
|
# ============================================================================
|
|
|
|
@admin_bp.route('/https-config', methods=['GET'])
|
|
@login_required
|
|
@admin_required
|
|
def https_config():
|
|
"""Display HTTPS configuration management page."""
|
|
try:
|
|
config = HTTPSConfig.get_config()
|
|
|
|
# Detect actual current HTTPS status
|
|
# Check if current connection is HTTPS
|
|
is_https_active = request.scheme == 'https' or request.headers.get('X-Forwarded-Proto') == 'https'
|
|
current_host = request.host.split(':')[0] # Remove port if present
|
|
|
|
# If HTTPS is active but database shows disabled, sync it
|
|
if is_https_active and config and not config.https_enabled:
|
|
# Update database to reflect actual HTTPS status
|
|
config.https_enabled = True
|
|
db.session.commit()
|
|
log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)')
|
|
|
|
return render_template('admin/https_config.html',
|
|
config=config,
|
|
is_https_active=is_https_active,
|
|
current_host=current_host)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading HTTPS config page: {str(e)}')
|
|
flash('Error loading HTTPS configuration page.', 'danger')
|
|
return redirect(url_for('admin.admin_panel'))
|
|
|
|
|
|
@admin_bp.route('/https-config/update', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def update_https_config():
|
|
"""Update HTTPS configuration."""
|
|
try:
|
|
https_enabled = request.form.get('https_enabled') == 'on'
|
|
hostname = request.form.get('hostname', '').strip()
|
|
domain = request.form.get('domain', '').strip()
|
|
ip_address = request.form.get('ip_address', '').strip()
|
|
email = request.form.get('email', '').strip()
|
|
port = request.form.get('port', '443').strip()
|
|
|
|
# Validation
|
|
errors = []
|
|
|
|
if https_enabled:
|
|
if not hostname:
|
|
errors.append('Hostname is required when HTTPS is enabled.')
|
|
if not domain:
|
|
errors.append('Domain name is required when HTTPS is enabled.')
|
|
if not ip_address:
|
|
errors.append('IP address is required when HTTPS is enabled.')
|
|
if not email:
|
|
errors.append('Email address is required when HTTPS is enabled.')
|
|
|
|
# Validate domain format (basic)
|
|
if domain and '.' not in domain:
|
|
errors.append('Please enter a valid domain name (e.g., example.com).')
|
|
|
|
# Validate IP format (basic)
|
|
if ip_address:
|
|
ip_parts = ip_address.split('.')
|
|
if len(ip_parts) != 4:
|
|
errors.append('Please enter a valid IPv4 address (e.g., 10.76.152.164).')
|
|
else:
|
|
try:
|
|
for part in ip_parts:
|
|
num = int(part)
|
|
if num < 0 or num > 255:
|
|
raise ValueError()
|
|
except ValueError:
|
|
errors.append('Please enter a valid IPv4 address.')
|
|
|
|
# Validate email format (basic)
|
|
if email and '@' not in email:
|
|
errors.append('Please enter a valid email address.')
|
|
|
|
# Validate port
|
|
try:
|
|
port_num = int(port)
|
|
if port_num < 1 or port_num > 65535:
|
|
errors.append('Port must be between 1 and 65535.')
|
|
port = port_num
|
|
except ValueError:
|
|
errors.append('Port must be a valid number.')
|
|
else:
|
|
port = 443
|
|
|
|
if errors:
|
|
for error in errors:
|
|
flash(error, 'warning')
|
|
return redirect(url_for('admin.https_config'))
|
|
|
|
# Update configuration
|
|
config = HTTPSConfig.create_or_update(
|
|
https_enabled=https_enabled,
|
|
hostname=hostname if https_enabled else None,
|
|
domain=domain if https_enabled else None,
|
|
ip_address=ip_address if https_enabled else None,
|
|
email=email if https_enabled else None,
|
|
port=port if https_enabled else 443,
|
|
updated_by=current_user.username
|
|
)
|
|
|
|
# Generate and update Caddyfile
|
|
try:
|
|
caddyfile_content = CaddyConfigGenerator.generate_caddyfile(config)
|
|
if CaddyConfigGenerator.write_caddyfile(caddyfile_content):
|
|
# Reload Caddy configuration
|
|
if CaddyConfigGenerator.reload_caddy():
|
|
caddy_status = '✅ Caddy configuration updated successfully!'
|
|
log_action('info', f'Caddy configuration reloaded by {current_user.username}')
|
|
else:
|
|
caddy_status = '⚠️ Caddyfile updated but reload failed. Please restart containers.'
|
|
log_action('warning', f'Caddy reload failed for {current_user.username}')
|
|
else:
|
|
caddy_status = '⚠️ Configuration saved but Caddyfile update failed.'
|
|
log_action('warning', f'Caddyfile write failed for {current_user.username}')
|
|
except Exception as caddy_error:
|
|
caddy_status = f'⚠️ Configuration saved but Caddy update failed: {str(caddy_error)}'
|
|
log_action('error', f'Caddy update error: {str(caddy_error)}')
|
|
|
|
if https_enabled:
|
|
log_action('info', f'HTTPS enabled by {current_user.username}: domain={domain}, hostname={hostname}, ip={ip_address}, email={email}')
|
|
flash(f'✅ HTTPS configuration saved successfully!\n{caddy_status}\nServer available at https://{domain}', 'success')
|
|
else:
|
|
log_action('info', f'HTTPS disabled by {current_user.username}')
|
|
flash(f'✅ HTTPS has been disabled. Server running on HTTP only.\n{caddy_status}', 'success')
|
|
|
|
return redirect(url_for('admin.https_config'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error updating HTTPS config: {str(e)}')
|
|
flash(f'Error updating HTTPS configuration: {str(e)}', 'danger')
|
|
return redirect(url_for('admin.https_config'))
|
|
|
|
|
|
@admin_bp.route('/https-config/status')
|
|
@login_required
|
|
@admin_required
|
|
def https_config_status():
|
|
"""Get current HTTPS configuration status as JSON."""
|
|
try:
|
|
config = HTTPSConfig.get_config()
|
|
|
|
if config:
|
|
return jsonify(config.to_dict())
|
|
else:
|
|
return jsonify({
|
|
'https_enabled': False,
|
|
'hostname': None,
|
|
'domain': None,
|
|
'ip_address': None,
|
|
'port': 443,
|
|
})
|
|
except Exception as e:
|
|
log_action('error', f'Error getting HTTPS status: {str(e)}')
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
# ============================================================================
|
|
# Player Build & Deployment Routes
|
|
# ============================================================================
|
|
|
|
def _player_build_meta_path() -> str:
|
|
"""Path to the persisted player-build settings (in the instance folder)."""
|
|
from app.utils.player_build import BUILD_META_FILENAME
|
|
return os.path.join(current_app.instance_path, BUILD_META_FILENAME)
|
|
|
|
|
|
@admin_bp.route('/build-player', methods=['GET'])
|
|
@login_required
|
|
@admin_required
|
|
def build_player():
|
|
"""Display the 'Build player files for deployment' admin page."""
|
|
from app.utils.ssh_deploy import get_local_player_code_status
|
|
from app.utils.player_build import load_build_settings
|
|
|
|
player_code_dir = current_app.config['PLAYER_CODE_DIR']
|
|
settings = load_build_settings(_player_build_meta_path()) or {}
|
|
|
|
# Prefill server address from saved settings, else from HTTPS config.
|
|
if not settings.get('server_ip'):
|
|
https_cfg = HTTPSConfig.get_config()
|
|
if https_cfg and (https_cfg.domain or https_cfg.ip_address):
|
|
settings.setdefault('server_ip', https_cfg.domain or https_cfg.ip_address)
|
|
settings.setdefault('port', str(https_cfg.port or 443))
|
|
settings.setdefault('use_https', bool(https_cfg.https_enabled))
|
|
|
|
# Sensible defaults.
|
|
settings.setdefault('repo_url', current_app.config.get('PLAYER_REPO_URL', ''))
|
|
settings.setdefault('branch', 'main')
|
|
default_host = request.host.split(':')[0]
|
|
if default_host in ('localhost', '127.0.0.1', '') or default_host.startswith('127.'):
|
|
from app.utils.ssh_deploy import detect_server_ip
|
|
default_host = detect_server_ip() or default_host
|
|
settings.setdefault('server_ip', default_host)
|
|
settings.setdefault('port', '443')
|
|
settings.setdefault('use_https', True)
|
|
settings.setdefault('verify_ssl', False)
|
|
settings.setdefault('orientation', 'Landscape')
|
|
settings.setdefault('max_resolution', '1920x1080')
|
|
|
|
code_status = get_local_player_code_status(player_code_dir)
|
|
if code_status.get('updated'):
|
|
code_status['updated_str'] = datetime.fromtimestamp(
|
|
code_status['updated']).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
return render_template(
|
|
'admin/build_player.html',
|
|
settings=settings,
|
|
code_status=code_status,
|
|
player_code_dir=player_code_dir,
|
|
)
|
|
|
|
|
|
@admin_bp.route('/build-player', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def build_player_action():
|
|
"""Start building/refreshing the staged player code.
|
|
|
|
The build runs in a background thread because a full clone of the player
|
|
repository takes far longer than gunicorn's worker timeout; running it
|
|
in-request would get the worker killed mid-clone and leave a broken
|
|
checkout. The page then polls ``admin.build_player_status`` for progress.
|
|
"""
|
|
from app.utils.player_build import start_background_build, is_build_running
|
|
|
|
player_code_dir = current_app.config['PLAYER_CODE_DIR']
|
|
action = request.form.get('action', 'build_and_config')
|
|
|
|
repo_url = request.form.get('repo_url', '').strip()
|
|
branch = request.form.get('branch', 'main').strip() or 'main'
|
|
server_ip = request.form.get('server_ip', '').strip()
|
|
port = request.form.get('port', '443').strip()
|
|
use_https = request.form.get('use_https') == 'on'
|
|
verify_ssl = request.form.get('verify_ssl') == 'on'
|
|
orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape'
|
|
max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080'
|
|
|
|
# Validation (unchanged — fail fast before starting any work)
|
|
errors = []
|
|
if action in ('build_files', 'build_and_config') and not repo_url:
|
|
errors.append('Repository URL is required to build player files.')
|
|
if action in ('save_config', 'build_and_config') and not server_ip:
|
|
errors.append('Server IP / domain is required for the player configuration.')
|
|
try:
|
|
int(port)
|
|
except ValueError:
|
|
errors.append('Port must be a valid number.')
|
|
|
|
if errors:
|
|
for err in errors:
|
|
flash(err, 'warning')
|
|
return redirect(url_for('admin.build_player'))
|
|
|
|
# 'save_config' only writes the config file — it touches no network and is
|
|
# fast, so it stays synchronous.
|
|
if action == 'save_config':
|
|
from app.utils.player_build import (
|
|
write_base_config, get_short_head, save_build_settings, make_build_record,
|
|
)
|
|
cfg_result = write_base_config(
|
|
player_code_dir=player_code_dir,
|
|
server_ip=server_ip, port=port, use_https=use_https,
|
|
verify_ssl=verify_ssl, orientation=orientation,
|
|
max_resolution=max_resolution,
|
|
)
|
|
version = get_short_head(player_code_dir)
|
|
save_build_settings(
|
|
_player_build_meta_path(),
|
|
make_build_record(
|
|
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
|
|
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
|
|
max_resolution=max_resolution, version=version,
|
|
built_by=current_user.username,
|
|
),
|
|
)
|
|
if cfg_result['success']:
|
|
log_action('info', f'Player config saved by {current_user.username}')
|
|
flash(f"✅ {cfg_result['message']}", 'success')
|
|
else:
|
|
log_action('error', f'Player config write failed: {cfg_result["message"]}')
|
|
flash(f"⚠️ {cfg_result['message']}", 'danger')
|
|
return redirect(url_for('admin.build_player'))
|
|
|
|
# build_files / build_and_config → background thread.
|
|
if is_build_running():
|
|
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
|
|
return redirect(url_for('admin.build_player'))
|
|
|
|
config_payload = None
|
|
if action == 'build_and_config':
|
|
config_payload = {
|
|
'server_ip': server_ip, 'port': port, 'use_https': use_https,
|
|
'verify_ssl': verify_ssl, 'orientation': orientation,
|
|
'max_resolution': max_resolution,
|
|
}
|
|
|
|
started = start_background_build(
|
|
player_code_dir=player_code_dir,
|
|
repo_url=repo_url,
|
|
branch=branch,
|
|
config_payload=config_payload,
|
|
meta_path=_player_build_meta_path(),
|
|
built_by=current_user.username,
|
|
)
|
|
|
|
if not started:
|
|
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
|
|
else:
|
|
log_action('info', f'Player build started by {current_user.username} '
|
|
f'({branch} @ {repo_url})')
|
|
flash('⏳ Build started — this page will update automatically.', 'info')
|
|
|
|
return redirect(url_for('admin.build_player'))
|
|
|
|
|
|
@admin_bp.route('/build-player/status', methods=['GET'])
|
|
@login_required
|
|
@admin_required
|
|
def build_player_status():
|
|
"""JSON progress for the running/last player build (polled by the page)."""
|
|
from app.utils.player_build import get_build_state
|
|
return jsonify(get_build_state())
|
|
|
|
|
|
# ===========================================================================
|
|
# Maintenance console (admin only)
|
|
#
|
|
# Centralises the "housekeeping" operations that were previously only possible
|
|
# by hand on the server:
|
|
# * SQLite integrity / foreign-key verification (+ VACUUM/ANALYZE)
|
|
# * detection and removal of orphaned rows left behind by deleted players
|
|
# and content
|
|
# * cleanup of edited-media folders whose content row is gone
|
|
# * player authentication-token reset (manual, bulk, or on an interval)
|
|
# ===========================================================================
|
|
|
|
# Orphaned-row checks.
|
|
#
|
|
# Deleting a player or a content item does not always cascade everywhere:
|
|
# the ``player_feedback`` foreign key has no ON DELETE action, and rows created
|
|
# before the constraints existed are simply unenforced. Each entry below finds
|
|
# (``count``) or removes (``clean``) rows pointing at a parent that is gone.
|
|
# ``action`` only changes the label shown in the UI.
|
|
_ORPHAN_CHECKS = [
|
|
{
|
|
'key': 'feedback_missing_player',
|
|
'label': 'Player heartbeat feedback for deleted players',
|
|
'table': 'player_feedback',
|
|
'action': 'delete',
|
|
'count': ("SELECT COUNT(*) FROM player_feedback f "
|
|
"LEFT JOIN player p ON f.player_id = p.id WHERE p.id IS NULL"),
|
|
'clean': ("DELETE FROM player_feedback "
|
|
"WHERE player_id NOT IN (SELECT id FROM player)"),
|
|
},
|
|
{
|
|
'key': 'feedback_missing_content',
|
|
'label': 'Feedback referencing deleted content (link cleared)',
|
|
'table': 'player_feedback',
|
|
'action': 'unlink',
|
|
'count': ("SELECT COUNT(*) FROM player_feedback f "
|
|
"LEFT JOIN content c ON f.current_content_id = c.id "
|
|
"WHERE f.current_content_id IS NOT NULL AND c.id IS NULL"),
|
|
'clean': ("UPDATE player_feedback SET current_content_id = NULL "
|
|
"WHERE current_content_id IS NOT NULL "
|
|
"AND current_content_id NOT IN (SELECT id FROM content)"),
|
|
},
|
|
{
|
|
'key': 'edits_missing_player',
|
|
'label': 'Player edits recorded for deleted players',
|
|
'table': 'player_edit',
|
|
'action': 'delete',
|
|
'count': ("SELECT COUNT(*) FROM player_edit e "
|
|
"LEFT JOIN player p ON e.player_id = p.id WHERE p.id IS NULL"),
|
|
'clean': ("DELETE FROM player_edit "
|
|
"WHERE player_id NOT IN (SELECT id FROM player)"),
|
|
},
|
|
{
|
|
'key': 'edits_missing_content',
|
|
'label': 'Player edits recorded for deleted content',
|
|
'table': 'player_edit',
|
|
'action': 'delete',
|
|
'count': ("SELECT COUNT(*) FROM player_edit e "
|
|
"LEFT JOIN content c ON e.content_id = c.id WHERE c.id IS NULL"),
|
|
'clean': ("DELETE FROM player_edit "
|
|
"WHERE content_id NOT IN (SELECT id FROM content)"),
|
|
},
|
|
{
|
|
'key': 'playlist_links_missing_content',
|
|
'label': 'Playlist entries pointing at deleted content',
|
|
'table': 'playlist_content',
|
|
'action': 'delete',
|
|
'count': ("SELECT COUNT(*) FROM playlist_content pc "
|
|
"LEFT JOIN content c ON pc.content_id = c.id WHERE c.id IS NULL"),
|
|
'clean': ("DELETE FROM playlist_content "
|
|
"WHERE content_id NOT IN (SELECT id FROM content)"),
|
|
},
|
|
{
|
|
'key': 'playlist_links_missing_playlist',
|
|
'label': 'Playlist entries pointing at deleted playlists',
|
|
'table': 'playlist_content',
|
|
'action': 'delete',
|
|
'count': ("SELECT COUNT(*) FROM playlist_content pc "
|
|
"LEFT JOIN playlist pl ON pc.playlist_id = pl.id WHERE pl.id IS NULL"),
|
|
'clean': ("DELETE FROM playlist_content "
|
|
"WHERE playlist_id NOT IN (SELECT id FROM playlist)"),
|
|
},
|
|
{
|
|
'key': 'player_missing_playlist',
|
|
'label': 'Players assigned to deleted playlists (assignment cleared)',
|
|
'table': 'player',
|
|
'action': 'unlink',
|
|
'count': ("SELECT COUNT(*) FROM player p LEFT JOIN playlist pl "
|
|
"ON p.playlist_id = pl.id "
|
|
"WHERE p.playlist_id IS NOT NULL AND pl.id IS NULL"),
|
|
'clean': ("UPDATE player SET playlist_id = NULL "
|
|
"WHERE playlist_id IS NOT NULL "
|
|
"AND playlist_id NOT IN (SELECT id FROM playlist)"),
|
|
},
|
|
]
|
|
|
|
|
|
def _edited_media_root() -> str:
|
|
"""Absolute path to the per-content edited-media archive folder."""
|
|
return os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media')
|
|
|
|
|
|
def _dir_size_bytes(path: str) -> int:
|
|
"""Total size in bytes of all files under *path*."""
|
|
total = 0
|
|
for dirpath, _dirnames, filenames in os.walk(path):
|
|
for filename in filenames:
|
|
filepath = os.path.join(dirpath, filename)
|
|
if os.path.exists(filepath):
|
|
total += os.path.getsize(filepath)
|
|
return total
|
|
|
|
|
|
def _run_orphan_scan():
|
|
"""Run every orphan check and return ``(results, total_row_count)``.
|
|
|
|
Each check is isolated: a missing table or column in an older database is
|
|
reported as an error on that single check rather than aborting the scan.
|
|
"""
|
|
results = []
|
|
total = 0
|
|
|
|
for check in _ORPHAN_CHECKS:
|
|
entry = {
|
|
'key': check['key'],
|
|
'label': check['label'],
|
|
'table': check['table'],
|
|
'action': check['action'],
|
|
'count': 0,
|
|
'error': None,
|
|
}
|
|
try:
|
|
entry['count'] = int(
|
|
db.session.execute(text(check['count'])).scalar() or 0
|
|
)
|
|
total += entry['count']
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
db.session.rollback()
|
|
entry['error'] = str(exc)
|
|
results.append(entry)
|
|
|
|
return results, total
|
|
|
|
|
|
def _scan_orphaned_edited_media() -> dict:
|
|
"""Find ``edited_media/<content_id>`` folders whose content row is gone."""
|
|
entry = {'count': 0, 'size_mb': 0.0, 'ids': [], 'error': None}
|
|
root = _edited_media_root()
|
|
|
|
if not os.path.isdir(root):
|
|
return entry
|
|
|
|
try:
|
|
valid_ids = {
|
|
row[0] for row in db.session.execute(text('SELECT id FROM content'))
|
|
}
|
|
size_total = 0
|
|
for name in sorted(os.listdir(root)):
|
|
path = os.path.join(root, name)
|
|
if not os.path.isdir(path) or not name.isdigit():
|
|
continue
|
|
if int(name) in valid_ids:
|
|
continue
|
|
size_total += _dir_size_bytes(path)
|
|
entry['ids'].append(int(name))
|
|
entry['count'] += 1
|
|
entry['size_mb'] = round(size_total / (1024 * 1024), 2)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
entry['error'] = str(exc)
|
|
|
|
return entry
|
|
|
|
|
|
@admin_bp.route('/maintenance')
|
|
@login_required
|
|
@admin_required
|
|
def maintenance():
|
|
"""Admin-only maintenance console."""
|
|
auth_config = PlayerAuthConfig.get_config()
|
|
players = Player.query.order_by(Player.name).all()
|
|
|
|
return render_template(
|
|
'admin/maintenance.html',
|
|
players=players,
|
|
auth_config=auth_config,
|
|
interval_days=PlayerAuthConfig.get_interval_days(),
|
|
)
|
|
|
|
|
|
@admin_bp.route('/maintenance/db-integrity', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def maintenance_db_integrity():
|
|
"""Run SQLite integrity checks and return the results as JSON."""
|
|
try:
|
|
integrity = [
|
|
row[0] for row in
|
|
db.session.execute(text('PRAGMA integrity_check')).fetchall()
|
|
]
|
|
quick = [
|
|
row[0] for row in
|
|
db.session.execute(text('PRAGMA quick_check')).fetchall()
|
|
]
|
|
violations = [
|
|
{
|
|
'table': row[0],
|
|
'rowid': row[1],
|
|
'parent': row[2],
|
|
'constraint': row[3],
|
|
}
|
|
for row in db.session.execute(text('PRAGMA foreign_key_check')).fetchall()
|
|
]
|
|
|
|
page_count = db.session.execute(text('PRAGMA page_count')).scalar() or 0
|
|
page_size = db.session.execute(text('PRAGMA page_size')).scalar() or 0
|
|
freelist = db.session.execute(text('PRAGMA freelist_count')).scalar() or 0
|
|
db_path = db.engine.url.database
|
|
db_size_mb = None
|
|
if db_path and os.path.exists(db_path):
|
|
db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2)
|
|
|
|
ok = integrity == ['ok'] and not violations
|
|
|
|
log_action('info',
|
|
f'Database integrity check by {current_user.username}: '
|
|
f'{"OK" if ok else "ISSUES FOUND"} '
|
|
f'({len(violations)} foreign-key violation(s))')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'ok': ok,
|
|
'integrity': integrity,
|
|
'quick_check': quick,
|
|
'foreign_key_violations': violations,
|
|
'fk_violation_count': len(violations),
|
|
'page_count': page_count,
|
|
'page_size': page_size,
|
|
'freelist_count': freelist,
|
|
'freelist_mb': round(freelist * page_size / (1024 * 1024), 2),
|
|
'db_size_mb': db_size_mb,
|
|
})
|
|
|
|
except Exception as exc:
|
|
log_action('error', f'Database integrity check failed: {exc}')
|
|
return jsonify({'success': False, 'error': str(exc)}), 500
|
|
|
|
|
|
@admin_bp.route('/maintenance/optimize-db', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def maintenance_optimize_db():
|
|
"""Compact the SQLite database (VACUUM) and refresh statistics (ANALYZE).
|
|
|
|
VACUUM cannot run inside an open transaction, so it is issued on an
|
|
AUTOCOMMIT connection.
|
|
"""
|
|
try:
|
|
db.session.commit()
|
|
with db.engine.connect().execution_options(
|
|
isolation_level='AUTOCOMMIT') as conn:
|
|
conn.execute(text('VACUUM'))
|
|
conn.execute(text('ANALYZE'))
|
|
|
|
db_path = db.engine.url.database
|
|
db_size_mb = None
|
|
if db_path and os.path.exists(db_path):
|
|
db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2)
|
|
|
|
log_action('info',
|
|
f'Database optimized (VACUUM + ANALYZE) by '
|
|
f'{current_user.username}')
|
|
return jsonify({'success': True, 'db_size_mb': db_size_mb})
|
|
|
|
except Exception as exc:
|
|
log_action('error', f'Database optimization failed: {exc}')
|
|
return jsonify({'success': False, 'error': str(exc)}), 500
|
|
|
|
|
|
@admin_bp.route('/maintenance/scan-orphans', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def maintenance_scan_orphans():
|
|
"""Scan for orphaned database rows and edited-media folders."""
|
|
checks, total = _run_orphan_scan()
|
|
edited_media = _scan_orphaned_edited_media()
|
|
|
|
if total or edited_media['count']:
|
|
log_action('info',
|
|
f'Orphan scan by {current_user.username}: {total} orphaned '
|
|
f'row(s), {edited_media["count"]} orphaned media folder(s)')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'checks': checks,
|
|
'total': total,
|
|
'edited_media': edited_media,
|
|
})
|
|
|
|
|
|
@admin_bp.route('/maintenance/clean-orphans', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def maintenance_clean_orphans():
|
|
"""Delete orphaned database rows and orphaned edited-media folders."""
|
|
cleaned = []
|
|
errors = []
|
|
total_rows = 0
|
|
|
|
for check in _ORPHAN_CHECKS:
|
|
try:
|
|
result = db.session.execute(text(check['clean']))
|
|
changed = result.rowcount or 0
|
|
if changed:
|
|
total_rows += changed
|
|
cleaned.append({
|
|
'label': check['label'],
|
|
'action': check['action'],
|
|
'rows': changed,
|
|
})
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
db.session.rollback()
|
|
errors.append({'label': check['label'], 'error': str(exc)})
|
|
|
|
try:
|
|
db.session.commit()
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
db.session.rollback()
|
|
errors.append({'label': 'commit', 'error': str(exc)})
|
|
|
|
# Orphaned edited-media folders (files on disk whose content row is gone).
|
|
removed_dirs = 0
|
|
freed_bytes = 0
|
|
media = _scan_orphaned_edited_media()
|
|
if media['error']:
|
|
errors.append({'label': 'edited media scan', 'error': media['error']})
|
|
elif media['ids']:
|
|
root = _edited_media_root()
|
|
for content_id in media['ids']:
|
|
path = os.path.join(root, str(content_id))
|
|
try:
|
|
freed_bytes += _dir_size_bytes(path)
|
|
shutil.rmtree(path)
|
|
removed_dirs += 1
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
errors.append({
|
|
'label': f'edited_media/{content_id}',
|
|
'error': str(exc),
|
|
})
|
|
|
|
log_action('info',
|
|
f'Orphan cleanup by {current_user.username}: removed '
|
|
f'{total_rows} row(s) and {removed_dirs} media folder(s)')
|
|
|
|
return jsonify({
|
|
'success': not errors,
|
|
'total_rows': total_rows,
|
|
'cleaned': cleaned,
|
|
'removed_dirs': removed_dirs,
|
|
'freed_mb': round(freed_bytes / (1024 * 1024), 2),
|
|
'errors': errors,
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Player authentication tokens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@admin_bp.route('/maintenance/set-interval', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def maintenance_set_interval():
|
|
"""Save the automatic authentication-token reset interval."""
|
|
raw = (request.form.get('interval_days') or '').strip()
|
|
|
|
try:
|
|
days = int(raw)
|
|
except (TypeError, ValueError):
|
|
flash('Reset interval must be a whole number of days.', 'warning')
|
|
return redirect(url_for('admin.maintenance'))
|
|
|
|
if days < 0:
|
|
flash('Reset interval cannot be negative.', 'warning')
|
|
return redirect(url_for('admin.maintenance'))
|
|
|
|
PlayerAuthConfig.create_or_update(days, updated_by=current_user.username)
|
|
log_action('info',
|
|
f'Player auth token reset interval set to {days} day(s) by '
|
|
f'{current_user.username}')
|
|
|
|
if days == 0:
|
|
flash('Automatic token reset disabled.', 'success')
|
|
else:
|
|
flash(f'Players will be asked to re-authenticate every {days} day(s).',
|
|
'success')
|
|
|
|
return redirect(url_for('admin.maintenance'))
|
|
|
|
|
|
@admin_bp.route('/maintenance/reset-token/<int:player_id>', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def maintenance_reset_token(player_id: int):
|
|
"""Request that a single player re-authenticate.
|
|
|
|
The token is not replaced here (see ``Player.request_reauth``): the player
|
|
is flagged, told to authenticate again on its next heartbeat, and receives
|
|
a fresh token at that point.
|
|
"""
|
|
try:
|
|
player = Player.query.get_or_404(player_id)
|
|
player.request_reauth()
|
|
db.session.commit()
|
|
|
|
log_action('info',
|
|
f'Re-authentication requested for player "{player.name}" '
|
|
f'(ID: {player.id}) by {current_user.username}')
|
|
flash(f'"{player.name}" will be asked to re-authenticate on its next '
|
|
f'heartbeat and then receive a new token.', 'success')
|
|
|
|
except Exception as exc:
|
|
db.session.rollback()
|
|
log_action('error', f'Error requesting token reset: {exc}')
|
|
flash('Error requesting the re-authentication.', 'danger')
|
|
|
|
return redirect(url_for('admin.maintenance'))
|
|
|
|
|
|
@admin_bp.route('/maintenance/reset-all-tokens', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def maintenance_reset_all_tokens():
|
|
"""Request that every player re-authenticate (see ``request_reauth``)."""
|
|
try:
|
|
players = Player.query.all()
|
|
for player in players:
|
|
player.request_reauth()
|
|
db.session.commit()
|
|
|
|
log_action('info',
|
|
f'Re-authentication requested for all {len(players)} '
|
|
f'player(s) by {current_user.username}')
|
|
flash(f'{len(players)} player(s) will be asked to re-authenticate on '
|
|
f'their next heartbeat and then receive new tokens.', 'success')
|
|
|
|
except Exception as exc:
|
|
db.session.rollback()
|
|
log_action('error', f'Error requesting all token resets: {exc}')
|
|
flash('Error requesting the re-authentications.', 'danger')
|
|
|
|
return redirect(url_for('admin.maintenance'))
|
|
|
|
|
|
@admin_bp.route('/maintenance/cancel-reset/<int:player_id>', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def maintenance_cancel_reset(player_id: int):
|
|
"""Cancel a pending re-authentication request for a player."""
|
|
try:
|
|
player = Player.query.get_or_404(player_id)
|
|
player.auth_reset_required = False
|
|
db.session.commit()
|
|
|
|
log_action('info',
|
|
f'Pending re-authentication cancelled for player '
|
|
f'"{player.name}" by {current_user.username}')
|
|
flash(f'Pending re-authentication cancelled for "{player.name}".',
|
|
'success')
|
|
|
|
except Exception as exc:
|
|
db.session.rollback()
|
|
log_action('error', f'Error cancelling pending re-auth: {exc}')
|
|
flash('Error cancelling the pending re-authentication.', 'danger')
|
|
|
|
return redirect(url_for('admin.maintenance'))
|