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
+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,
}