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
+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.