8 Commits

Author SHA1 Message Date
ske087 05fc1a14f6 Move the Ajutor nav link after Logout
Header nav order was Dashboard, Players, Playlists, Ajutor, Admin, Logout,
toggle. Ajutor now sits after Logout and before the theme toggle, so the
navigation links stay grouped and the two "settings"-style controls (logout,
help, theme) are together at the end.
2026-10-15 00:01:07 +03:00
ske087 ef85a3ca42 Stop per-heartbeat re-auth log spam; stop logging bearer tokens
Two issues found while investigating repeated "Re-authentication required for
Test player (rpi-test1): token_reset" entries appearing every ~20s.

1) Log spam. The heartbeat handler logged an INFO row every time it asked a
   player to re-authenticate. A player heartbeats every ~15s and the request
   repeats until the player complies, so a pending request wrote a server_log
   row every few seconds (~230 rows in ~1.3h here) and buried the dashboard.

   The per-heartbeat log line is removed. The request is now recorded once, at
   the moment it is raised: the admin action already logs when the button is
   pressed, and the automatic interval now logs at its transition. The
   outstanding request stays visible in Admin -> Maintenance as
   "awaiting re-auth".

2) Credential leak. verify_player_auth logged the rejected value in full
   ("Invalid auth code attempt: <token>"). server_log is readable in the UI and
   the database, so this wrote a live bearer token to a readable store. It now
   logs a 6-character prefix plus the client IP, which is enough to correlate
   failures without exposing the credential.
2026-10-14 23:54:48 +03:00
ske087 6b0e51898c Make player token reset non-breaking (deferred rotation)
The maintenance-page reset used to rotate auth_code immediately. Because the
token also guards the playlist endpoints, that stopped a player from syncing
content the instant the button was pressed -- before the player ever had a
chance to react -- and clients that do not understand the heartbeat's
401 auth_required would have stayed broken until redeployed.

Split the model behaviour:

* request_reauth()  -- sets the flag only (used by the admin reset endpoints
  and the automatic interval). The current token stays valid, so there is no
  outage while the player is being asked to re-authenticate.
* issue_new_token() -- replaces the token now (used by the existing
  "regenerate auth code" action, where the admin is shown the new value).
* mark_authenticated() -- if a reset was pending, this is where the old token
  is retired: a new token is issued and returned to the player in the
  /api/auth/player response.

Net effect: the reset is a request the player acts on, never a forced logout.
A player that ignores it keeps working and shows as "awaiting re-auth" in the
UI; one that handles it gets a fresh token and the old one stops working.

Maintenance page wording updated to match (request vs reset; "awaiting
re-auth" badge), plus a themed info note explaining it is not a forced logout.

Adds docs/player-auth-reset.md describing the protocol and exactly what the
player client (Kiwy-Signage) still needs to implement: detect 401 /
auth_required on the heartbeat (and on the token-guarded endpoints), re-run
POST /api/auth/player with the stored quick-connect code, save the new
auth_code and retry. No password or SSH redeploy required.

Verified end-to-end: token unchanged and still accepted after the request
(200), heartbeat answers 401 auth_required=token_reset, re-auth issues a
different token, the old token is then rejected (403) and the new one accepted
(200), flag cleared; the interval path only flags without rotating.
2026-10-14 23:14:11 +03:00
ske087 4dc8506c9b Fix all remaining dark-theme colour bugs across the templates
Follow-up to the player-modal / maintenance-page fix: the same root cause --
colours hard-coded for the light theme -- affected the whole app, in two
places.

1) Inline style attributes (inline outranks the theme rules, so the light
   value survives dark mode and the inherited text colour turns
   light-on-light). base.html now carries a single, property-scoped
   dark-mode override layer that maps every light literal found in the
   templates to the theme variables: light backgrounds/whites -> dark
   surfaces, semantic pastels -> dark equivalents, dark text -> light text,
   light borders -> dark borders, plus .back-link. Scoped to
   body.dark-mode, so the light theme is untouched. This covers all
   templates, including ones not edited here and any added later.

2) Page-level <style> blocks with no dark support:
   * admin/https_config.html hard-coded a light-only palette (blue-tint
     panels, dark labels) -- added a body.dark-mode block for it.
   * dashboard.html / help.html / manage_player.html coloured text links
     with --primary-color (#7c3aed in dark = too dim). Introduced a
     dedicated --link-color (#667eea light / #a78bfa dark) and used it for
     text links, leaving --primary-color for button/background use.

Verified with an automated luminance scan of all 18 pages in both themes:
dark mode has 0 light backgrounds and 0 dark-on-dark text (the only
remaining light surfaces are the intentional white buttons on the admin
gradient cards), and light mode is unchanged.
2026-10-14 22:45:09 +03:00
ske087 5b69ccb02b Fix dark-theme colours on player delete modal and maintenance page
Both bugs had the same cause: light colours were set with inline style
attributes, which outrank the theme rules in base.html and so survived the
dark theme. Text that inherits --text-color then became light-on-light.

* manage_player.html: the delete confirmation modal used an inline
  background-color of #fefefe with the body's dark-mode text colour, making
  the message unreadable in dark mode. Rebuilt it with .modal-* classes
  driven by the base.html CSS variables (and dark-mode variants for the
  title, warning and player name).
* admin/maintenance.html: the "Automatic reset interval" panel, table
  headers and JS-rendered rows/tables hardcoded #f8f9fa/#e2e8f0/#dee2e6.
  Switched them to --bg-color / --card-bg / --border-color /
  --text-secondary and added a themed .maint-badge-pending.

Verified with computed styles in both themes; light mode is unchanged.
2026-10-14 22:35:39 +03:00
ske087 88827be7be 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.
2026-10-14 22:13:18 +03:00
ske087 2539f82868 Fix Add Content card overlapping the playlist table
The two cards on the playlist manage page overlapped: the Add Content card
was painted on top of the playlist content card.

Cause: the 9-column playlist table needs ~830px, but its grid column was
only ~683px, so the table overflowed its card by ~150px. The Add Content
card comes later in the DOM, so it painted over the overflowing table.

This was introduced by the minmax(0, ...) I added in the previous commit
to stop a long filename widening the track. That fixed a page-wide
horizontal scrollbar but capped the track, which is what made the table
spill. Reverting alone would not have been correct either — it swaps the
overlap for the scrollbar — so this fixes the underlying width problem:

- Widen the page container. The template already declared max-width:1400px,
  but on its own wrapper — a child cannot widen its parent, so it did
  nothing and the layout was stuck at base.html's 1200px.
- Use 3fr/1fr instead of 2fr/1fr. At 2fr/1fr the table column came out
  ~50px too narrow and sat in a permanent horizontal scrollbar.
- Stack the cards below 1440px, where side-by-side cannot give the table
  the width it needs. Stacked, it fits outright.
- The stacked rule uses minmax(0, 1fr): a bare 1fr keeps the min-content
  floor, which let the table widen the track and push the whole page into
  horizontal overflow at <=900px.
- Trim cell padding 12px -> 8px to reclaim ~72px for the table.
- Keep .playlist-table-scroll (overflow-x:auto) as a last-resort clip for
  genuinely narrow screens, so the table can never paint over its
  neighbour again.

Verified by hit-testing the Add Content card at 500-1920px: zero elements
from the table are painted over it at any width. No page-wide horizontal
overflow at any width; the table fits without scrolling at >=1024px.
2026-10-14 21:54:45 +03:00
ske087 7784da495b Show image thumbnails in the playlist Add Content card
The "Add Content" card listed available media as an icon plus a filename,
so the only way to tell two media items apart was to read near-identical
filenames. Images now render an actual thumbnail.

Previews are shown for every image type, not just JPEG: the library is a
mix of JPEG and PNG (the PDF/PPTX converter emits PNG), so a JPEG-only
rule would have left most items without a preview. Non-images keep the
existing emoji icon. A missing or unreadable file falls back to the icon
via onerror, and images are `loading="lazy"` since the list is scrollable.

This surfaced two layout bugs in the same card, both now fixed:

- .content-grid used bare `2fr 1fr`. A grid track has an automatic
  min-content floor, so the long unbreakable media filenames forced the
  track wider than the viewport — the page had a horizontal scrollbar
  (scrollWidth 1475 in a 1185px viewport). minmax(0, ...) fixes it.

- Long names were truncated with ellipsis, which hides the distinguishing
  part: media filenames differ at the END (..._slide_003.png). Names now
  wrap with overflow-wrap: anywhere instead.

The row is flex-wrap so that on a narrow card the Add button drops to its
own line rather than squeezing the name.

Verified: 5/5 items render an <img> whose URL returns HTTP 200 (including
a nested edited_media/ JPEG), no element overlaps at 1000-1500px, and dark
mode picks up the theme variables.
2026-10-14 21:54:45 +03:00
17 changed files with 1859 additions and 62 deletions
+480 -1
View File
@@ -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,479 @@ 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):
"""Request that a single player re-authenticate.
The token is not replaced here (see ``Player.request_reauth``): the player
is flagged, told to authenticate again on its next heartbeat, and receives
a fresh token at that point.
"""
try:
player = Player.query.get_or_404(player_id)
player.request_reauth()
db.session.commit()
log_action('info',
f'Re-authentication requested for player "{player.name}" '
f'(ID: {player.id}) by {current_user.username}')
flash(f'"{player.name}" will be asked to re-authenticate on its next '
f'heartbeat and then receive a new token.', 'success')
except Exception as exc:
db.session.rollback()
log_action('error', f'Error requesting token reset: {exc}')
flash('Error requesting the re-authentication.', '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():
"""Request that every player re-authenticate (see ``request_reauth``)."""
try:
players = Player.query.all()
for player in players:
player.request_reauth()
db.session.commit()
log_action('info',
f'Re-authentication requested for all {len(players)} '
f'player(s) by {current_user.username}')
flash(f'{len(players)} player(s) will be asked to re-authenticate on '
f'their next heartbeat and then receive new tokens.', 'success')
except Exception as exc:
db.session.rollback()
log_action('error', f'Error requesting all token resets: {exc}')
flash('Error requesting the re-authentications.', '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'))
+95 -2
View File
@@ -78,7 +78,13 @@ def verify_player_auth(f):
player = Player.query.filter_by(auth_code=auth_code).first() player = Player.query.filter_by(auth_code=auth_code).first()
if not player: if not player:
log_action('warning', f'Invalid auth code attempt: {auth_code}') # Never log the whole token: server_log is readable in the UI (and
# the DB), so writing a bearer value there would leak a credential.
# A short prefix is enough to correlate repeated failures.
token_hint = f'{auth_code[:6]}…' if auth_code else '(empty)'
log_action('warning',
f'Invalid auth code attempt (token {token_hint}, '
f'client {request.remote_addr})')
return jsonify({'error': 'Invalid authentication code'}), 403 return jsonify({'error': 'Invalid authentication code'}), 403
# Store player in request context # Store player in request context
@@ -107,6 +113,60 @@ 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`` asks the player to authenticate again.
Both are *requests*, not enforcement: the flag is raised here and the token
is only replaced once the player actually re-authenticates (see
``Player.mark_authenticated``). That keeps a player which cannot handle the
request working instead of silently losing playlist sync.
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):
# Raise the request and record it ONCE, at the transition.
#
# This deliberately does not log per request: a player heartbeats every
# ~15s, so logging each time it is asked would add a server_log row
# every few seconds and drown the dashboard. The pending request stays
# visible in Admin -> Maintenance as "awaiting re-auth", which is the
# place to look, and the admin-triggered path is logged where the admin
# presses the button.
player.request_reauth()
db.session.commit()
log_action('info',
f'Auth token interval elapsed for {player.name} '
f'({player.hostname}) — re-authentication requested')
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 +218,13 @@ 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 finalise the token. If a re-authentication had
# been requested (admin reset or expiry interval), this is where the old
# token is retired: mark_authenticated() issues a brand-new one. The value
# is returned below as `auth_code`, so the player picks it up here. A
# player that re-authenticated with no request pending simply gets its
# issue time refreshed.
player.mark_authenticated()
player.update_status('online') player.update_status('online')
db.session.commit() db.session.commit()
@@ -508,6 +574,33 @@ 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
# ------------------------------------------------------------------
# Re-authentication request.
#
# When an admin requests a token reset (or an automatic reset interval
# elapses), the heartbeat is answered with 401 + auth_required so the
# client restarts its authentication flow. The player's existing token
# is still valid at this point — it is replaced only once the client
# actually re-authenticates — so a player that does not understand this
# response keeps syncing instead of breaking.
#
# This runs before any feedback is recorded: the heartbeat is not a
# meaningful status update while the client is being asked to
# re-authenticate. Note there is intentionally NO log line here — the
# request repeats on every heartbeat (~15s) until the player complies,
# which would flood server_log. The outstanding request is visible in
# Admin -> Maintenance ("awaiting re-auth") and is logged once where it
# is raised (admin action, or the interval transition above).
# ------------------------------------------------------------------
needs_reauth, reauth_reason = player_needs_reauth(player)
if needs_reauth:
return jsonify({
'success': False,
'auth_required': True,
'reason': reauth_reason,
'message': 'Re-authentication required. Please authenticate again.',
}), 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', '')
+6 -3
View File
@@ -300,9 +300,12 @@ 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 immediately and stamp the issue time so the
new_auth_code = secrets.token_urlsafe(16) # maintenance page's reset interval measures age from this point. The
player.auth_code = new_auth_code # admin is shown the new value, so an immediate swap is intended here —
# unlike the maintenance page, which only asks the player to re-auth.
new_auth_code = player.issue_new_token()
player.auth_reset_required = 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})')
+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_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',
] ]
+66
View File
@@ -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
""" """
@@ -36,6 +38,15 @@ class Player(db.Model):
last_seen = db.Column(db.DateTime, nullable=True, index=True) last_seen = db.Column(db.DateTime, nullable=True, index=True)
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 a re-authentication has
# been requested (by an admin, or by the automatic interval); the next
# heartbeat then tells the player to authenticate again, and the token is
# replaced at the moment it does. See :meth:`request_reauth`.
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'),
@@ -117,6 +128,61 @@ 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 issue_new_token(self) -> str:
"""Replace the authentication token immediately.
The old token stops working at once, so this is only safe when the
caller can hand the new value straight to the player — i.e. during
authentication itself, or when an admin explicitly asks for a new code
to be shown in the UI. To *ask* a running player to re-authenticate,
use :meth:`request_reauth` instead.
Returns:
The newly generated auth code.
"""
import secrets
self.auth_code = secrets.token_urlsafe(16)
self.auth_token_issued_at = datetime.utcnow()
return self.auth_code
def request_reauth(self) -> None:
"""Ask the player to re-authenticate on its next heartbeat.
Deliberately *deferred*: the current token is left valid and only the
``auth_reset_required`` flag is set. The heartbeat then answers
``401 {auth_required: true}``, and when the player re-authenticates a
brand-new token is issued (:meth:`mark_authenticated`), at which point
the old one finally stops working.
Why not rotate at once: the token guards the playlist endpoints, so
rotating it immediately would stop a player from syncing content the
moment the admin pressed the button — before the player ever had the
chance to re-authenticate. Deferring makes the operation non-breaking
for players that do not (yet) react to the request.
"""
self.auth_reset_required = True
def mark_authenticated(self) -> None:
"""Record a successful authentication.
Stamps the token issue time and clears any pending reset request. If a
reset *was* pending, this is the point where the old token dies: a
brand-new token is generated (available afterwards as ``auth_code``)
so the caller's response hands the player a fresh credential.
"""
if self.auth_reset_required:
self.issue_new_token()
else:
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.
+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,
}
+10 -14
View File
@@ -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;
+86
View File
@@ -577,6 +577,92 @@
width: 100%; width: 100%;
} }
} }
/* ------------------------------------------------------------------
Dark theme
------------------------------------------------------------------
This page predates the shared dark-mode variables and hard-coded its
own light palette (light panel/blue-tint backgrounds with dark text).
The rules below re-map those to dark equivalents so the page matches
the rest of the app when the dark theme is active.
------------------------------------------------------------------ */
body.dark-mode .back-link { color: #90cdf4; }
body.dark-mode .status-detection {
background: #1e3a5f;
border-left-color: #90cdf4;
}
body.dark-mode .badge-success { background: #1a4d2e; color: #86efac; }
body.dark-mode .badge-warning { background: #4a3800; color: #fbbf24; }
body.dark-mode .status-card { border-left-color: #4a5568; }
body.dark-mode .status-enabled {
background: linear-gradient(135deg, #1a4d2e 0%, #22543d 100%);
}
body.dark-mode .status-disabled {
background: linear-gradient(135deg, #4a3800 0%, #5a4a1e 100%);
}
body.dark-mode .status-badge { background: #276749; color: #e2e8f0; }
body.dark-mode .status-badge-inactive { background: #b45309; color: #fff; }
body.dark-mode .status-details code { background: rgba(255, 255, 255, 0.12); }
body.dark-mode .info-text {
background: #1e3a5f;
border-left-color: #90cdf4;
color: #cbd5e1;
}
body.dark-mode .form-group label { color: var(--text-color); }
body.dark-mode .form-input {
background: var(--card-bg);
color: var(--text-color);
border-color: var(--border-color);
}
body.dark-mode .form-input:focus { border-color: #90cdf4; }
body.dark-mode .form-hint { color: var(--text-secondary); }
body.dark-mode .required { color: #fca5a5; }
body.dark-mode .toggle-slider { background: #4a5568; }
body.dark-mode .toggle-slider::after { background: #e2e8f0; }
body.dark-mode .toggle-input:checked + .toggle-slider { background: #276749; }
body.dark-mode .toggle-text { color: var(--text-color); }
body.dark-mode .preview-section {
background: var(--bg-color);
border-color: #90cdf4;
}
body.dark-mode .preview-section h3 { color: #90cdf4; }
body.dark-mode .access-points li {
background: var(--card-bg);
border-left-color: #90cdf4;
}
body.dark-mode .access-points code { background: #1e3a5f; color: #90cdf4; }
body.dark-mode .form-actions { border-top-color: var(--border-color); }
body.dark-mode .info-card {
background: linear-gradient(135deg, #1e3a5f 0%, #162032 100%);
}
body.dark-mode .info-card h2 { color: #90cdf4; }
body.dark-mode .info-card li { color: var(--text-secondary); }
body.dark-mode .nginx-status-card {
background: linear-gradient(135deg, #162032 0%, #1e3a5f 100%);
border-left-color: #90cdf4;
}
body.dark-mode .nginx-status-card h2 { color: #90cdf4; }
body.dark-mode .status-item {
background: var(--card-bg);
border-left-color: #90cdf4;
}
body.dark-mode .status-item strong { color: var(--text-color); }
body.dark-mode .status-item code { background: #1e3a5f; color: #90cdf4; }
body.dark-mode .info-section h3 { color: #90cdf4; }
body.dark-mode .info-section li { color: var(--text-secondary); }
</style> </style>
<script> <script>
+422
View File
@@ -0,0 +1,422 @@
{% extends "base.html" %}
{% block title %}Maintenance - Admin - DigiServer v2{% endblock %}
{% block content %}
<style>
/* Theme-aware building blocks for the maintenance console. Using the
variables defined in base.html keeps every panel readable in both the
light and dark themes (inline colours would override the theme). */
.maint-panel {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.maint-thead {
background: var(--card-bg);
}
.maint-badge-pending {
display: inline-block;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #fef3c7;
color: #92400e;
}
/* Informational note (e.g. the "this is a request, not a forced logout"
explanation), themed so it stays readable in both modes. */
.maint-note {
padding: 10px 14px;
border-radius: 8px;
font-size: 0.875rem;
background: #e7f3ff;
border-left: 4px solid #0066cc;
color: #0c5460;
}
body.dark-mode .maint-note {
background: #1e3a5f;
border-left-color: #90cdf4;
color: #cbd5e1;
}
body.dark-mode .maint-badge-pending {
background: #4a3800;
color: #fbbf24;
}
/* Muted description text should stay readable on the dark card. */
body.dark-mode .card > p[style*="color: #6c757d"] {
color: var(--text-secondary) !important;
}
</style>
<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: 12px;">
Requests that a player authenticate again. The player is told on its next
heartbeat and receives a fresh token when it does — its current token stays
valid until then, so playlist syncing is not interrupted.
</p>
<p class="maint-note" style="margin-bottom: 20px;">
ℹ️ This asks the player to re-authenticate; it is not a forced logout. A player
whose software does not yet handle the request keeps working on its existing
token and simply stays <em>awaiting&nbsp;re-auth</em> below. Players do not need
a password — the quick-connect code they already hold is enough.
</p>
<form method="POST" action="{{ url_for('admin.maintenance_set_interval') }}"
class="maint-panel"
style="display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 25px; padding: 15px;">
<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 var(--border-color); border-radius: 6px; width: 140px;">
</div>
<div style="flex: 1; min-width: 220px; color: var(--text-secondary); 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('Ask ALL players to re-authenticate? Each one will be told on its next heartbeat and then receive a new token. Their current tokens keep working until then.');"
style="margin-bottom: 20px;">
<button type="submit" class="btn btn-danger">🔁 Request Re-auth for All Players</button>
</form>
<div style="max-height: 460px; overflow-y: auto;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead class="maint-thead" style="position: sticky; top: 0; z-index: 1;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid var(--border-color);">Player</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid var(--border-color);">Status</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid var(--border-color);">Token Age</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid var(--border-color);">Re-auth</th>
<th style="padding: 10px; text-align: right; border-bottom: 2px solid var(--border-color);">Actions</th>
</tr>
</thead>
<tbody>
{% for player in players %}
{% set age = player.auth_token_age_days() %}
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 10px;">
<strong>{{ player.name }}</strong>
<div style="font-size: 12px; color: var(--text-secondary);">{{ 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: var(--text-secondary);">○ {{ 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: var(--text-secondary);">
issued {{ player.auth_token_issued_at | localtime('%Y-%m-%d %H:%M') }}
</div>
{% endif %}
{% else %}
<span style="color: var(--text-secondary);">—</span>
{% endif %}
</td>
<td style="padding: 10px;">
{% if player.auth_reset_required %}
<span class="maint-badge-pending" title="The player has been asked to re-authenticate; the token is replaced once it does.">awaiting re-auth</span>
{% else %}
<span style="color: var(--text-secondary); 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('Ask {{ player.name }} to re-authenticate?');">
<button type="submit" class="btn btn-warning btn-sm"
style="padding: 4px 10px; font-size: 12px;">🔁 Re-auth</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: var(--text-secondary);">
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 var(--border-color);">' + c + '</td>';
}).join('') + '</tr>';
}
function esc(s) {
return String(s === null || s === undefined ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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: var(--text-secondary);">' +
'<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>' +
' &nbsp;·&nbsp; 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 var(--border-color);">' + 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 var(--border-color);">' + 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 var(--border-color);">' + 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 %}
+168 -2
View File
@@ -18,6 +18,10 @@
--primary-color: #667eea; --primary-color: #667eea;
--primary-dark: #5568d3; --primary-dark: #5568d3;
--secondary-color: #764ba2; --secondary-color: #764ba2;
/* Colour for text links. Kept separate from --primary-color so the
primary colour can stay dark enough for button backgrounds while
links remain legible on the matching background. */
--link-color: #667eea;
--bg-color: #f5f7fa; --bg-color: #f5f7fa;
--card-bg: #ffffff; --card-bg: #ffffff;
--text-color: #2d3748; --text-color: #2d3748;
@@ -34,6 +38,9 @@
--primary-color: #7c3aed; --primary-color: #7c3aed;
--primary-dark: #6d28d9; --primary-dark: #6d28d9;
--secondary-color: #8b5cf6; --secondary-color: #8b5cf6;
/* Dark background needs a lighter link colour (#7c3aed is too dim
on a dark surface). */
--link-color: #a78bfa;
--bg-color: #1a202c; --bg-color: #1a202c;
--card-bg: #2d3748; --card-bg: #2d3748;
--text-color: #e2e8f0; --text-color: #e2e8f0;
@@ -369,6 +376,165 @@
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
} }
/* ================================================================
Dark-mode fixes for INLINE colours
----------------------------------------------------------------
A recurring pattern across the templates is to set colours with
inline style="..." attributes (a few JS blocks build nodes the
same way). An inline declaration outranks every normal rule --
including the body.dark-mode rules above -- so those literals keep
their LIGHT value in dark mode. The text, which inherits the (now
light) --text-color, then ends up light-on-light: unreadable.
Inline styles can only be beaten with !important, so the light
literals are mapped to the theme variables here, centrally, with
the property scoped so a colour used for two things (e.g. #1a202c
is both a dark surface and dark text) is only changed where it is
wrong. This fixes every template at once -- including any future
page that repeats the pattern -- instead of patching each file.
Everything is scoped to body.dark-mode, so the LIGHT theme is
completely untouched.
Ordering matters: selectors have equal specificity, so later rules
win. The bare "#fff" rule is therefore placed BEFORE the "#fff3cd"
/"#fff8e1" ones, because "#fff" is a string prefix of them.
================================================================ */
/* --- White / near-white surfaces -> dark card --------------------- */
body.dark-mode [style*="background: white"], body.dark-mode [style*="background-color: white"],
body.dark-mode [style*="background: #fff"], body.dark-mode [style*="background-color: #fff"],
body.dark-mode [style*="background: #ffffff"], body.dark-mode [style*="background-color: #ffffff"],
body.dark-mode [style*="background: #fefefe"], body.dark-mode [style*="background-color: #fefefe"],
body.dark-mode [style*="background: #f5f7fa"], body.dark-mode [style*="background-color: #f5f7fa"],
body.dark-mode [style*="background: #e9ecef"], body.dark-mode [style*="background-color: #e9ecef"],
body.dark-mode [style*="background: #e0e0e0"], body.dark-mode [style*="background-color: #e0e0e0"],
body.dark-mode [style*="background: #f0f2ff"], body.dark-mode [style*="background-color: #f0f2ff"],
body.dark-mode [style*="background: #e8ebff"], body.dark-mode [style*="background-color: #e8ebff"],
body.dark-mode [style*="background: #e7e9ff"], body.dark-mode [style*="background-color: #e7e9ff"],
body.dark-mode [style*="background: #ede9fe"], body.dark-mode [style*="background-color: #ede9fe"] {
background: var(--card-bg) !important;
}
/* --- Light grey panels -> dark page background -------------------- */
body.dark-mode [style*="background: #f8f9fa"], body.dark-mode [style*="background-color: #f8f9fa"],
body.dark-mode [style*="background: #f0f0f0"], body.dark-mode [style*="background-color: #f0f0f0"],
body.dark-mode [style*="background: #f5f5f5"], body.dark-mode [style*="background-color: #f5f5f5"],
body.dark-mode [style*="background: #f4f4f4"], body.dark-mode [style*="background-color: #f4f4f4"],
body.dark-mode [style*="background: #f8fafc"], body.dark-mode [style*="background-color: #f8fafc"] {
background: var(--bg-color) !important;
}
/* --- Semantic pastel backgrounds -> dark equivalents -------------- */
/* info / blue */
body.dark-mode [style*="background: #d1ecf1"], body.dark-mode [style*="background-color: #d1ecf1"],
body.dark-mode [style*="background: #cce5ff"], body.dark-mode [style*="background-color: #cce5ff"],
body.dark-mode [style*="background: #e7f3ff"], body.dark-mode [style*="background-color: #e7f3ff"],
body.dark-mode [style*="background: #f0f7ff"], body.dark-mode [style*="background-color: #f0f7ff"] {
background: #1e3a5f !important;
}
/* success / green */
body.dark-mode [style*="background: #d4edda"], body.dark-mode [style*="background-color: #d4edda"],
body.dark-mode [style*="background: #d1f0e0"], body.dark-mode [style*="background-color: #d1f0e0"],
body.dark-mode [style*="background: #c6f6d5"], body.dark-mode [style*="background-color: #c6f6d5"] {
background: #1a4d2e !important;
}
/* warning / amber (must stay AFTER the "#fff" rule above) */
body.dark-mode [style*="background: #fff3cd"], body.dark-mode [style*="background-color: #fff3cd"],
body.dark-mode [style*="background: #fff8e1"], body.dark-mode [style*="background-color: #fff8e1"],
body.dark-mode [style*="background: #ffeaa7"], body.dark-mode [style*="background-color: #ffeaa7"],
body.dark-mode [style*="background: #fef3c7"], body.dark-mode [style*="background-color: #fef3c7"],
body.dark-mode [style*="background: #feebc8"], body.dark-mode [style*="background-color: #feebc8"] {
background: #4a3800 !important;
}
/* danger / red */
body.dark-mode [style*="background: #f8d7da"], body.dark-mode [style*="background-color: #f8d7da"],
body.dark-mode [style*="background: #fed7d7"], body.dark-mode [style*="background-color: #fed7d7"] {
background: #4a1a1a !important;
}
/* mid-grey chips / knobs */
body.dark-mode [style*="background: #ccc"], body.dark-mode [style*="background-color: #ccc"],
body.dark-mode [style*="background: #6c757d"], body.dark-mode [style*="background-color: #6c757d"] {
background: #4a5568 !important;
}
/* --- Dark text -> light text -------------------------------------- */
body.dark-mode [style*="color: black"] {
color: var(--text-color) !important;
}
body.dark-mode [style*="color: #6c757d"], body.dark-mode [style*="color:#6c757d"],
body.dark-mode [style*="color: #666"], body.dark-mode [style*="color: #888"],
body.dark-mode [style*="color: #999"], body.dark-mode [style*="color: #aaa"],
body.dark-mode [style*="color: #495057"], body.dark-mode [style*="color: #718096"],
body.dark-mode [style*="color: #7f8c8d"], body.dark-mode [style*="color: #5a6a78"],
body.dark-mode [style*="color: #4a5568"] {
color: var(--text-secondary) !important;
}
body.dark-mode [style*="color: #555"], body.dark-mode [style*="color: #475569"],
body.dark-mode [style*="color: #64748b"] {
color: #cbd5e1 !important;
}
body.dark-mode [style*="color: #333"], body.dark-mode [style*="color: #2c3e50"],
body.dark-mode [style*="color: #000"], body.dark-mode [style*="color: #1a202c"],
body.dark-mode [style*="color: #2d3748"] {
color: var(--text-color) !important;
}
/* links / info blue */
body.dark-mode [style*="color: #0066cc"], body.dark-mode [style*="color: #007bff"],
body.dark-mode [style*="color: #0056b3"], body.dark-mode [style*="color: #0c5460"],
body.dark-mode [style*="color: #004085"], body.dark-mode [style*="color: #3498db"] {
color: #90cdf4 !important;
}
/* success text */
body.dark-mode [style*="color: #155724"], body.dark-mode [style*="color: #156b2e"],
body.dark-mode [style*="color: #28a745"], body.dark-mode [style*="color: #27ae60"],
body.dark-mode [style*="color: #2f855a"] {
color: #86efac !important;
}
/* warning text */
body.dark-mode [style*="color: #856404"], body.dark-mode [style*="color: #92400e"],
body.dark-mode [style*="color: #c05621"], body.dark-mode [style*="color: #e67e22"] {
color: #fbbf24 !important;
}
/* danger text */
body.dark-mode [style*="color: #721c24"], body.dark-mode [style*="color: #dc3545"],
body.dark-mode [style*="color: #e74c3c"], body.dark-mode [style*="color: #e53e3e"],
body.dark-mode [style*="color:#e53e3e"], body.dark-mode [style*="color: #c53030"],
body.dark-mode [style*="color: #b91c1c"] {
color: #fca5a5 !important;
}
/* purple accents */
body.dark-mode [style*="color: #5b21b6"] {
color: #a78bfa !important;
}
/* --- Light borders -> dark borders ------------------------------- */
body.dark-mode [style*="border-top-color: white"] {
border-top-color: var(--border-color) !important;
}
body.dark-mode [style*="#e2e8f0"], body.dark-mode [style*="#dee2e6"],
body.dark-mode [style*="#ddd"], body.dark-mode [style*="#ccc"],
body.dark-mode [style*="#ced4da"], body.dark-mode [style*="#e0e0e0"],
body.dark-mode [style*="#eee"], body.dark-mode [style*="#e9ecef"],
body.dark-mode [style*="#f0f0f0"], body.dark-mode [style*="#f0f2ff"],
body.dark-mode [style*="#888"], body.dark-mode [style*="#999"] {
border-color: #4a5568 !important;
}
body.dark-mode [style*="#c3e6cb"], body.dark-mode [style*="#d1f0e0"] {
border-color: #2f855a !important;
}
body.dark-mode [style*="#f5c6cb"] {
border-color: #c53030 !important;
}
body.dark-mode [style*="#bee5eb"], body.dark-mode [style*="#cce5ff"] {
border-color: #2c5282 !important;
}
/* --- "Back" links (class used across the admin pages) ------------- */
body.dark-mode .back-link {
color: #90cdf4 !important;
}
</style> </style>
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
</head> </head>
@@ -393,12 +559,12 @@
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt=""> <img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="">
Playlists Playlists
</a> </a>
<a href="{{ url_for('admin.admin_panel') }}">Admin</a>
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a>
<a href="{{ url_for('main.help_page') }}"> <a href="{{ url_for('main.help_page') }}">
<img src="{{ url_for('static', filename='icons/help.svg') }}" alt="" onerror="this.style.display='none';"> <img src="{{ url_for('static', filename='icons/help.svg') }}" alt="" onerror="this.style.display='none';">
Ajutor Ajutor
</a> </a>
<a href="{{ url_for('admin.admin_panel') }}">Admin</a>
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a>
<button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle Dark Mode"> <button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle Dark Mode">
<img id="theme-icon" src="{{ url_for('static', filename='icons/moon.svg') }}" alt="Toggle theme"> <img id="theme-icon" src="{{ url_for('static', filename='icons/moon.svg') }}" alt="Toggle theme">
</button> </button>
@@ -39,9 +39,33 @@
.content-grid { .content-grid {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; /* 3fr/1fr (not 2fr/1fr) so the 9-column playlist table gets enough room;
at 2fr/1fr it was ~50px too narrow and looped with a scrollbar.
minmax(0, …) removes the track's automatic min-content floor, without
which the table's intrinsic width would widen the track itself. */
grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
gap: 20px; gap: 20px;
} }
/* The table needs ~830px and side by side it cannot get that below ~1440px.
Stack the cards instead of forcing a horizontal scrollbar on the table. */
@media (max-width: 1439px) {
.content-grid {
/* minmax(0, 1fr) matters here too: a bare 1fr keeps the min-content
floor, which let the 830px table widen the track and push the
page into horizontal overflow. */
grid-template-columns: minmax(0, 1fr);
}
}
/* Last-resort containment: on a genuinely narrow screen the table cannot
fit, and an unclipped table is painted straight over the Add Content
card, because that card is later in the DOM and therefore paints on top.
Clip here so the table scrolls inside its own card instead. */
.playlist-table-scroll {
overflow-x: auto;
max-width: 100%;
}
.playlist-table { .playlist-table {
width: 100%; width: 100%;
@@ -50,13 +74,13 @@
.playlist-table th { .playlist-table th {
background: #f8f9fa; background: #f8f9fa;
padding: 12px; padding: 8px;
text-align: left; text-align: left;
border-bottom: 2px solid #dee2e6; border-bottom: 2px solid #dee2e6;
} }
.playlist-table td { .playlist-table td {
padding: 12px; padding: 8px;
border-bottom: 1px solid #dee2e6; border-bottom: 1px solid #dee2e6;
} }
@@ -87,6 +111,56 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px;
/* On a narrow viewport the thumbnail + name + button no longer fit on
one line; wrapping drops the Add button below instead of squeezing
the name under it. */
flex-wrap: wrap;
}
/* Left group: thumbnail + name/size. min-width:0 lets long names ellipsise
instead of pushing the Add button out of the card. */
.avail-main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.avail-thumb {
width: 52px;
height: 52px;
flex: 0 0 auto;
border-radius: 6px;
overflow: hidden;
background: #e9ecef;
border: 1px solid #dee2e6;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
line-height: 1;
}
.avail-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avail-info {
min-width: 0;
}
.avail-name {
font-size: 14px;
/* Long media filenames carry the meaningful part at the END
(…_slide_003.png), so breaking mid-word is better than ellipsis,
which would hide the distinguishing characters. `anywhere` allows
breaks inside the unbroken run and keeps min-content width tiny. */
overflow-wrap: anywhere;
word-break: break-word;
}
.avail-meta {
font-size: 12px;
color: #999;
} }
/* Audio toggle styles */ /* Audio toggle styles */
@@ -215,6 +289,15 @@
body.dark-mode .available-content { body.dark-mode .available-content {
color: #e2e8f0; color: #e2e8f0;
} }
body.dark-mode .avail-thumb {
background: #2d3748;
border-color: #4a5568;
}
body.dark-mode .avail-meta {
color: #a0aec0;
}
/* Dark mode for duration spinner */ /* Dark mode for duration spinner */
body.dark-mode .duration-display { body.dark-mode .duration-display {
@@ -245,11 +328,18 @@
body.dark-mode .duration-spinner button.btn-decrease { body.dark-mode .duration-spinner button.btn-decrease {
color: #f56565; color: #f56565;
} }
/* The page needs more than base.html's 1200px container to show the
playlist table and the Add Content card side by side. This template
already declared max-width:1400px on its own wrapper, but a child cannot
widen its parent, so it had no effect. Widen the base container instead. */
body > .container {
max-width: 1400px;
}
</style> </style>
<div class="container" style="max-width: 1400px;"> <div class="container" style="max-width: 1400px;">
<div class="playlist-header"> <div class="playlist-header"> <h1>🎬 {{ playlist.name }}</h1>
<h1>🎬 {{ playlist.name }}</h1>
{% if playlist.description %} {% if playlist.description %}
<p style="margin: 5px 0; opacity: 0.9;">{{ playlist.description }}</p> <p style="margin: 5px 0; opacity: 0.9;">{{ playlist.description }}</p>
{% endif %} {% endif %}
@@ -290,6 +380,7 @@
</div> </div>
{% if playlist_content %} {% if playlist_content %}
<div class="playlist-table-scroll">
<table class="playlist-table" id="playlist-table"> <table class="playlist-table" id="playlist-table">
<thead> <thead>
<tr> <tr>
@@ -397,6 +488,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% else %} {% else %}
<div style="text-align: center; padding: 40px; color: #999;"> <div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px;">📭</div> <div style="font-size: 48px;">📭</div>
@@ -428,21 +520,32 @@
<div class="available-content"> <div class="available-content">
{% for content in available_content %} {% for content in available_content %}
<div class="content-item"> <div class="content-item">
<div> <div class="avail-main">
<div> <div class="avail-thumb">
{% if content.content_type == 'image' %}📷 {% if content.content_type == 'image' %}
{% elif content.content_type == 'video' %}🎥 {# Preview the real image so the user can see what
{% elif content.content_type == 'pdf' %}📄 is being added. Falls back to the camera icon
{% else %}📁{% endif %} if the file is missing or fails to load. #}
{{ content.original_display_name }} <img src="{{ url_for('static', filename='uploads/' + content.current_media_path) }}"
alt="{{ content.original_display_name }}"
loading="lazy"
onerror="this.parentElement.innerHTML='<span>📷</span>';">
{% elif content.content_type == 'video' %}<span>🎥</span>
{% elif content.content_type == 'pdf' %}<span>📄</span>
{% else %}<span>📁</span>{% endif %}
</div> </div>
<div style="font-size: 12px; color: #999;"> <div class="avail-info">
{{ content.file_size_mb }} MB <div class="avail-name" title="{{ content.original_display_name }}">
{{ content.original_display_name }}
</div>
<div class="avail-meta">
{{ content.file_size_mb }} MB
</div>
</div> </div>
</div> </div>
<form method="POST" <form method="POST"
action="{{ url_for('content.add_content_to_playlist', playlist_id=playlist.id) }}" action="{{ url_for('content.add_content_to_playlist', playlist_id=playlist.id) }}"
style="display: inline;"> style="display: inline; flex: 0 0 auto;">
<input type="hidden" name="content_id" value="{{ content.id }}"> <input type="hidden" name="content_id" value="{{ content.id }}">
<input type="hidden" name="duration" value="{{ content.duration }}"> <input type="hidden" name="duration" value="{{ content.duration }}">
<button type="submit" class="btn btn-success btn-sm"> <button type="submit" class="btn btn-success btn-sm">
+2 -2
View File
@@ -315,7 +315,7 @@ body.dark-mode .compact-btn:not(.btn-success) .compact-btn-icon {
.playlist-name { .playlist-name {
font-weight: 600; font-weight: 600;
font-size: 1rem; font-size: 1rem;
color: var(--primary-color); color: var(--link-color);
text-decoration: none; text-decoration: none;
word-break: break-word; word-break: break-word;
} }
@@ -374,7 +374,7 @@ body.dark-mode .player-chip.is-offline .status-dot { background: #718096; }
font-size: 0.85rem; font-size: 0.85rem;
} }
.unassigned-note a { .unassigned-note a {
color: var(--primary-color); color: var(--link-color);
} }
/* Sidebar collapses below the main column when space runs out. */ /* Sidebar collapses below the main column when space runs out. */
+3 -3
View File
@@ -51,7 +51,7 @@
} }
.help-toc a:hover { .help-toc a:hover {
background: var(--bg-color); background: var(--bg-color);
color: var(--primary-color); color: var(--link-color);
} }
.help-toc a.active { .help-toc a.active {
background: var(--primary-color); background: var(--primary-color);
@@ -79,7 +79,7 @@
font-size: 1.4rem; font-size: 1.4rem;
margin: 2.25rem 0 0.85rem; margin: 2.25rem 0 0.85rem;
padding-top: 1rem; padding-top: 1rem;
color: var(--primary-color); color: var(--link-color);
scroll-margin-top: 20px; scroll-margin-top: 20px;
} }
.help-content h3 { .help-content h3 {
@@ -99,7 +99,7 @@
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
margin: 2rem 0; margin: 2rem 0;
} }
.help-content a { color: var(--primary-color); } .help-content a { color: var(--link-color); }
.help-content code { .help-content code {
background: var(--bg-color); background: var(--bg-color);
padding: 0.15rem 0.4rem; padding: 0.15rem 0.4rem;
+80 -20
View File
@@ -290,24 +290,24 @@
</div> </div>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div id="deleteModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4);"> <div id="deleteModal" class="modal-overlay">
<div style="background-color: #fefefe; margin: 15% auto; padding: 20px; border: 1px solid #888; border-radius: 8px; width: 90%; max-width: 500px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);"> <div class="modal-box">
<h2 style="margin-top: 0; color: #dc3545; display: flex; align-items: center; gap: 0.5rem;"> <h2 class="modal-title">
<span style="font-size: 1.5rem;">⚠️</span> <span style="font-size: 1.5rem;">⚠️</span>
Confirm Delete Confirm Delete
</h2> </h2>
<p style="font-size: 1.1rem; margin: 1.5rem 0;"> <p class="modal-text">
Are you sure you want to delete player <strong>"{{ player.name }}"</strong>? Are you sure you want to delete player <strong class="modal-player">"{{ player.name }}"</strong>?
</p> </p>
<p style="color: #dc3545; margin: 1rem 0;"> <p class="modal-warning">
<strong>Warning:</strong> This action cannot be undone. All feedback logs for this player will also be deleted. <strong>Warning:</strong> This action cannot be undone. All feedback logs for this player will also be deleted.
</p> </p>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 2rem;"> <div class="modal-actions">
<button onclick="closeDeleteModal()" class="btn" style="background: #6c757d;"> <button onclick="closeDeleteModal()" class="btn modal-cancel">
Cancel Cancel
</button> </button>
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}" style="margin: 0;"> <form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}" style="margin: 0;">
<button type="submit" class="btn" style="background: #dc3545;"> <button type="submit" class="btn btn-danger">
Yes, Delete Player Yes, Delete Player
</button> </button>
</form> </form>
@@ -316,23 +316,83 @@
</div> </div>
<style> <style>
body.dark-mode #deleteModal > div { /* Delete confirmation modal — themed via the CSS variables defined in
background-color: #1a202c; base.html so it follows the light/dark theme. Inline colours are
border-color: #4a5568; deliberately avoided: they would win over the theme rules and leave
color: #e2e8f0; the modal white (with light text) in dark mode. */
.modal-overlay {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
} }
body.dark-mode #deleteModal h2 { .modal-box {
background-color: var(--card-bg);
color: var(--text-color);
margin: 15% auto;
padding: 20px;
border: 1px solid var(--border-color);
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: var(--shadow-lg);
}
.modal-title {
margin-top: 0;
color: #dc3545;
display: flex;
align-items: center;
gap: 0.5rem;
}
body.dark-mode .modal-title {
color: #f87171; color: #f87171;
} }
body.dark-mode #deleteModal p { .modal-text {
color: #e2e8f0; font-size: 1.1rem;
margin: 1.5rem 0;
color: var(--text-color);
} }
body.dark-mode #deleteModal p strong { .modal-player {
color: var(--link-color);
}
.modal-warning {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-radius: 6px;
background: #fff3cd;
color: #856404;
}
body.dark-mode .modal-warning {
background: #4a3800;
color: #fbbf24; color: #fbbf24;
} }
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 2rem;
}
.modal-cancel {
background: #6c757d;
}
.modal-cancel:hover {
background: #5a6268;
}
</style> </style>
<script> <script>
+1
View File
@@ -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..."
+166
View File
@@ -0,0 +1,166 @@
# Player authentication & token re-authentication
Server-side implementation reference for the player client
(`Kiwy-Signage`, https://gitea.moto-adv.com/ske087/Kiwy-Signage.git).
This document describes how the DigiServer side behaves so the player can be
updated to match. **The server side is complete; the client still needs to
handle the re-authentication request** (see "What the player must implement").
---
## 1. How a player authenticates today
The player reads `config/app_config.json` (written by the SSH deploy step),
which contains:
```json
{
"server_ip": "192.168.0.159",
"port": "443",
"screen_name": "rpi-test1", // == Player.hostname on the server
"quickconnect_key": "AB12CD", // == Player.quickconnect_code (plaintext)
"orientation": "Landscape",
"use_https": true,
"verify_ssl": false
}
```
Note the player builds URLs as `{scheme}://{server_ip}:{port}/api/...` — it does
**not** use any path prefix.
### Authentication flow (documented in `docs/05-blueprints-api.md`)
```mermaid
sequenceDiagram
participant P as Player
participant A as DigiServer
P->>A: POST /api/auth/player {hostname, quickconnect_code}
A-->>P: {auth_code, player_id, playlist_id, orientation}
Note over P: stores auth_code (player_auth.json)
P->>A: GET /api/playlists/<player_id> (Bearer auth_code)
A-->>P: playlist + playlist_version
loop refresh poll
P->>A: GET /api/playlist-version/<player_id> (Bearer auth_code)
A-->>P: {playlist_version, content_count}
end
loop heartbeat (~15s)
P->>A: POST /api/player-feedback {hostname, status, ...}
A-->>P: {success: true}
end
```
### Which endpoints use the token
| Endpoint | Auth |
|---|---|
| `POST /api/auth/player` | hostname + password **or** quickconnect code (no token) |
| `GET /api/playlists?hostname=&quickconnect_code=` | quickconnect code (no token) |
| `GET /api/playlists/<player_id>` | **Bearer `auth_code`** |
| `GET /api/playlist-version/<player_id>` | **Bearer `auth_code`** |
| `POST /api/player-edit-media` | **Bearer `auth_code`** |
| `POST /api/player-feedback` (heartbeat) | none — the player is looked up by hostname |
Because the token guards the playlist endpoints, **an invalid token stops
content sync**, not just auth.
---
## 2. Re-authentication request (what changed on the server)
An admin can request re-authentication from **Admin → Maintenance → Player
Authentication Tokens**, either per player or for all players. There is also an
optional automatic interval (days) after which the request fires on its own.
A re-authentication request is **deferred** — the server does *not* invalidate
the current token:
```mermaid
sequenceDiagram
participant Admin
participant A as DigiServer
participant P as Player
Admin->>A: request re-auth
A->>A: auth_reset_required = true (token unchanged)
P->>A: POST /api/player-feedback
A-->>P: 401 {auth_required: true, reason: "token_reset"}
Note over P: ⚠️ CLIENT ACTION REQUIRED HERE
P->>A: POST /api/auth/player {hostname, quickconnect_code}
A->>A: issue NEW token, clear flag
A-->>P: {auth_code: NEW}
Note over P: store new auth_code, retry requests
P->>A: GET /api/playlists/<id> (Bearer NEW) ✅
```
Key points:
* Until the player re-authenticates, its **old token remains valid** — so a
player that ignores the request keeps syncing content (no outage).
* The new token is issued **during** `POST /api/auth/player`, and returned in
the `auth_code` field of the response, exactly like first-time setup.
* The player needs **no password**: the `quickconnect_key` already in
`app_config.json` is sufficient.
* No SSH, restart, or redeploy is required.
* The admin UI shows the player as **awaiting re-auth** until this completes;
the admin can also **Cancel** the request.
---
## 3. What the player must implement
Today, if the client never handles the request, nothing breaks — but the reset
never happens (the player keeps its old token forever and the admin UI stays on
"awaiting re-auth").
To make the reset effective, the player client should:
1. **Detect the request.** On the heartbeat response, treat
`HTTP 401` with `auth_required: true` as "re-authenticate", e.g.:
```python
resp = requests.post(f"{base}/api/player-feedback", json=payload, timeout=10)
if resp.status_code == 401:
body = resp.json() if resp.content else {}
if body.get("auth_required"):
reauthenticate(reason=body.get("reason")) # "token_reset" | "token_expired"
return
```
2. **Also handle 401 on the token-guarded calls.** If the token is ever
rejected by `/api/playlists/<id>` or `/api/playlist-version/<id>`, run the
same re-authentication path. This makes the client self-healing even if the
heartbeat was missed.
3. **Re-authenticate using the stored quick-connect code:**
```python
def reauthenticate():
cfg = load_app_config() # config/app_config.json
r = requests.post(f"{base}/api/auth/player", json={
"hostname": cfg["screen_name"],
"quickconnect_code": cfg["quickconnect_key"],
}, timeout=15)
r.raise_for_status()
save_auth(r.json()["auth_code"]) # player_auth.json
# then resume: fetch playlist + version with the new token
```
4. **Retry the failed request** with the new token so sync resumes seamlessly.
5. **Be idempotent / back off.** While a request is pending the heartbeat keeps
answering `401`; avoid a tight retry loop (e.g. re-authenticate at most once
per N seconds, or immediately then resume the normal heartbeat cadence).
### Recommended: also add `re-auth` to the edit-media path
`POST /api/player-edit-media` is token-guarded too, so the same 401 →
re-authenticate → retry wrapper should apply there.
---
## 4. Manual alternative (no client change needed)
Redeploying the player via **Admin → Build Player Files / Deploy** rewrites
`config/app_config.json` and deletes `player_auth.json`, which forces a clean
authentication on next start. This is the deterministic path and is what the
maintenance reset is intended to replace (it is remote and non-disruptive).
+68
View File
@@ -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.")