Add admin maintenance console

Admin-only page reachable from a new card on the admin panel (replacing the
Quick Actions card) that centralises housekeeping operations:

* Database integrity: PRAGMA integrity_check / quick_check / foreign_key_check,
  plus VACUUM + ANALYZE to reclaim free space.
* Leftover records: detects and removes orphaned rows left behind by deleted
  players/content (player_feedback, player_edit, playlist_content, dangling
  player.playlist_id) and orphaned edited-media folders on disk.
* Player authentication tokens: manual reset (single or all), a configurable
  automatic reset interval, and cancellation of a pending reset.

Resetting a token rotates player.auth_code and sets auth_reset_required, so the
next heartbeat (POST /api/player-feedback) answers 401 + auth_required and the
client restarts its authentication flow. A successful authentication clears the
flag and stamps auth_token_issued_at, from which the interval is measured.

New: PlayerAuthConfig model, add_player_auth_reset migration (registered in
docker-entrypoint.sh), admin/maintenance.html.
This commit is contained in:
2026-10-14 22:13:18 +03:00
parent 2539f82868
commit 88827be7be
10 changed files with 1124 additions and 19 deletions
+67 -1
View File
@@ -107,6 +107,47 @@ def get_assigned_playlist(player: Player) -> Optional[Playlist]:
return db.session.get(Playlist, player.playlist_id)
def player_needs_reauth(player: Player):
"""Decide whether a heartbeat should force the player to re-authenticate.
Two independent triggers are combined:
* an explicit reset flag (``auth_reset_required``) set from the admin
maintenance page, individually or for every player; and
* the optional automatic expiry interval configured on that page — a token
older than ``reset_interval_days`` is rotated and the player is asked to
authenticate again.
Args:
player: The player sending the heartbeat.
Returns:
``(needs_reauth, reason)`` where reason is one of ``'token_reset'``,
``'token_expired'`` or ``None``.
"""
from app.models import PlayerAuthConfig
if getattr(player, 'auth_reset_required', False):
return True, 'token_reset'
interval_days = PlayerAuthConfig.get_interval_days()
if interval_days <= 0:
return False, None
issued = getattr(player, 'auth_token_issued_at', None)
if not issued:
return False, None
if datetime.utcnow() - issued >= timedelta(days=interval_days):
# Expire the token now: rotate it and flag the player, so the response
# below tells the client to restart authentication.
player.rotate_auth_token(force_reauth=True)
db.session.commit()
return True, 'token_expired'
return False, None
@api_bp.route('/health', methods=['GET'])
def health_check():
"""API health check endpoint."""
@@ -158,7 +199,11 @@ def authenticate_player():
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
return jsonify({'error': 'Invalid credentials'}), 401
# Update player status
# Update player status and stamp the token. Marking the player as
# authenticated clears any pending re-authentication request set from the
# admin maintenance page, so a player that just re-authenticated stops
# being told to authenticate again.
player.mark_authenticated()
player.update_status('online')
db.session.commit()
@@ -508,6 +553,27 @@ def receive_player_feedback():
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
return jsonify({'error': 'Invalid quickconnect code'}), 403
# ------------------------------------------------------------------
# Authentication-token reset.
#
# When the admin maintenance page resets a player's token (or an
# automatic reset interval has elapsed), the heartbeat is answered with
# 401 + auth_required so the client restarts its authentication flow.
# This runs before any feedback is recorded: the heartbeat is not a
# meaningful status update while the client is being asked to
# re-authenticate.
# ------------------------------------------------------------------
needs_reauth, reauth_reason = player_needs_reauth(player)
if needs_reauth:
log_action('info', f'Re-authentication required for {player.name} '
f'({player.hostname}): {reauth_reason}')
return jsonify({
'success': False,
'auth_required': True,
'reason': reauth_reason,
'message': 'Authentication token reset. Please re-authenticate.',
}), 401
# Create feedback record
status = data.get('status', 'unknown')
message = data.get('message', '')