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
+475 -1
View File
@@ -4,11 +4,14 @@ 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
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
@@ -1178,3 +1181,474 @@ 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):
"""Reset a single player's authentication token."""
try:
player = Player.query.get_or_404(player_id)
player.rotate_auth_token(force_reauth=True)
db.session.commit()
log_action('info',
f'Auth token reset for player "{player.name}" '
f'(ID: {player.id}) by {current_user.username}')
flash(f'Token reset for "{player.name}". It will re-authenticate on its '
f'next heartbeat.', 'success')
except Exception as exc:
db.session.rollback()
log_action('error', f'Error resetting auth token: {exc}')
flash('Error resetting the authentication token.', '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():
"""Reset the authentication token of every player."""
try:
players = Player.query.all()
for player in players:
player.rotate_auth_token(force_reauth=True)
db.session.commit()
log_action('info',
f'Auth tokens reset for all {len(players)} player(s) by '
f'{current_user.username}')
flash(f'Token reset for {len(players)} player(s). They will '
f're-authenticate on their next heartbeat.', 'success')
except Exception as exc:
db.session.rollback()
log_action('error', f'Error resetting all auth tokens: {exc}')
flash('Error resetting the authentication tokens.', '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'))
+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', '')
+4 -3
View File
@@ -300,9 +300,10 @@ def regenerate_auth_code(player_id: int):
try:
player = Player.query.get_or_404(player_id)
# Generate new auth code
new_auth_code = secrets.token_urlsafe(16)
player.auth_code = new_auth_code
# Issue a new auth code and stamp the issue time so the maintenance
# page's reset interval measures age from this point. A manual
# regenerate keeps its previous behaviour (no forced re-auth).
new_auth_code = player.rotate_auth_token(force_reauth=False)
db.session.commit()
log_action('info', f'Auth code regenerated for player "{player.name}" (ID: {player_id})')
+2
View File
@@ -8,6 +8,7 @@ from app.models.player_feedback import PlayerFeedback
from app.models.player_edit import PlayerEdit
from app.models.player_user import PlayerUser
from app.models.https_config import HTTPSConfig
from app.models.player_auth_config import PlayerAuthConfig
__all__ = [
'User',
@@ -19,5 +20,6 @@ __all__ = [
'PlayerEdit',
'PlayerUser',
'HTTPSConfig',
'PlayerAuthConfig',
'playlist_content',
]
+47
View File
@@ -19,6 +19,8 @@ class Player(db.Model):
orientation: Display orientation (Landscape/Portrait)
status: Current player status (online, offline, error)
last_seen: Last activity timestamp
auth_token_issued_at: When the current auth token was issued
auth_reset_required: Whether the player must re-authenticate
playlist_id: Assigned playlist (sync version comes from Playlist.version)
created_at: Player creation timestamp
"""
@@ -36,6 +38,13 @@ class Player(db.Model):
last_seen = db.Column(db.DateTime, nullable=True, index=True)
last_heartbeat = db.Column(db.DateTime, nullable=True, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Authentication token lifecycle. ``auth_token_issued_at`` records when the
# current ``auth_code`` was handed out, so the maintenance page can expire
# tokens by age. ``auth_reset_required`` is set when the token has been
# invalidated; the next heartbeat then tells the player to re-authenticate.
auth_token_issued_at = db.Column(db.DateTime, nullable=True)
auth_reset_required = db.Column(db.Boolean, default=False, nullable=False)
# Playlist assignment
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
@@ -117,6 +126,44 @@ class Player(db.Model):
from app.extensions import bcrypt
return bcrypt.check_password_hash(self.quickconnect_code, code)
def rotate_auth_token(self, force_reauth: bool = True) -> str:
"""Issue a brand new authentication token for this player.
Rotating the token invalidates any token the player currently holds:
endpoints guarded by ``verify_player_auth`` will start returning 401
for the old value. Combined with ``auth_reset_required`` this makes the
player restart its authentication flow on the next heartbeat.
Args:
force_reauth: When True (default) also flag the player so the next
heartbeat asks it to re-authenticate.
Returns:
The newly generated auth code.
"""
import secrets
self.auth_code = secrets.token_urlsafe(16)
self.auth_token_issued_at = datetime.utcnow()
if force_reauth:
self.auth_reset_required = True
return self.auth_code
def mark_authenticated(self) -> None:
"""Record a successful authentication.
Stamps the token issue time and clears any pending reset request, so a
player that re-authenticates stops being told to authenticate again.
"""
self.auth_token_issued_at = datetime.utcnow()
self.auth_reset_required = False
def auth_token_age_days(self) -> Optional[float]:
"""Return the age of the current token in days, or ``None`` if unset."""
if not self.auth_token_issued_at:
return None
delta = datetime.utcnow() - self.auth_token_issued_at
return delta.total_seconds() / 86400
@staticmethod
def authenticate(hostname: str, password: str = None, quickconnect_code: str = None) -> Optional['Player']:
"""Authenticate a player by hostname and password or quickconnect code.
+86
View File
@@ -0,0 +1,86 @@
"""Player authentication configuration (token reset policy).
Stores the single-row policy that controls how often a player's
authentication token is invalidated so that the client restarts its
authentication flow on the next heartbeat.
"""
from datetime import datetime
from typing import Optional
from app.extensions import db
class PlayerAuthConfig(db.Model):
"""Single-row settings for player authentication tokens.
Attributes:
id: Primary key
reset_interval_days: Automatically require re-authentication once a
token is older than this many days. ``0`` (the default) disables
automatic expiry; tokens are then only reset manually from the
maintenance page.
updated_at: Last modification timestamp
updated_by: Username of the last editor
"""
__tablename__ = 'player_auth_config'
id = db.Column(db.Integer, primary_key=True)
reset_interval_days = db.Column(db.Integer, default=0, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
updated_by = db.Column(db.String(255), nullable=True)
def __repr__(self) -> str:
"""String representation of PlayerAuthConfig."""
return f'<PlayerAuthConfig interval={self.reset_interval_days}d>'
@classmethod
def get_config(cls) -> Optional['PlayerAuthConfig']:
"""Return the current configuration row, or ``None`` if unset."""
return cls.query.first()
@classmethod
def get_interval_days(cls) -> int:
"""Return the configured reset interval in days (0 = disabled).
Defensive against an unset row or a NULL/negative value so callers
can use the result directly without extra guards.
"""
config = cls.get_config()
if not config or not config.reset_interval_days:
return 0
return max(0, int(config.reset_interval_days))
@classmethod
def create_or_update(cls, reset_interval_days: int,
updated_by: str = None) -> 'PlayerAuthConfig':
"""Create or update the authentication reset policy.
Args:
reset_interval_days: Days before a token is auto-expired
(0 disables automatic expiry)
updated_by: Username of the editor
Returns:
The persisted ``PlayerAuthConfig`` instance.
"""
config = cls.get_config()
if not config:
config = cls()
config.reset_interval_days = max(0, int(reset_interval_days or 0))
config.updated_by = updated_by
config.updated_at = datetime.utcnow()
db.session.add(config)
db.session.commit()
return config
def to_dict(self) -> dict:
"""Convert configuration to a dictionary."""
return {
'id': self.id,
'reset_interval_days': self.reset_interval_days,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'updated_by': self.updated_by,
}
+10 -14
View File
@@ -129,15 +129,18 @@
</div>
{% endif %}
<!-- Quick Actions Card -->
<div class="card">
<h2>⚡ Quick Actions</h2>
<div class="quick-actions">
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">🖥️ View Players</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📋 View Playlists</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📁 View Media Library</a>
<!-- Maintenance Card (Admin Only) -->
{% if current_user.is_admin %}
<div class="card management-card" style="background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%);">
<h2>🛠️ Maintenance</h2>
<p>Database integrity, leftover records and player authentication tokens</p>
<div class="card-actions">
<a href="{{ url_for('admin.maintenance') }}" class="btn btn-primary">
Open Maintenance
</a>
</div>
</div>
{% endif %}
</div>
<style>
@@ -228,13 +231,6 @@ body.dark-mode .stat-value {
margin-top: 20px;
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 15px;
}
.btn {
padding: 10px 20px;
text-decoration: none;
+364
View File
@@ -0,0 +1,364 @@
{% extends "base.html" %}
{% block title %}Maintenance - Admin - DigiServer v2{% endblock %}
{% block content %}
<div style="max-width: 1200px; margin: 0 auto;">
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
<div>
<h1 style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
🛠️ Maintenance
</h1>
<p style="color: #6c757d; margin: 0;">
Administrative tools for database health, leftover records and player authentication.
</p>
</div>
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
← Back to Admin
</a>
</div>
<!-- ================= Database Integrity ================= -->
<div class="card" style="margin-bottom: 30px;">
<h2>🧩 Database Integrity</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Runs SQLite's <code>integrity_check</code>, <code>quick_check</code> and
<code>foreign_key_check</code> to confirm the database file is healthy and
no rows reference missing parents. <strong>Optimize</strong> runs
<code>VACUUM</code> and <code>ANALYZE</code> to reclaim free space.
</p>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="button" class="btn btn-primary" id="btn-integrity">🔍 Check Integrity</button>
<button type="button" class="btn btn-warning" id="btn-optimize"
onclick="return confirm('Compact the database now? This may take a few seconds.');">
🧹 Optimize Database
</button>
</div>
<div id="integrity-result" style="margin-top: 20px;"></div>
</div>
<!-- ================= Leftover / Orphaned Records ================= -->
<div class="card" style="margin-bottom: 30px;">
<h2>🧾 Leftover Database Records</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Rows that point at a player, playlist or content item that no longer exists —
typically left behind after deleting a player. Cleaning removes them (or
clears the dangling link); it never touches valid data.
</p>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="button" class="btn btn-primary" id="btn-scan">🔍 Scan for Leftovers</button>
<button type="button" class="btn btn-danger" id="btn-clean"
onclick="return confirm('Remove all leftover database records and orphaned edited-media folders? This cannot be undone.');">
🗑️ Clean Leftovers
</button>
</div>
<div id="orphan-result" style="margin-top: 20px;"></div>
</div>
<!-- ================= Player Authentication Tokens ================= -->
<div class="card" style="margin-bottom: 30px;">
<h2>🔑 Player Authentication Tokens</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Resetting a token invalidates the player's current credentials. On its next
heartbeat the player is told to authenticate again and receives a fresh token.
</p>
<form method="POST" action="{{ url_for('admin.maintenance_set_interval') }}"
style="display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 25px; padding: 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e2e8f0;">
<div>
<label for="interval_days" style="display: block; font-weight: 600; margin-bottom: 6px;">
Automatic reset interval
</label>
<input type="number" id="interval_days" name="interval_days" min="0" step="1"
value="{{ interval_days }}"
style="padding: 10px; border: 1px solid #ccc; border-radius: 6px; width: 140px;">
</div>
<div style="flex: 1; min-width: 220px; color: #6c757d; font-size: 0.9rem;">
Days before a token expires automatically. Use <strong>0</strong> to disable.
{% if auth_config %}
<br>Last changed by <strong>{{ auth_config.updated_by or 'unknown' }}</strong>
{% if auth_config.updated_at %}
on {{ auth_config.updated_at | localtime('%Y-%m-%d %H:%M') }}
{% endif %}
{% else %}
<br>Not configured yet.
{% endif %}
</div>
<button type="submit" class="btn btn-primary">💾 Save Interval</button>
</form>
<form method="POST" action="{{ url_for('admin.maintenance_reset_all_tokens') }}"
onsubmit="return confirm('Reset the authentication token for ALL players? Each player will re-authenticate on its next heartbeat.');"
style="margin-bottom: 20px;">
<button type="submit" class="btn btn-danger">🔁 Reset All Player Tokens</button>
</form>
<div style="max-height: 460px; overflow-y: auto;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa; z-index: 1;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Player</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Token Age</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Re-auth</th>
<th style="padding: 10px; text-align: right; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for player in players %}
{% set age = player.auth_token_age_days() %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">
<strong>{{ player.name }}</strong>
<div style="font-size: 12px; color: #6c757d;">{{ player.hostname }}</div>
</td>
<td style="padding: 10px;">
{% if player.is_online %}
<span style="color: #16a34a; font-weight: 600;">● Online</span>
{% else %}
<span style="color: #94a3b8;">○ {{ player.status or 'offline' }}</span>
{% endif %}
</td>
<td style="padding: 10px;">
{% if age is not none %}
{{ '%.1f'|format(age) }} day(s)
{% if player.auth_token_issued_at %}
<div style="font-size: 12px; color: #6c757d;">
issued {{ player.auth_token_issued_at | localtime('%Y-%m-%d %H:%M') }}
</div>
{% endif %}
{% else %}
<span style="color: #94a3b8;">—</span>
{% endif %}
</td>
<td style="padding: 10px;">
{% if player.auth_reset_required %}
<span style="background: #fef3c7; color: #92400e; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 600;">
pending
</span>
{% else %}
<span style="color: #94a3b8; font-size: 12px;">—</span>
{% endif %}
</td>
<td style="padding: 10px; text-align: right; white-space: nowrap;">
<form method="POST"
action="{{ url_for('admin.maintenance_reset_token', player_id=player.id) }}"
style="display: inline;"
onsubmit="return confirm('Reset the token for {{ player.name }}?');">
<button type="submit" class="btn btn-warning btn-sm"
style="padding: 4px 10px; font-size: 12px;">🔁 Reset</button>
</form>
{% if player.auth_reset_required %}
<form method="POST"
action="{{ url_for('admin.maintenance_cancel_reset', player_id=player.id) }}"
style="display: inline;">
<button type="submit" class="btn btn-secondary btn-sm"
style="padding: 4px 10px; font-size: 12px;">Cancel</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="padding: 30px; text-align: center; color: #999;">
No players registered yet.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
(function () {
const URLS = {
integrity: "{{ url_for('admin.maintenance_db_integrity') }}",
optimize: "{{ url_for('admin.maintenance_optimize_db') }}",
scan: "{{ url_for('admin.maintenance_scan_orphans') }}",
clean: "{{ url_for('admin.maintenance_clean_orphans') }}"
};
const integrityBox = document.getElementById('integrity-result');
const orphanBox = document.getElementById('orphan-result');
function post(url) {
return fetch(url, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) { return r.json(); });
}
function banner(ok, text) {
const bg = ok ? '#dcfce7' : '#fee2e2';
const fg = ok ? '#166534' : '#991b1b';
const icon = ok ? '✓' : '✕';
return '<div style="padding: 12px 15px; border-radius: 8px; background: ' + bg +
'; color: ' + fg + '; font-weight: 600;">' + icon + ' ' + text + '</div>';
}
function tableRow(cells) {
return '<tr>' + cells.map(function (c) {
return '<td style="padding: 8px 10px; border-bottom: 1px solid #e2e8f0;">' + c + '</td>';
}).join('') + '</tr>';
}
function esc(s) {
return String(s === null || s === undefined ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function setBusy(button, busy, label) {
if (!button) { return; }
if (busy) {
button.dataset.original = button.innerHTML;
button.disabled = true;
button.innerHTML = label || '⏳ Working…';
} else {
button.disabled = false;
if (button.dataset.original) { button.innerHTML = button.dataset.original; }
}
}
// ---- Integrity ----
const btnIntegrity = document.getElementById('btn-integrity');
btnIntegrity.addEventListener('click', function () {
setBusy(btnIntegrity, true, '⏳ Checking…');
integrityBox.innerHTML = '';
post(URLS.integrity).then(function (data) {
if (!data.success) {
integrityBox.innerHTML = banner(false, 'Check failed: ' + esc(data.error));
return;
}
let html = '';
html += banner(data.ok,
data.ok ? 'Database integrity OK — no problems found.'
: 'Issues detected — review the details below.');
html += '<div style="margin-top: 12px; font-size: 0.9rem; color: #475569;">' +
'<div>Integrity check: <strong>' + esc(data.integrity.join(', ')) + '</strong></div>' +
'<div>Quick check: <strong>' + esc(data.quick_check.join(', ')) + '</strong></div>' +
'<div>Database size: <strong>' + esc(data.db_size_mb) + ' MB</strong>' +
' &nbsp;·&nbsp; Reclaimable free space: <strong>' + esc(data.freelist_mb) + ' MB</strong></div>' +
'</div>';
if (data.foreign_key_violations.length) {
html += '<h3 style="margin: 18px 0 8px;">Foreign-key violations (' +
data.foreign_key_violations.length + ')</h3>';
html += '<table class="table" style="width: 100%; border-collapse: collapse;">';
html += '<thead><tr>' +
['Table', 'Row ID', 'Missing parent', 'Constraint'].map(function (h) {
return '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #dee2e6;">' + h + '</th>';
}).join('') + '</tr></thead><tbody>';
data.foreign_key_violations.slice(0, 100).forEach(function (v) {
html += tableRow([esc(v.table), esc(v.rowid), esc(v.parent), esc(v.constraint)]);
});
html += '</tbody></table>';
}
integrityBox.innerHTML = html;
}).catch(function (e) {
integrityBox.innerHTML = banner(false, 'Request failed: ' + e);
}).finally(function () {
setBusy(btnIntegrity, false);
});
});
// ---- Optimize ----
const btnOptimize = document.getElementById('btn-optimize');
btnOptimize.addEventListener('click', function () {
setBusy(btnOptimize, true, '⏳ Optimizing…');
post(URLS.optimize).then(function (data) {
if (data.success) {
integrityBox.innerHTML = banner(true,
'Database optimized. Current size: ' + esc(data.db_size_mb) + ' MB.');
} else {
integrityBox.innerHTML = banner(false, 'Optimize failed: ' + esc(data.error));
}
}).catch(function (e) {
integrityBox.innerHTML = banner(false, 'Request failed: ' + e);
}).finally(function () {
setBusy(btnOptimize, false);
});
});
// ---- Scan orphans ----
const btnScan = document.getElementById('btn-scan');
btnScan.addEventListener('click', function () {
setBusy(btnScan, true, '⏳ Scanning…');
orphanBox.innerHTML = '';
post(URLS.scan).then(function (data) {
if (!data.success) {
orphanBox.innerHTML = banner(false, 'Scan failed.');
return;
}
const em = data.edited_media || {};
const total = data.total + (em.count || 0);
let html = banner(total === 0,
total === 0 ? 'No leftover records or files found.'
: total + ' leftover item(s) found.');
html += '<table class="table" style="width: 100%; border-collapse: collapse; margin-top: 15px;">';
html += '<thead><tr>' +
['Type', 'Table', 'Action', 'Count'].map(function (h) {
return '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #dee2e6;">' + h + '</th>';
}).join('') + '</tr></thead><tbody>';
data.checks.forEach(function (c) {
if (c.error) {
html += tableRow([esc(c.label), esc(c.table), '—',
'<span style="color: #b91c1c;">error: ' + esc(c.error) + '</span>']);
} else {
const colour = c.count > 0 ? '#b91c1c' : '#16a34a';
html += tableRow([esc(c.label), esc(c.table), esc(c.action),
'<strong style="color: ' + colour + ';">' + c.count + '</strong>']);
}
});
if (em.count) {
html += tableRow(['Orphaned edited-media folders on disk', 'edited_media',
'delete', '<strong style="color: #b91c1c;">' + em.count +
'</strong> (' + esc(em.size_mb) + ' MB)']);
}
html += '</tbody></table>';
orphanBox.innerHTML = html;
}).catch(function (e) {
orphanBox.innerHTML = banner(false, 'Request failed: ' + e);
}).finally(function () {
setBusy(btnScan, false);
});
});
// ---- Clean orphans ----
const btnClean = document.getElementById('btn-clean');
btnClean.addEventListener('click', function () {
setBusy(btnClean, true, '⏳ Cleaning…');
post(URLS.clean).then(function (data) {
let html = banner(data.success && !data.errors.length,
'Removed ' + data.total_rows + ' database row(s) and ' +
data.removed_dirs + ' media folder(s); freed ' + data.freed_mb + ' MB.');
if (data.cleaned && data.cleaned.length) {
html += '<table class="table" style="width: 100%; border-collapse: collapse; margin-top: 15px;">';
html += '<thead><tr>' +
['Record type', 'Action', 'Rows removed'].map(function (h) {
return '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #dee2e6;">' + h + '</th>';
}).join('') + '</tr></thead><tbody>';
data.cleaned.forEach(function (c) {
html += tableRow([esc(c.label), esc(c.action), c.rows]);
});
html += '</tbody></table>';
}
if (data.errors && data.errors.length) {
html += '<h3 style="margin: 18px 0 8px; color: #b91c1c;">Errors</h3>';
html += '<table class="table" style="width: 100%; border-collapse: collapse;">';
data.errors.forEach(function (e) {
html += tableRow([esc(e.label), esc(e.error)]);
});
html += '</table>';
}
orphanBox.innerHTML = html;
}).catch(function (e) {
orphanBox.innerHTML = banner(false, 'Request failed: ' + e);
}).finally(function () {
setBusy(btnClean, false);
});
});
})();
</script>
{% endblock %}
+1
View File
@@ -84,6 +84,7 @@ MIGRATIONS=(
"add_url_to_content.py"
"add_original_filename_to_content.py"
"add_deployment_fields_to_player.py"
"add_player_auth_reset.py"
)
echo "Running database migrations..."
+68
View File
@@ -0,0 +1,68 @@
"""Add player authentication token reset support.
Adds:
* ``player.auth_token_issued_at`` — when the current auth token was issued
* ``player.auth_reset_required`` — force re-authentication on next heartbeat
* ``player_auth_config`` — single-row token reset interval policy
Idempotent: safe to run on every container start.
"""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app()
with app.app_context():
print("Adding player authentication token reset support...")
# 1. New columns on the player table.
columns = [
("auth_token_issued_at", "DATETIME"),
("auth_reset_required", "BOOLEAN DEFAULT 0"),
]
for col_name, col_type in columns:
try:
db.session.execute(text(f"ALTER TABLE player ADD COLUMN {col_name} {col_type}"))
db.session.commit()
print(f"✓ '{col_name}' column added to player table.")
except Exception as e:
if 'duplicate column' in str(e).lower() or 'already exists' in str(e).lower():
print(f"✓ '{col_name}' column already exists, skipping.")
else:
print(f"✗ Error adding '{col_name}': {e}")
raise
# 2. Settings table holding the automatic reset interval.
try:
db.session.execute(text("""
CREATE TABLE IF NOT EXISTS player_auth_config (
id INTEGER PRIMARY KEY,
reset_interval_days INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME,
updated_by VARCHAR(255)
)
"""))
db.session.commit()
print("✓ 'player_auth_config' table ready.")
except Exception as e:
print(f"✗ Error creating 'player_auth_config': {e}")
raise
# 3. Backfill: treat existing tokens as issued now so the interval starts
# counting from the migration rather than expiring every player at once.
try:
result = db.session.execute(text(
"UPDATE player SET auth_token_issued_at = CURRENT_TIMESTAMP "
"WHERE auth_token_issued_at IS NULL"
))
db.session.commit()
print(f"✓ Backfilled auth_token_issued_at for {result.rowcount} player(s).")
except Exception as e:
print(f"⚠ Could not backfill auth_token_issued_at: {e}")
db.session.rollback()
print("Done.")