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:
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user