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:
+475
-1
@@ -4,11 +4,14 @@ from flask_login import login_required, current_user
|
|||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.extensions import db, bcrypt
|
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.logger import log_action
|
||||||
from app.utils.caddy_manager import CaddyConfigGenerator
|
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)."""
|
"""JSON progress for the running/last player build (polled by the page)."""
|
||||||
from app.utils.player_build import get_build_state
|
from app.utils.player_build import get_build_state
|
||||||
return jsonify(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
@@ -107,6 +107,47 @@ def get_assigned_playlist(player: Player) -> Optional[Playlist]:
|
|||||||
return db.session.get(Playlist, player.playlist_id)
|
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'])
|
@api_bp.route('/health', methods=['GET'])
|
||||||
def health_check():
|
def health_check():
|
||||||
"""API health check endpoint."""
|
"""API health check endpoint."""
|
||||||
@@ -158,7 +199,11 @@ def authenticate_player():
|
|||||||
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
|
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
|
||||||
return jsonify({'error': 'Invalid credentials'}), 401
|
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')
|
player.update_status('online')
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -508,6 +553,27 @@ def receive_player_feedback():
|
|||||||
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
|
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
|
||||||
return jsonify({'error': 'Invalid quickconnect code'}), 403
|
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
|
# Create feedback record
|
||||||
status = data.get('status', 'unknown')
|
status = data.get('status', 'unknown')
|
||||||
message = data.get('message', '')
|
message = data.get('message', '')
|
||||||
|
|||||||
@@ -300,9 +300,10 @@ def regenerate_auth_code(player_id: int):
|
|||||||
try:
|
try:
|
||||||
player = Player.query.get_or_404(player_id)
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
# Generate new auth code
|
# Issue a new auth code and stamp the issue time so the maintenance
|
||||||
new_auth_code = secrets.token_urlsafe(16)
|
# page's reset interval measures age from this point. A manual
|
||||||
player.auth_code = new_auth_code
|
# regenerate keeps its previous behaviour (no forced re-auth).
|
||||||
|
new_auth_code = player.rotate_auth_token(force_reauth=False)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
log_action('info', f'Auth code regenerated for player "{player.name}" (ID: {player_id})')
|
log_action('info', f'Auth code regenerated for player "{player.name}" (ID: {player_id})')
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.models.player_feedback import PlayerFeedback
|
|||||||
from app.models.player_edit import PlayerEdit
|
from app.models.player_edit import PlayerEdit
|
||||||
from app.models.player_user import PlayerUser
|
from app.models.player_user import PlayerUser
|
||||||
from app.models.https_config import HTTPSConfig
|
from app.models.https_config import HTTPSConfig
|
||||||
|
from app.models.player_auth_config import PlayerAuthConfig
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'User',
|
'User',
|
||||||
@@ -19,5 +20,6 @@ __all__ = [
|
|||||||
'PlayerEdit',
|
'PlayerEdit',
|
||||||
'PlayerUser',
|
'PlayerUser',
|
||||||
'HTTPSConfig',
|
'HTTPSConfig',
|
||||||
|
'PlayerAuthConfig',
|
||||||
'playlist_content',
|
'playlist_content',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class Player(db.Model):
|
|||||||
orientation: Display orientation (Landscape/Portrait)
|
orientation: Display orientation (Landscape/Portrait)
|
||||||
status: Current player status (online, offline, error)
|
status: Current player status (online, offline, error)
|
||||||
last_seen: Last activity timestamp
|
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)
|
playlist_id: Assigned playlist (sync version comes from Playlist.version)
|
||||||
created_at: Player creation timestamp
|
created_at: Player creation timestamp
|
||||||
"""
|
"""
|
||||||
@@ -37,6 +39,13 @@ class Player(db.Model):
|
|||||||
last_heartbeat = 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)
|
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 assignment
|
||||||
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
||||||
nullable=True, index=True)
|
nullable=True, index=True)
|
||||||
@@ -117,6 +126,44 @@ class Player(db.Model):
|
|||||||
from app.extensions import bcrypt
|
from app.extensions import bcrypt
|
||||||
return bcrypt.check_password_hash(self.quickconnect_code, code)
|
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
|
@staticmethod
|
||||||
def authenticate(hostname: str, password: str = None, quickconnect_code: str = None) -> Optional['Player']:
|
def authenticate(hostname: str, password: str = None, quickconnect_code: str = None) -> Optional['Player']:
|
||||||
"""Authenticate a player by hostname and password or quickconnect code.
|
"""Authenticate a player by hostname and password or quickconnect code.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -129,15 +129,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Quick Actions Card -->
|
<!-- Maintenance Card (Admin Only) -->
|
||||||
<div class="card">
|
{% if current_user.is_admin %}
|
||||||
<h2>⚡ Quick Actions</h2>
|
<div class="card management-card" style="background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%);">
|
||||||
<div class="quick-actions">
|
<h2>🛠️ Maintenance</h2>
|
||||||
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">🖥️ View Players</a>
|
<p>Database integrity, leftover records and player authentication tokens</p>
|
||||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📋 View Playlists</a>
|
<div class="card-actions">
|
||||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📁 View Media Library</a>
|
<a href="{{ url_for('admin.maintenance') }}" class="btn btn-primary">
|
||||||
|
Open Maintenance
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -228,13 +231,6 @@ body.dark-mode .stat-value {
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>' +
|
||||||
|
' · 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 %}
|
||||||
@@ -84,6 +84,7 @@ MIGRATIONS=(
|
|||||||
"add_url_to_content.py"
|
"add_url_to_content.py"
|
||||||
"add_original_filename_to_content.py"
|
"add_original_filename_to_content.py"
|
||||||
"add_deployment_fields_to_player.py"
|
"add_deployment_fields_to_player.py"
|
||||||
|
"add_player_auth_reset.py"
|
||||||
)
|
)
|
||||||
|
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
|
|||||||
@@ -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.")
|
||||||
Reference in New Issue
Block a user