From 88827be7bec00e3d0b44f72965fc39d26a07f871 Mon Sep 17 00:00:00 2001 From: scheianu Date: Wed, 14 Oct 2026 22:13:18 +0300 Subject: [PATCH] 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. --- app/blueprints/admin.py | 476 ++++++++++++++++++++++++++- app/blueprints/api.py | 68 +++- app/blueprints/players.py | 7 +- app/models/__init__.py | 2 + app/models/player.py | 47 +++ app/models/player_auth_config.py | 86 +++++ app/templates/admin/admin.html | 24 +- app/templates/admin/maintenance.html | 364 ++++++++++++++++++++ docker-entrypoint.sh | 1 + migrations/add_player_auth_reset.py | 68 ++++ 10 files changed, 1124 insertions(+), 19 deletions(-) create mode 100644 app/models/player_auth_config.py create mode 100644 app/templates/admin/maintenance.html create mode 100644 migrations/add_player_auth_reset.py diff --git a/app/blueprints/admin.py b/app/blueprints/admin.py index 9be5e6f..2d02b46 100644 --- a/app/blueprints/admin.py +++ b/app/blueprints/admin.py @@ -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/`` 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/', 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/', 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')) diff --git a/app/blueprints/api.py b/app/blueprints/api.py index 15e9564..5e825c0 100644 --- a/app/blueprints/api.py +++ b/app/blueprints/api.py @@ -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', '') diff --git a/app/blueprints/players.py b/app/blueprints/players.py index 34f2bf9..a6cb1ed 100644 --- a/app/blueprints/players.py +++ b/app/blueprints/players.py @@ -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})') diff --git a/app/models/__init__.py b/app/models/__init__.py index 565a19b..0c8642a 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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', ] diff --git a/app/models/player.py b/app/models/player.py index edcbc89..e08a6f1 100644 --- a/app/models/player.py +++ b/app/models/player.py @@ -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. diff --git a/app/models/player_auth_config.py b/app/models/player_auth_config.py new file mode 100644 index 0000000..ddab31a --- /dev/null +++ b/app/models/player_auth_config.py @@ -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'' + + @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, + } diff --git a/app/templates/admin/admin.html b/app/templates/admin/admin.html index 20f23cf..e20970d 100644 --- a/app/templates/admin/admin.html +++ b/app/templates/admin/admin.html @@ -129,15 +129,18 @@ {% endif %} - -
-

⚡ Quick Actions

-
- 🖥️ View Players - 📋 View Playlists - 📁 View Media Library + + {% if current_user.is_admin %} +
+

🛠️ Maintenance

+

Database integrity, leftover records and player authentication tokens

+
+ {% endif %}