Sanitize codebase, reorganize docs, and add missing deploy files

Remove dead code identified in docs/SANITIZATION-REVIEW.md:
- app/blueprints/content_old.py and app/blueprints/playlist.py
- app/models/group.py, app/utils/nginx_config_reader.py
- orphaned templates (content_list, edit_content, upload_content,
  player_page) and the related group/Template references

Result: 6 blueprints, 82 routes, no dead modules or orphan templates.

Add files that deploy.sh and docker-entrypoint.sh already require but
which were never tracked:
- https_manager.py       (referenced by deploy.sh, migrate_network.sh,
                          docker-entrypoint.sh)
- Caddyfile.example      (seeded by deploy.sh; its absence aborts deploy)

Relocate generated Graphify artifacts from graphify-out/ to
docs/graphify-out/ (110 files, no content change) and archive the
superseded docs under docs/.

Ignore hygiene:
- ignore ad-hoc .env backups (.env.bak*) — they contain live secrets
- keep the pre-sanitization snapshots (docs/legacy code/,
  docs/old_code_documentation/) on disk but out of the repo

Fix .env.example: drop a duplicated config block, genericize the
hardcoded host IP, and document HOSTNAME_INTERNAL.
This commit is contained in:
2026-09-11 12:18:34 +03:00
parent 1c5186463a
commit 46602f1933
226 changed files with 3999 additions and 15737 deletions
+5
View File
@@ -47,6 +47,11 @@ Thumbs.db
# Runtime data volumes (mounted at runtime, NOT part of the image) # Runtime data volumes (mounted at runtime, NOT part of the image)
data/ data/
# Archived snapshot of the pre-sanitization codebase (not part of the image).
# Matched at any depth so it stays excluded regardless of where it is moved.
legacy code/
**/legacy code/
# Documentation # Documentation
BLUEPRINT_GUIDE.md BLUEPRINT_GUIDE.md
ICON_INTEGRATION.md ICON_INTEGRATION.md
+41 -14
View File
@@ -2,6 +2,47 @@
# Copy to .env and update with your production values # Copy to .env and update with your production values
# IMPORTANT: Never commit this file to git # IMPORTANT: Never commit this file to git
# Server Configuration
# ---------------------------------------------------------------------------
# Deploy-time TLS bootstrap. Copy this file to `.env` and set these two before
# `docker compose up`. Both must be present for HTTPS to be configured at
# startup; if either is missing the app stays on the plain-HTTP fallback and you
# can enable HTTPS later from Admin → HTTPS Configuration (no restart needed).
# ---------------------------------------------------------------------------
# Hostname shown in the UI and used in the Caddy site block.
HOSTNAME_INTERNAL=digiserver
# The host's LAN IP as reachable by the players/browsers.
# Replace 192.168.1.100 with THIS server's actual LAN IP. It is used for the
# Caddy site blocks and the certificate, so a wrong value breaks HTTPS.
# Find it with: ip -4 route get 1.1.1.1 | grep -oP 'src \K[\d.]+'
HOST_IP=192.168.1.100
# Public domain for Let's Encrypt. LEAVE EMPTY for an intranet/internal name
# (e.g. "digiserver" or "signage.corp.local") — a non-public name cannot pass an
# ACME challenge, so an empty DOMAIN selects Caddy's internal CA instead.
DOMAIN=
# Email for ACME/Let's Encrypt notifications (unused by the internal CA).
SSL_EMAIL=admin@example.com
# Published ports. Caddy listens on 80/443 inside the container; these control
# which host ports they are mapped to. Port 80 is always answered — the site
# responds whether clients use the IP or the hostname.
HTTP_PORT=80
HTTPS_PORT=443
# "true" → also serve plain HTTP alongside HTTPS. Required for players whose
# trust store lacks the internal CA (i.e. verify_ssl is not disabled).
# "false" → serve TLS only and redirect HTTP to https://<host>:<HTTPS_PORT>.
HTTPS_HTTP_FALLBACK=true
# After configuring HTTPS, probe it and automatically fall back to plain HTTP if
# it does not come up — so a bad certificate can never make the site unreachable.
# Set "false" to trust the configuration without probing.
HTTPS_VERIFY=true
# Flask Configuration # Flask Configuration
FLASK_ENV=production FLASK_ENV=production
FLASK_APP=app.app:create_app FLASK_APP=app.app:create_app
@@ -20,25 +61,11 @@ ADMIN_EMAIL=admin@your-domain.com
# For SQLite: sqlite:////data/instance/dashboard.db # For SQLite: sqlite:////data/instance/dashboard.db
# DATABASE_URL= # DATABASE_URL=
# Server Configuration
# Set BEFORE deployment if host will have static IP after restart
# This IP/domain will be used for SSL certificates and nginx configuration
DOMAIN=your-domain.com
HOST_IP=192.168.0.121
EMAIL=admin@your-domain.com
PREFERRED_URL_SCHEME=https PREFERRED_URL_SCHEME=https
# SSL/HTTPS (configured in nginx.conf by default)
SSL_CERT_PATH=/etc/nginx/ssl/cert.pem
SSL_KEY_PATH=/etc/nginx/ssl/key.pem
# Logging # Logging
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Security Headers (configured in nginx.conf)
HSTS_MAX_AGE=31536000
HSTS_INCLUDE_SUBDOMAINS=true
# Features (optional) # Features (optional)
ENABLE_LIBREOFFICE=true ENABLE_LIBREOFFICE=true
MAX_UPLOAD_SIZE=500000000 # 500MB MAX_UPLOAD_SIZE=500000000 # 500MB
+24
View File
@@ -26,6 +26,23 @@ data/
# Environment # Environment
.env .env
.env.local .env.local
.env.*.local
# Ad-hoc backups of .env (they contain real secrets — must never be committed)
.env.bak
.env.bak.*
.env.*.bak
*.env.bak
# Deployment artefacts that contain secrets
.deployment-credentials
caddy-root.crt
# Certificates / keys (generated by Caddy or the host)
*.pem
*.key
!data/caddy-data/**
!Caddyfile.example
# Database # Database
*.db *.db
@@ -59,3 +76,10 @@ build/
#data #data
data/ data/
# Local archive / restore-point snapshots.
# Kept on disk for reference (see docs/SANITIZATION-REVIEW.md) but deliberately
# NOT tracked: docs/legacy code/ is a full pre-sanitization repo snapshot and
# docs/old_code_documentation/ is a byte-identical copy of the tree inside it.
docs/legacy code/
docs/old_code_documentation/
+35
View File
@@ -0,0 +1,35 @@
{
# Caddy admin API — used by DigiServer to reload config after HTTPS is enabled
admin 0.0.0.0:2019
}
# Default: serve the app on HTTP port 80
# Once HTTPS is configured via Admin → HTTPS Config, Caddy will reload
# this file and start provisioning a Let's Encrypt certificate automatically.
:80 {
reverse_proxy digiserver-app:5000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
transport http {
read_timeout 300s
write_timeout 300s
}
}
request_body {
max_size 2GB
}
encode gzip
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
}
log {
output file /var/log/caddy/access.log
}
}
-2
View File
@@ -94,7 +94,6 @@ def register_blueprints(app):
from app.blueprints.admin import admin_bp from app.blueprints.admin import admin_bp
from app.blueprints.players import players_bp from app.blueprints.players import players_bp
from app.blueprints.content import content_bp from app.blueprints.content import content_bp
from app.blueprints.playlist import playlist_bp
from app.blueprints.api import api_bp from app.blueprints.api import api_bp
# Register blueprints (using URL prefixes from blueprint definitions) # Register blueprints (using URL prefixes from blueprint definitions)
@@ -103,7 +102,6 @@ def register_blueprints(app):
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
app.register_blueprint(players_bp) app.register_blueprint(players_bp)
app.register_blueprint(content_bp) app.register_blueprint(content_bp)
app.register_blueprint(playlist_bp)
app.register_blueprint(api_bp) app.register_blueprint(api_bp)
+69 -47
View File
@@ -1072,11 +1072,14 @@ def build_player():
@login_required @login_required
@admin_required @admin_required
def build_player_action(): def build_player_action():
"""Build/refresh the staged player code and/or write its base config.""" """Start building/refreshing the staged player code.
from app.utils.player_build import (
build_player_files, write_base_config, get_short_head, The build runs in a background thread because a full clone of the player
save_build_settings, make_build_record, repository takes far longer than gunicorn's worker timeout; running it
) in-request would get the worker killed mid-clone and leave a broken
checkout. The page then polls ``admin.build_player_status`` for progress.
"""
from app.utils.player_build import start_background_build, is_build_running
player_code_dir = current_app.config['PLAYER_CODE_DIR'] player_code_dir = current_app.config['PLAYER_CODE_DIR']
action = request.form.get('action', 'build_and_config') action = request.form.get('action', 'build_and_config')
@@ -1090,16 +1093,14 @@ def build_player_action():
orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape' orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape'
max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080' max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080'
# Validation # Validation (unchanged — fail fast before starting any work)
errors = [] errors = []
if action in ('build_files', 'build_and_config') and not repo_url: if action in ('build_files', 'build_and_config') and not repo_url:
errors.append('Repository URL is required to build player files.') errors.append('Repository URL is required to build player files.')
if action in ('save_config', 'build_and_config') and not server_ip: if action in ('save_config', 'build_and_config') and not server_ip:
errors.append('Server IP / domain is required for the player configuration.') errors.append('Server IP / domain is required for the player configuration.')
try: try:
port_num = int(port) int(port)
if port_num < 1 or port_num > 65535:
errors.append('Port must be between 1 and 65535.')
except ValueError: except ValueError:
errors.append('Port must be a valid number.') errors.append('Port must be a valid number.')
@@ -1108,51 +1109,72 @@ def build_player_action():
flash(err, 'warning') flash(err, 'warning')
return redirect(url_for('admin.build_player')) return redirect(url_for('admin.build_player'))
messages = [] # 'save_config' only writes the config file — it touches no network and is
success = True # fast, so it stays synchronous.
version = None if action == 'save_config':
from app.utils.player_build import (
# Step 1: build/refresh files from the repository. write_base_config, get_short_head, save_build_settings, make_build_record,
if action in ('build_files', 'build_and_config'): )
result = build_player_files(player_code_dir, repo_url, branch)
version = result.get('version')
messages.append(result['message'])
if not result['success']:
success = False
log_action('error', f'Player build failed by {current_user.username}: {result["message"]}')
# Step 2: write the base config (only if the previous step didn't fail).
if success and action in ('save_config', 'build_and_config'):
cfg_result = write_base_config( cfg_result = write_base_config(
player_code_dir=player_code_dir, player_code_dir=player_code_dir,
server_ip=server_ip, server_ip=server_ip, port=port, use_https=use_https,
port=port, verify_ssl=verify_ssl, orientation=orientation,
use_https=use_https,
verify_ssl=verify_ssl,
orientation=orientation,
max_resolution=max_resolution, max_resolution=max_resolution,
) )
messages.append(cfg_result['message'])
if not cfg_result['success']:
success = False
# Persist settings so deployment uses the same server address.
if version is None:
version = get_short_head(player_code_dir) version = get_short_head(player_code_dir)
save_build_settings( save_build_settings(
_player_build_meta_path(), _player_build_meta_path(),
make_build_record( make_build_record(
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port, repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation, use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
max_resolution=max_resolution, version=version, built_by=current_user.username, max_resolution=max_resolution, version=version,
), built_by=current_user.username,
),
)
if cfg_result['success']:
log_action('info', f'Player config saved by {current_user.username}')
flash(f"{cfg_result['message']}", 'success')
else:
log_action('error', f'Player config write failed: {cfg_result["message"]}')
flash(f"⚠️ {cfg_result['message']}", 'danger')
return redirect(url_for('admin.build_player'))
# build_files / build_and_config → background thread.
if is_build_running():
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
return redirect(url_for('admin.build_player'))
config_payload = None
if action == 'build_and_config':
config_payload = {
'server_ip': server_ip, 'port': port, 'use_https': use_https,
'verify_ssl': verify_ssl, 'orientation': orientation,
'max_resolution': max_resolution,
}
started = start_background_build(
player_code_dir=player_code_dir,
repo_url=repo_url,
branch=branch,
config_payload=config_payload,
meta_path=_player_build_meta_path(),
built_by=current_user.username,
) )
summary = ' '.join(messages) if messages else 'No action performed.' if not started:
if success: flash('⚠️ A build is already running — wait for it to finish.', 'warning')
log_action('info', f'Player files built by {current_user.username} (version {version})')
flash(f'{summary}', 'success')
else: else:
flash(f'⚠️ {summary}', 'danger') log_action('info', f'Player build started by {current_user.username} '
f'({branch} @ {repo_url})')
flash('⏳ Build started — this page will update automatically.', 'info')
return redirect(url_for('admin.build_player')) return redirect(url_for('admin.build_player'))
@admin_bp.route('/build-player/status', methods=['GET'])
@login_required
@admin_required
def build_player_status():
"""JSON progress for the running/last player build (polled by the page)."""
from app.utils.player_build import get_build_state
return jsonify(get_build_state())
+36 -51
View File
@@ -8,7 +8,9 @@ import bcrypt
from typing import Optional, Dict, List from typing import Optional, Dict, List
from app.extensions import db, cache from app.extensions import db, cache
from app.models import Player, Content, PlayerFeedback, ServerLog from app.models import (
Player, Playlist, Content, PlayerFeedback, ServerLog,
)
from app.utils.logger import log_action from app.utils.logger import log_action
api_bp = Blueprint('api', __name__, url_prefix='/api') api_bp = Blueprint('api', __name__, url_prefix='/api')
@@ -86,6 +88,25 @@ def verify_player_auth(f):
return decorated_function return decorated_function
def get_assigned_playlist(player: Player) -> Optional[Playlist]:
"""Return the playlist assigned to *player*, or ``None`` if unassigned.
Centralises playlist lookup so every endpoint reports the same sync
version. Playlist edits bump ``Playlist.version``; players poll that
value to decide whether their cached content is stale. A player with no
assigned playlist has nothing to sync and resolves to version 0.
Args:
player: The player whose assigned playlist should be resolved.
Returns:
The assigned ``Playlist`` instance, or ``None`` when unassigned.
"""
if not player.playlist_id:
return None
return db.session.get(Playlist, player.playlist_id)
@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."""
@@ -113,7 +134,7 @@ def authenticate_player():
quickconnect_code: Quick connect code (optional if using password) quickconnect_code: Quick connect code (optional if using password)
Returns: Returns:
JSON with auth_code, player_id, group_id, and configuration JSON with auth_code, player_id, playlist_id, and configuration
""" """
data = request.get_json() data = request.get_json()
@@ -265,12 +286,8 @@ def get_playlist_by_quickconnect():
db.session.commit() db.session.commit()
# Get playlist version from the assigned playlist # Get playlist version from the assigned playlist
playlist_version = 1 assigned_playlist = get_assigned_playlist(player)
if player.playlist_id: playlist_version = assigned_playlist.version if assigned_playlist else 0
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
# Hash the quickconnect code for validation on client side # Hash the quickconnect code for validation on client side
hashed_quickconnect = bcrypt.hashpw( hashed_quickconnect = bcrypt.hashpw(
@@ -322,12 +339,8 @@ def get_player_playlist(player_id: int):
db.session.commit() db.session.commit()
# Get playlist version from the assigned playlist # Get playlist version from the assigned playlist
playlist_version = 1 assigned_playlist = get_assigned_playlist(player)
if player.playlist_id: playlist_version = assigned_playlist.version if assigned_playlist else 0
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
return jsonify({ return jsonify({
'player_id': player_id, 'player_id': player_id,
@@ -363,10 +376,16 @@ def get_playlist_version(player_id: int):
player.last_seen = datetime.utcnow() player.last_seen = datetime.utcnow()
db.session.commit() db.session.commit()
# Player syncs against the version of its assigned playlist; the
# content count comes from that same playlist (Content has no
# player_id column - it reaches players through the playlist).
assigned_playlist = get_assigned_playlist(player)
return jsonify({ return jsonify({
'player_id': player_id, 'player_id': player_id,
'playlist_version': player.playlist_version, 'playlist_id': player.playlist_id,
'content_count': Content.query.filter_by(player_id=player_id).count() 'playlist_version': assigned_playlist.version if assigned_playlist else 0,
'content_count': assigned_playlist.contents.count() if assigned_playlist else 0
}) })
except Exception as e: except Exception as e:
@@ -378,7 +397,6 @@ def get_playlist_version(player_id: int):
def get_cached_playlist(player_id: int) -> List[Dict]: def get_cached_playlist(player_id: int) -> List[Dict]:
"""Get cached playlist for a player based on assigned playlist.""" """Get cached playlist for a player based on assigned playlist."""
from flask import url_for from flask import url_for
from app.models import Playlist
player = Player.query.get(player_id) player = Player.query.get(player_id)
if not player or not player.playlist_id: if not player or not player.playlist_id:
@@ -556,7 +574,6 @@ def get_player_status(player_id: int):
'player_id': player_id, 'player_id': player_id,
'name': player.name, 'name': player.name,
'location': player.location, 'location': player.location,
'group_id': player.group_id,
'status': player.status, 'status': player.status,
'is_online': is_online, 'is_online': is_online,
'last_seen': player.last_seen.isoformat() if player.last_seen else None, 'last_seen': player.last_seen.isoformat() if player.last_seen else None,
@@ -593,7 +610,6 @@ def system_info():
try: try:
# Get counts # Get counts
total_players = Player.query.count() total_players = Player.query.count()
total_groups = Group.query.count()
total_content = Content.query.count() total_content = Content.query.count()
# Count online players (seen in last 5 minutes) # Count online players (seen in last 5 minutes)
@@ -610,7 +626,6 @@ def system_info():
'total': total_players, 'total': total_players,
'online': online_players 'online': online_players
}, },
'groups': total_groups,
'content': total_content, 'content': total_content,
'logs_24h': recent_logs, 'logs_24h': recent_logs,
'timestamp': datetime.utcnow().isoformat() 'timestamp': datetime.utcnow().isoformat()
@@ -621,35 +636,6 @@ def system_info():
return jsonify({'error': 'Internal server error'}), 500 return jsonify({'error': 'Internal server error'}), 500
# DEPRECATED: Groups functionality has been archived
# @api_bp.route('/groups', methods=['GET'])
# @rate_limit(max_requests=60, window=60)
# def list_groups():
# """List all groups with basic information."""
# try:
# groups = Group.query.order_by(Group.name).all()
#
# groups_data = []
# for group in groups:
# groups_data.append({
# 'id': group.id,
# 'name': group.name,
# 'description': group.description,
# 'player_count': group.players.count(),
# 'content_count': group.contents.count()
# })
#
# return jsonify({
# 'groups': groups_data,
# 'count': len(groups_data)
# })
#
# except Exception as e:
# log_action('error', f'Error listing groups: {str(e)}')
# return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/content', methods=['GET']) @api_bp.route('/content', methods=['GET'])
@rate_limit(max_requests=60, window=60) @rate_limit(max_requests=60, window=60)
def list_content(): def list_content():
@@ -665,8 +651,7 @@ def list_content():
'type': content.content_type, 'type': content.content_type,
'duration': content.duration, 'duration': content.duration,
'size': content.file_size, 'size': content.file_size,
'uploaded_at': content.uploaded_at.isoformat(), 'uploaded_at': content.uploaded_at.isoformat()
'group_count': content.groups.count()
}) })
return jsonify({ return jsonify({
-500
View File
@@ -1,500 +0,0 @@
"""Content blueprint for media upload and management."""
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app, send_from_directory)
from flask_login import login_required
from werkzeug.utils import secure_filename
import os
from typing import Optional, Dict
import json
from app.extensions import db, cache
from app.models import Content, Group
from app.utils.logger import log_action
from app.utils.uploads import (
save_uploaded_file,
process_video_file,
process_pdf_file,
get_upload_progress,
set_upload_progress
)
content_bp = Blueprint('content', __name__, url_prefix='/content')
# In-memory storage for upload progress (for simple demo; use Redis in production)
upload_progress = {}
@content_bp.route('/')
@login_required
def content_list():
"""Display list of all content."""
try:
# Get all unique content files (by filename)
from sqlalchemy import func
# Get content with player information
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
# Group content by filename to show which players have each file
content_map = {}
for content in contents:
if content.filename not in content_map:
content_map[content.filename] = {
'content': content,
'players': [],
'groups': []
}
# Add player info if assigned to a player
if content.player_id:
from app.models import Player
player = Player.query.get(content.player_id)
if player:
content_map[content.filename]['players'].append({
'id': player.id,
'name': player.name,
'group': player.group.name if player.group else None
})
# Convert to list for template
content_list = []
for filename, data in content_map.items():
content_list.append({
'filename': filename,
'content_type': data['content'].content_type,
'duration': data['content'].duration,
'file_size': data['content'].file_size_mb,
'uploaded_at': data['content'].uploaded_at,
'players': data['players'],
'player_count': len(data['players'])
})
# Sort by upload date
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
return render_template('content/content_list.html',
content_list=content_list)
except Exception as e:
log_action('error', f'Error loading content list: {str(e)}')
flash('Error loading content list.', 'danger')
return redirect(url_for('main.dashboard'))
@content_bp.route('/upload', methods=['GET', 'POST'])
@login_required
def upload_content():
"""Upload new content."""
if request.method == 'GET':
# Get parameters for return URL and pre-selection
player_id = request.args.get('player_id', type=int)
return_url = request.args.get('return_url', url_for('content.content_list'))
# Get all players for selection
from app.models import Player
players = Player.query.order_by(Player.name).all()
return render_template('content/upload_content.html',
players=players,
selected_player_id=player_id,
return_url=return_url)
try:
# Get form data
player_id = request.form.get('player_id', type=int)
media_type = request.form.get('media_type', 'image')
duration = request.form.get('duration', type=int, default=10)
session_id = request.form.get('session_id', os.urandom(8).hex())
return_url = request.form.get('return_url', url_for('content.content_list'))
# Get files
files = request.files.getlist('files')
if not files or files[0].filename == '':
flash('No files provided.', 'warning')
return redirect(url_for('content.upload_content'))
if not player_id:
flash('Please select a player.', 'warning')
return redirect(url_for('content.upload_content'))
# Initialize progress tracking using shared utility
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
# Process each file
upload_folder = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_folder, exist_ok=True)
processed_count = 0
total_files = len(files)
for idx, file in enumerate(files):
if file.filename == '':
continue
# Update progress
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
set_upload_progress(session_id, progress_pct,
f'Processing file {idx + 1} of {total_files}...', 'processing')
filename = secure_filename(file.filename)
filepath = os.path.join(upload_folder, filename)
# Save file
file.save(filepath)
# Determine content type
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
content_type = 'image'
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
content_type = 'video'
# Process video (convert to Raspberry Pi optimized format)
set_upload_progress(session_id, progress_pct + 5,
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
success, message = process_video_file(filepath, session_id)
if not success:
log_action('error', f'Video optimization failed: {message}')
continue # Skip this file and move to next
elif file_ext == 'pdf':
content_type = 'pdf'
# Process PDF (convert to images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PDF {idx + 1}...', 'processing')
# process_pdf_file(filepath, session_id)
elif file_ext in ['ppt', 'pptx']:
content_type = 'presentation'
# Process presentation (convert to PDF then images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PowerPoint {idx + 1}...', 'processing')
# This would call pptx_converter utility
else:
content_type = 'other'
# Create content record linked to player
from app.models import Player
player = Player.query.get(player_id)
if player:
new_content = Content(
filename=filename,
content_type=content_type,
duration=duration,
file_size=os.path.getsize(filepath),
player_id=player_id
)
db.session.add(new_content)
# Increment playlist version
player.playlist_version += 1
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
processed_count += 1
# Commit all changes
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
db.session.commit()
# Complete
set_upload_progress(session_id, 100,
f'Successfully uploaded {processed_count} file(s)!', 'complete')
# Clear all playlist caches
cache.clear()
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
return redirect(return_url)
except Exception as e:
db.session.rollback()
# Update progress to error state
if 'session_id' in locals():
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
log_action('error', f'Error uploading content: {str(e)}')
flash('Error uploading content. Please try again.', 'danger')
return redirect(url_for('content.upload_content'))
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_content(content_id: int):
"""Edit content metadata."""
content = Content.query.get_or_404(content_id)
if request.method == 'GET':
return render_template('content/edit_content.html', content=content)
try:
duration = request.form.get('duration', type=int)
description = request.form.get('description', '').strip()
# Update content
if duration is not None:
content.duration = duration
content.description = description or None
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
flash(f'Content "{content.filename}" updated successfully.', 'success')
return redirect(url_for('content.content_list'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating content: {str(e)}')
flash('Error updating content. Please try again.', 'danger')
return redirect(url_for('content.edit_content', content_id=content_id))
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
@login_required
def delete_content(content_id: int):
"""Delete content and associated file."""
try:
content = Content.query.get_or_404(content_id)
filename = content.filename
# Delete file from disk
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
# Delete from database
db.session.delete(content)
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
flash(f'Content "{filename}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting content: {str(e)}')
flash('Error deleting content. Please try again.', 'danger')
return redirect(url_for('content.content_list'))
@content_bp.route('/delete-by-filename', methods=['POST'])
@login_required
def delete_by_filename():
"""Delete all content entries with a specific filename."""
try:
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({'success': False, 'message': 'No filename provided'}), 400
# Find all content entries with this filename
contents = Content.query.filter_by(filename=filename).all()
if not contents:
return jsonify({'success': False, 'message': 'Content not found'}), 404
deleted_count = len(contents)
# Delete file from disk (only once)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
log_action('info', f'Deleted file from disk: {filename}')
# Delete all database entries
for content in contents:
db.session.delete(content)
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
return jsonify({
'success': True,
'message': f'Content deleted from {deleted_count} playlist(s)',
'deleted_count': deleted_count
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting content by filename: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/bulk/delete', methods=['POST'])
@login_required
def bulk_delete_content():
"""Delete multiple content items at once."""
try:
content_ids = request.json.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'error': 'No content selected'}), 400
# Delete content
deleted_count = 0
for content_id in content_ids:
content = Content.query.get(content_id)
if content:
# Delete file
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(filepath):
os.remove(filepath)
db.session.delete(content)
deleted_count += 1
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Bulk deleted {deleted_count} content items')
return jsonify({'success': True, 'deleted': deleted_count})
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk deleting content: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500
@content_bp.route('/upload-progress/<upload_id>')
@login_required
def upload_progress_status(upload_id: str):
"""Get upload progress for a specific upload."""
progress = get_upload_progress(upload_id)
return jsonify(progress)
@content_bp.route('/preview/<int:content_id>')
@login_required
def preview_content(content_id: int):
"""Preview content in browser."""
try:
content = Content.query.get_or_404(content_id)
# Serve file from uploads folder
return send_from_directory(
current_app.config['UPLOAD_FOLDER'],
content.filename,
as_attachment=False
)
except Exception as e:
log_action('error', f'Error previewing content: {str(e)}')
return "Error loading content", 500
@content_bp.route('/<int:content_id>/download')
@login_required
def download_content(content_id: int):
"""Download content file."""
try:
content = Content.query.get_or_404(content_id)
log_action('info', f'Content "{content.filename}" downloaded')
return send_from_directory(
current_app.config['UPLOAD_FOLDER'],
content.filename,
as_attachment=True
)
except Exception as e:
log_action('error', f'Error downloading content: {str(e)}')
return "Error downloading content", 500
@content_bp.route('/statistics')
@login_required
def content_statistics():
"""Get content statistics."""
try:
total_content = Content.query.count()
# Count by type
type_counts = {}
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
count = Content.query.filter_by(content_type=content_type).count()
type_counts[content_type] = count
# Calculate total storage
upload_folder = current_app.config['UPLOAD_FOLDER']
total_size = 0
if os.path.exists(upload_folder):
for dirpath, dirnames, filenames in os.walk(upload_folder):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
return jsonify({
'total': total_content,
'by_type': type_counts,
'total_size_mb': round(total_size / (1024 * 1024), 2)
})
except Exception as e:
log_action('error', f'Error getting content statistics: {str(e)}')
return jsonify({'error': str(e)}), 500
@content_bp.route('/check-duplicates')
@login_required
def check_duplicates():
"""Check for duplicate filenames."""
try:
# Get all filenames
all_content = Content.query.all()
filename_counts = {}
for content in all_content:
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
# Find duplicates
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
return jsonify({
'has_duplicates': len(duplicates) > 0,
'duplicates': duplicates
})
except Exception as e:
log_action('error', f'Error checking duplicates: {str(e)}')
return jsonify({'error': str(e)}), 500
@content_bp.route('/<int:content_id>/groups')
@login_required
def content_groups_info(content_id: int):
"""Get groups that contain this content."""
try:
content = Content.query.get_or_404(content_id)
groups_data = []
for group in content.groups:
groups_data.append({
'id': group.id,
'name': group.name,
'description': group.description,
'player_count': group.players.count()
})
return jsonify({
'content_id': content_id,
'filename': content.filename,
'groups': groups_data
})
except Exception as e:
log_action('error', f'Error getting content groups: {str(e)}')
return jsonify({'error': str(e)}), 500
-102
View File
@@ -585,14 +585,6 @@ def get_player_playlist(player_id: int) -> List[dict]:
return playlist return playlist
@players_bp.route('/<int:player_id>/reorder', methods=['POST'])
@login_required
def reorder_content(player_id: int):
"""Legacy endpoint - Content reordering now handled in playlist management."""
return jsonify({
'success': False,
'error': 'Content reordering is now managed through playlists. Use the Playlists page to reorder content.'
}), 400
@players_bp.route('/bulk/delete', methods=['POST']) @players_bp.route('/bulk/delete', methods=['POST'])
@@ -686,97 +678,3 @@ def deployment_status():
except Exception as e: except Exception as e:
log_action('error', f'Error fetching deployment status: {str(e)}') log_action('error', f'Error fetching deployment status: {str(e)}')
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@players_bp.route('/<int:player_id>/playlist/reorder', methods=['POST'])
@login_required
def reorder_playlist(player_id: int):
"""Reorder items in player's playlist."""
try:
data = request.get_json()
content_id = data.get('content_id')
direction = data.get('direction') # 'up' or 'down'
if not content_id or not direction:
return jsonify({'success': False, 'message': 'Missing parameters'}), 400
# Get the content item
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if not content:
return jsonify({'success': False, 'message': 'Content not found'}), 404
# Get all content for this player, ordered by position
all_content = Content.query.filter_by(player_id=player_id)\
.order_by(Content.position, Content.uploaded_at).all()
# Find current index
current_index = None
for idx, item in enumerate(all_content):
if item.id == content_id:
current_index = idx
break
if current_index is None:
return jsonify({'success': False, 'message': 'Content not in playlist'}), 404
# Swap positions
if direction == 'up' and current_index > 0:
# Swap with previous item
all_content[current_index].position, all_content[current_index - 1].position = \
all_content[current_index - 1].position, all_content[current_index].position
elif direction == 'down' and current_index < len(all_content) - 1:
# Swap with next item
all_content[current_index].position, all_content[current_index + 1].position = \
all_content[current_index + 1].position, all_content[current_index].position
db.session.commit()
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Reordered playlist for player {player_id}')
return jsonify({'success': True})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@players_bp.route('/<int:player_id>/playlist/remove', methods=['POST'])
@login_required
def remove_from_playlist(player_id: int):
"""Remove content from player's playlist."""
try:
data = request.get_json()
content_id = data.get('content_id')
if not content_id:
return jsonify({'success': False, 'message': 'Missing content_id'}), 400
# Get the content item
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if not content:
return jsonify({'success': False, 'message': 'Content not found'}), 404
filename = content.filename
# Delete from database
db.session.delete(content)
# Increment playlist version
player = Player.query.get(player_id)
if player:
player.playlist_version += 1
db.session.commit()
# Clear cache
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Removed "{filename}" from player {player_id} playlist (version {player.playlist_version})')
return jsonify({'success': True, 'message': f'Removed "{filename}" from playlist'})
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing from playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
-310
View File
@@ -1,310 +0,0 @@
"""Playlist blueprint for managing player playlists."""
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app)
from flask_login import login_required
from sqlalchemy import desc, update
import os
from app.extensions import db, cache
from app.models import Player, Content, Playlist
from app.models.playlist import playlist_content
from app.utils.logger import log_action
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
@playlist_bp.route('/<int:player_id>')
@login_required
def manage_playlist(player_id: int):
"""Legacy route - redirect to new content management area."""
player = Player.query.get_or_404(player_id)
if player.playlist_id:
# Redirect to the new content management interface
return redirect(url_for('content.manage_playlist_content', playlist_id=player.playlist_id))
else:
# Player has no playlist assigned
flash('This player has no playlist assigned.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
@login_required
def add_to_playlist(player_id: int):
"""Add content to player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
content_id = request.form.get('content_id', type=int)
duration = request.form.get('duration', type=int, default=10)
if not content_id:
flash('Please select content.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
# Get max position
from sqlalchemy import select, func
max_pos = db.session.execute(
select(func.max(playlist_content.c.position)).where(
playlist_content.c.playlist_id == playlist.id
)
).scalar() or 0
# Add to playlist_content association table
stmt = playlist_content.insert().values(
playlist_id=playlist.id,
content_id=content.id,
position=max_pos + 1,
duration=duration
)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"')
flash(f'Added "{content.filename}" to playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding to playlist: {str(e)}')
flash('Error adding to playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
@playlist_bp.route('/<int:player_id>/remove/<int:content_id>', methods=['POST'])
@login_required
def remove_from_playlist(player_id: int, content_id: int):
"""Remove content from player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
filename = content.filename
# Remove from playlist_content association table
from sqlalchemy import delete
stmt = delete(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
)
db.session.execute(stmt)
# Reorder remaining content
from sqlalchemy import select
remaining = db.session.execute(
select(playlist_content.c.content_id, playlist_content.c.position).where(
playlist_content.c.playlist_id == playlist.id
).order_by(playlist_content.c.position)
).fetchall()
for idx, row in enumerate(remaining, start=1):
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == row.content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Removed "{filename}" from playlist for player "{player.name}"')
flash(f'Removed "{filename}" from playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing from playlist: {str(e)}')
flash('Error removing from playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
@playlist_bp.route('/<int:player_id>/reorder', methods=['POST'])
@login_required
def reorder_playlist(player_id: int):
"""Reorder playlist items."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
# Get new order from JSON
data = request.get_json()
content_ids = data.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
# Update positions in association table
for idx, content_id in enumerate(content_ids, start=1):
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})')
return jsonify({
'success': True,
'message': 'Playlist reordered successfully',
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/update-duration/<int:content_id>', methods=['POST'])
@login_required
def update_duration(player_id: int, content_id: int):
"""Update content duration in playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
content = Content.query.get_or_404(content_id)
duration = request.form.get('duration', type=int)
if not duration or duration < 1:
return jsonify({'success': False, 'message': 'Invalid duration'}), 400
# Update duration in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(duration=duration)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated duration for "{content.filename}" in player "{player.name}" playlist')
return jsonify({
'success': True,
'message': 'Duration updated',
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating duration: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/update-muted/<int:content_id>', methods=['POST'])
@login_required
def update_muted(player_id: int, content_id: int):
"""Update content muted setting in playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
content = Content.query.get_or_404(content_id)
muted = request.form.get('muted', 'true').lower() == 'true'
# Update muted in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(muted=muted)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated muted={muted} for "{content.filename}" in player "{player.name}" playlist')
return jsonify({
'success': True,
'message': 'Audio setting updated',
'muted': muted,
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating muted setting: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
@login_required
def clear_playlist(player_id: int):
"""Clear all content from player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
playlist = Playlist.query.get(player.playlist_id)
# Delete all content from playlist
from sqlalchemy import delete
stmt = delete(playlist_content).where(
playlist_content.c.playlist_id == playlist.id
)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Cleared playlist for player "{player.name}"')
flash('Playlist cleared successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error clearing playlist: {str(e)}')
flash('Error clearing playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
-3
View File
@@ -1,7 +1,6 @@
"""Models package for digiserver-v2.""" """Models package for digiserver-v2."""
from app.models.user import User from app.models.user import User
from app.models.player import Player from app.models.player import Player
from app.models.group import Group, group_content
from app.models.playlist import Playlist, playlist_content from app.models.playlist import Playlist, playlist_content
from app.models.content import Content from app.models.content import Content
from app.models.server_log import ServerLog from app.models.server_log import ServerLog
@@ -13,7 +12,6 @@ from app.models.https_config import HTTPSConfig
__all__ = [ __all__ = [
'User', 'User',
'Player', 'Player',
'Group',
'Playlist', 'Playlist',
'Content', 'Content',
'ServerLog', 'ServerLog',
@@ -21,6 +19,5 @@ __all__ = [
'PlayerEdit', 'PlayerEdit',
'PlayerUser', 'PlayerUser',
'HTTPSConfig', 'HTTPSConfig',
'group_content',
'playlist_content', 'playlist_content',
] ]
-7
View File
@@ -38,8 +38,6 @@ class Content(db.Model):
# Relationships - many-to-many with playlists # Relationships - many-to-many with playlists
playlists = db.relationship('Playlist', secondary='playlist_content', playlists = db.relationship('Playlist', secondary='playlist_content',
back_populates='contents', lazy='dynamic') back_populates='contents', lazy='dynamic')
groups = db.relationship('Group', secondary='group_content',
back_populates='contents', lazy='dynamic')
def __repr__(self) -> str: def __repr__(self) -> str:
"""String representation of Content.""" """String representation of Content."""
@@ -52,11 +50,6 @@ class Content(db.Model):
return round(self.file_size / (1024 * 1024), 2) return round(self.file_size / (1024 * 1024), 2)
return 0.0 return 0.0
@property
def group_count(self) -> int:
"""Get number of groups containing this content."""
return self.groups.count()
@property @property
def original_display_name(self) -> str: def original_display_name(self) -> str:
"""Name of the original (unedited) file for display purposes.""" """Name of the original (unedited) file for display purposes."""
-66
View File
@@ -1,66 +0,0 @@
"""Group model for organizing players and content."""
from datetime import datetime
from typing import List, Optional
from app.extensions import db
# Association table for many-to-many relationship between groups and content
group_content = db.Table('group_content',
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
db.Column('content_id', db.Integer, db.ForeignKey('content.id'), primary_key=True)
)
class Group(db.Model):
"""Group model for organizing players with shared content.
Attributes:
id: Primary key
name: Unique group name
description: Optional group description
created_at: Group creation timestamp
updated_at: Last modification timestamp
"""
__tablename__ = 'group'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
# Relationships
contents = db.relationship('Content', secondary=group_content,
back_populates='groups', lazy='dynamic')
def __repr__(self) -> str:
"""String representation of Group."""
return f'<Group {self.name} (ID={self.id})>'
@property
def content_count(self) -> int:
"""Get number of content items in this group."""
return self.contents.count()
def add_player(self, player) -> None:
"""Add a player to this group.
Args:
player: Player instance to add
"""
player.group_id = self.id
self.updated_at = datetime.utcnow()
def remove_player(self, player) -> None:
"""Remove a player from this group.
Args:
player: Player instance to remove
"""
if player.group_id == self.id:
player.group_id = None
self.updated_at = datetime.utcnow()
+1 -1
View File
@@ -19,7 +19,7 @@ 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
playlist_version: Version number for playlist synchronization playlist_id: Assigned playlist (sync version comes from Playlist.version)
created_at: Player creation timestamp created_at: Player creation timestamp
""" """
__tablename__ = 'player' __tablename__ = 'player'
+82 -2
View File
@@ -42,6 +42,24 @@
{% endif %} {% endif %}
</div> </div>
<!-- Live build progress (updated by polling below) -->
<div class="card" id="build-progress" style="display: none;">
<h2>Build progress</h2>
<p id="build-progress-text" style="margin: 0;">
<span class="badge badge-warning" id="build-progress-badge">⏳ Running</span>
<span id="build-progress-step"></span>
</p>
<p id="build-progress-message" style="color: #6c757d; margin-top: 8px;"></p>
<div style="margin-top: 10px;">
<button type="button" class="btn btn-secondary" onclick="window.location.reload()">
🔄 Refresh page
</button>
</div>
<p style="color: #6c757d; font-size: 13px; margin-top: 10px;">
The clone takes a couple of minutes. You can leave this page and come back.
</p>
</div>
<form method="POST" action="{{ url_for('admin.build_player_action') }}"> <form method="POST" action="{{ url_for('admin.build_player_action') }}">
<!-- Repository --> <!-- Repository -->
<div class="card"> <div class="card">
@@ -114,10 +132,12 @@
<div class="card"> <div class="card">
<h2>3. Build</h2> <h2>3. Build</h2>
<div class="card-actions" style="display: flex; gap: 10px; flex-wrap: wrap;"> <div class="card-actions" style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="submit" name="action" value="build_and_config" class="btn btn-primary"> <button type="submit" name="action" value="build_and_config"
class="btn btn-primary" id="btn-build-all">
⬇️ Build files &amp; write config ⬇️ Build files &amp; write config
</button> </button>
<button type="submit" name="action" value="build_files" class="btn btn-secondary"> <button type="submit" name="action" value="build_files"
class="btn btn-secondary" id="btn-build-files">
Build files only Build files only
</button> </button>
<button type="submit" name="action" value="save_config" class="btn btn-secondary"> <button type="submit" name="action" value="save_config" class="btn btn-secondary">
@@ -127,4 +147,64 @@
</div> </div>
</form> </form>
</div> </div>
<script>
// Poll the build status so the admin sees live progress instead of a request
// that appears to hang (the clone takes ~1-2 minutes).
(function () {
const statusUrl = "{{ url_for('admin.build_player_status') }}";
const panel = document.getElementById('build-progress');
const badge = document.getElementById('build-progress-badge');
const step = document.getElementById('build-progress-step');
const message = document.getElementById('build-progress-message');
const bAll = document.getElementById('btn-build-all');
const bFiles = document.getElementById('btn-build-files');
let sawRunning = false;
function render(s) {
const state = s.state || 'idle';
if (state === 'running') {
sawRunning = true;
panel.style.display = 'block';
badge.className = 'badge badge-warning';
badge.textContent = '⏳ Running';
step.textContent = s.step || '';
message.textContent = '';
if (bAll) { bAll.disabled = true; bAll.textContent = '⏳ Building…'; }
if (bFiles) { bFiles.disabled = true; }
return;
}
// Reached a terminal state.
if (state === 'success' || state === 'error') {
panel.style.display = 'block';
if (state === 'success') {
badge.className = 'badge badge-success';
badge.textContent = '✅ Build complete';
} else {
badge.className = 'badge badge-danger';
badge.textContent = '❌ Build failed';
}
step.textContent = '';
message.textContent = s.message || '';
if (sawRunning) {
// Reload once so the staged-version panel reflects the new build.
setTimeout(function () { window.location.reload(); }, 1500);
}
}
}
function poll() {
fetch(statusUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (s) { if (s) render(s); })
.catch(function () { /* transient — keep polling */ });
}
poll();
setInterval(poll, 3000);
})();
</script>
{% endblock %} {% endblock %}
-205
View File
@@ -1,205 +0,0 @@
{% extends "base.html" %}
{% block title %}Content Library - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Content Library</h1>
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">+ Upload Content</a>
</div>
{% if content_list %}
<div class="card">
<div style="margin-bottom: 15px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
<strong>Total Files:</strong> {{ content_list|length }} |
<strong>Total Assignments:</strong> {% set total = namespace(count=0) %}{% for item in content_list %}{% set total.count = total.count + item.player_count %}{% endfor %}{{ total.count }}
</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">File Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Type</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned To</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Uploaded</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for item in content_list %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;">
<strong>{{ item.filename }}</strong>
</td>
<td style="padding: 12px;">
{% if item.content_type == 'image' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
{% elif item.content_type == 'video' %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
{% elif item.content_type == 'pdf' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
{% elif item.content_type == 'presentation' %}
<span style="background: #ffc107; color: black; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📊 PPT</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
{% endif %}
</td>
<td style="padding: 12px;">
{{ item.duration }}s
</td>
<td style="padding: 12px;">
{{ item.file_size }} MB
</td>
<td style="padding: 12px;">
{% if item.player_count == 0 %}
<span style="color: #6c757d; font-style: italic;">Not assigned</span>
{% else %}
<div style="max-height: 100px; overflow-y: auto;">
{% for player in item.players %}
<div style="margin-bottom: 5px;">
<strong>{{ player.name }}</strong>
{% if player.group %}
<span style="color: #6c757d; font-size: 12px;">({{ player.group }})</span>
{% endif %}
</div>
{% endfor %}
</div>
<div style="margin-top: 5px;">
<span style="background: #007bff; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
{{ item.player_count }} player{% if item.player_count != 1 %}s{% endif %}
</span>
</div>
{% endif %}
</td>
<td style="padding: 12px;">
<small style="color: #6c757d;">{{ item.uploaded_at | localtime }}</small>
</td>
<td style="padding: 12px;">
{% if item.player_count > 0 %}
{% set first_player = item.players[0] %}
<a href="{{ url_for('players.player_page', player_id=first_player.id) }}"
class="btn btn-primary btn-sm"
title="Manage Playlist for {{ first_player.name }}"
style="margin-bottom: 5px;">
📝 Manage Playlist
</a>
{% if item.player_count > 1 %}
<button onclick="showAllPlayers('{{ item.filename|replace("'", "\\'") }}', {{ item.players|tojson }})"
class="btn btn-info btn-sm"
title="View all players with this content">
👥 View All ({{ item.player_count }})
</button>
{% endif %}
{% endif %}
<button onclick="deleteContent('{{ item.filename|replace("'", "\\'") }}')"
class="btn btn-danger btn-sm"
title="Delete this content from all playlists"
style="margin-top: 5px;">
🗑️ Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
️ No content uploaded yet. <a href="{{ url_for('content.upload_content') }}" style="color: #0c5460; text-decoration: underline;">Upload your first content</a>
</div>
{% endif %}
</div>
<!-- Modal for viewing all players -->
<div id="playersModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 600px; margin: 100px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 id="modalTitle" style="margin-bottom: 20px; color: #2c3e50;">Players with this content</h2>
<div id="playersList" style="max-height: 400px; overflow-y: auto;"></div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closePlayersModal()">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
function showAllPlayers(filename, players) {
document.getElementById('modalTitle').textContent = 'Players with: ' + filename;
const playersList = document.getElementById('playersList');
playersList.innerHTML = '<table style="width: 100%; border-collapse: collapse;">';
playersList.innerHTML += '<thead><tr style="background: #f8f9fa;"><th style="padding: 10px; text-align: left;">Player Name</th><th style="padding: 10px; text-align: left;">Group</th><th style="padding: 10px; text-align: left;">Action</th></tr></thead><tbody>';
players.forEach(player => {
playersList.innerHTML += `
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;"><strong>${player.name}</strong></td>
<td style="padding: 10px;">${player.group || '-'}</td>
<td style="padding: 10px;">
<a href="/players/${player.id}" class="btn btn-sm" style="background: #007bff; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">
Manage Playlist
</a>
</td>
</tr>
`;
});
playersList.innerHTML += '</tbody></table>';
document.getElementById('playersModal').style.display = 'block';
}
function closePlayersModal() {
document.getElementById('playersModal').style.display = 'none';
}
function deleteContent(filename) {
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis will remove it from ALL player playlists!`)) {
fetch('/content/delete-by-filename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: filename
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Successfully deleted "${filename}" from ${data.deleted_count} playlist(s)`);
location.reload();
} else {
alert('Error deleting content: ' + data.message);
}
})
.catch(error => {
alert('Error deleting content: ' + error);
});
}
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('playersModal');
if (event.target == modal) {
closePlayersModal();
}
}
</script>
{% endblock %}
-11
View File
@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block title %}Edit Content{% endblock %}
{% block content %}
<div class="container">
<h2>Edit Content</h2>
<p>Edit content functionality - placeholder</p>
<a href="{{ url_for('content.list') }}" class="btn btn-secondary">Back to Content</a>
</div>
{% endblock %}
-278
View File
@@ -1,278 +0,0 @@
{% extends "base.html" %}
{% block title %}Upload Content - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1200px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Upload Content</h1>
</div>
<form id="upload-form" method="POST" enctype="multipart/form-data" onsubmit="handleFormSubmit(event)">
<input type="hidden" name="return_url" value="{{ return_url or url_for('content.content_list') }}">
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Select Player</h3>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Player:</label>
<select name="player_id" id="player_id" class="form-control" required>
<option value="" disabled {% if not selected_player_id %}selected{% endif %}>Select a Player</option>
{% for player in players %}
<option value="{{ player.id }}" {% if selected_player_id == player.id %}selected{% endif %}>
{{ player.name }} - {{ player.location or 'No location' }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Media Details</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Media Type:</label>
<select name="media_type" id="media_type" class="form-control" required onchange="handleMediaTypeChange()">
<option value="image">Image (JPG, PNG, GIF)</option>
<option value="video">Video (MP4, AVI, MOV)</option>
<option value="pdf">PDF Document</option>
<option value="ppt">PowerPoint (PPT/PPTX)</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 5px;" id="media-type-hint">
Images will be displayed as-is
</small>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Duration (seconds):</label>
<input type="number" name="duration" id="duration" class="form-control" required min="1" value="10">
<small style="color: #6c757d; display: block; margin-top: 5px;">
How long to display each image/slide (videos use actual length)
</small>
</div>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Files:</label>
<input type="file" name="files" id="files" class="form-control" multiple required
accept="image/*,video/*,.pdf,.ppt,.pptx" onchange="handleFileChange()">
<small style="color: #6c757d; display: block; margin-top: 5px;">
Select multiple files. Supported: JPG, PNG, GIF, MP4, PDF, PPT, PPTX
</small>
<div id="file-list" style="margin-top: 10px;"></div>
</div>
</div>
<div style="text-align: center;">
<button type="submit" id="submit-button" class="btn btn-success" style="padding: 10px 30px; font-size: 16px;">
📤 Upload Files
</button>
<a href="{{ return_url or url_for('content.content_list') }}" class="btn" style="padding: 10px 30px; font-size: 16px;">
← Back
</a>
</div>
</form>
</div>
<!-- Modal for Status Updates -->
<div id="statusModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 800px; margin: 50px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 style="margin-bottom: 20px; color: #2c3e50;">Processing Files</h2>
<div style="margin-bottom: 20px;">
<p id="status-message" style="font-size: 16px; color: #555;">Uploading and processing your files. Please wait...</p>
</div>
<!-- Progress Bar -->
<div style="margin-bottom: 30px;">
<label style="display: block; margin-bottom: 10px; font-weight: bold;">File Processing Progress</label>
<div style="width: 100%; height: 30px; background: #e9ecef; border-radius: 5px; overflow: hidden;">
<div id="progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #007bff, #0056b3); transition: width 0.3s ease; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 14px;">
0%
</div>
</div>
</div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closeModal()" disabled id="close-modal-btn">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
let progressInterval = null;
let sessionId = null;
let returnUrl = '{{ return_url or url_for("content.content_list") }}';
function generateSessionId() {
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function handleFormSubmit(event) {
event.preventDefault();
sessionId = generateSessionId();
const form = document.getElementById('upload-form');
let sessionInput = document.getElementById('session_id_input');
if (!sessionInput) {
sessionInput = document.createElement('input');
sessionInput.type = 'hidden';
sessionInput.name = 'session_id';
sessionInput.id = 'session_id_input';
form.appendChild(sessionInput);
}
sessionInput.value = sessionId;
showStatusModal();
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Upload failed');
}
console.log('Form submitted successfully');
})
.catch(error => {
console.error('Form submission error:', error);
document.getElementById('status-message').textContent = 'Upload failed: ' + error.message;
document.getElementById('progress-bar').style.background = '#dc3545';
document.getElementById('close-modal-btn').disabled = false;
});
}
function showStatusModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'block';
const mediaType = document.getElementById('media_type').value;
const statusMessage = document.getElementById('status-message');
switch(mediaType) {
case 'image':
statusMessage.textContent = 'Uploading images...';
break;
case 'video':
statusMessage.textContent = 'Uploading and converting video. This may take several minutes...';
break;
case 'pdf':
statusMessage.textContent = 'Uploading and converting PDF to images...';
break;
case 'ppt':
statusMessage.textContent = 'Uploading and converting PowerPoint to images...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
pollUploadProgress();
}
function closeModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'none';
if (progressInterval) {
clearInterval(progressInterval);
}
window.location.href = returnUrl;
}
function pollUploadProgress() {
progressInterval = setInterval(() => {
fetch(`/api/upload-progress/${sessionId}`)
.then(response => response.json())
.then(data => {
const progressBar = document.getElementById('progress-bar');
progressBar.style.width = `${data.progress}%`;
progressBar.textContent = `${data.progress}%`;
document.getElementById('status-message').textContent = data.message;
if (data.status === 'complete' || data.status === 'error') {
clearInterval(progressInterval);
progressInterval = null;
const closeBtn = document.getElementById('close-modal-btn');
closeBtn.disabled = false;
if (data.status === 'complete') {
progressBar.style.background = '#28a745';
setTimeout(() => closeModal(), 2000);
} else if (data.status === 'error') {
progressBar.style.background = '#dc3545';
}
}
})
.catch(error => console.error('Error fetching progress:', error));
}, 500);
}
function handleMediaTypeChange() {
const mediaType = document.getElementById('media_type').value;
const hint = document.getElementById('media-type-hint');
switch(mediaType) {
case 'image':
hint.textContent = 'Images will be displayed as-is';
break;
case 'video':
hint.textContent = 'Videos will be converted to optimized format';
break;
case 'pdf':
hint.textContent = 'PDF will be converted to images (one per page)';
break;
case 'ppt':
hint.textContent = 'PowerPoint will be converted to images (one per slide)';
break;
}
}
function handleFileChange() {
const filesInput = document.getElementById('files');
const fileList = document.getElementById('file-list');
const mediaType = document.getElementById('media_type').value;
const durationInput = document.getElementById('duration');
fileList.innerHTML = '';
if (filesInput.files.length > 0) {
fileList.innerHTML = '<strong>Selected files:</strong><ul style="margin: 5px 0; padding-left: 20px;">';
for (let i = 0; i < filesInput.files.length; i++) {
const file = filesInput.files[i];
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
fileList.innerHTML += `<li>${file.name} (${sizeMB} MB)</li>`;
}
fileList.innerHTML += '</ul>';
}
if (mediaType === 'video' && filesInput.files.length > 0) {
const file = filesInput.files[0];
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = function() {
window.URL.revokeObjectURL(video.src);
const duration = Math.round(video.duration);
durationInput.value = duration;
};
video.src = URL.createObjectURL(file);
}
}
</script>
{% endblock %}
-227
View File
@@ -1,227 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ player.name }} - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1400px;">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div>
<h1>{{ player.name }}</h1>
<div style="margin-top: 10px;">
{% if status_info.online %}
<span style="background: #28a745; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
🟢 Online
</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
⚫ Offline
</span>
{% endif %}
<span style="color: #6c757d; font-size: 14px;">
Last seen: {{ status_info.last_seen_ago }}
</span>
</div>
</div>
<div>
<a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary">
✏️ Edit Player
</a>
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}" class="btn btn-success">
🎬 Manage Playlist
</a>
<a href="{{ url_for('players.list') }}" class="btn">
← Back to Players
</a>
</div>
</div>
<!-- Main Content Grid -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<!-- Player Information Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
📋 Player Information
</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold; width: 40%;">Display Name:</td>
<td style="padding: 10px;">{{ player.name }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Hostname:</td>
<td style="padding: 10px;">
<code style="background: #f8f9fa; padding: 3px 8px; border-radius: 3px;">{{ player.hostname }}</code>
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Location:</td>
<td style="padding: 10px;">{{ player.location or '-' }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Orientation:</td>
<td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td>
</tr>
<tr>
<td style="padding: 10px; font-weight: bold;">Created:</td>
<td style="padding: 10px;">{{ player.created_at | localtime }}</td>
</tr>
</table>
</div>
<!-- Authentication Details Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
🔐 Authentication Details
</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold; width: 40%;">Password Set:</td>
<td style="padding: 10px;">
{% if player.password_hash %}
<span style="color: #28a745;">✓ Yes</span>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Quick Connect Code:</td>
<td style="padding: 10px;">
{% if player.quickconnect_code %}
<span style="color: #28a745;">✓ Yes</span>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Auth Code:</td>
<td style="padding: 10px;">
{% if player.auth_code %}
<span style="color: #28a745;">✓ Yes</span>
<form method="POST" action="{{ url_for('players.regenerate_auth_code', player_id=player.id) }}" style="display: inline; margin-left: 10px;">
<button type="submit" class="btn btn-sm" style="background: #ffc107; padding: 3px 8px;"
onclick="return confirm('Regenerate auth code? The player will need to authenticate again.')">
🔄 Regenerate
</button>
</form>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 10px;">
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
class="btn btn-primary" style="width: 100%; text-align: center;">
✏️ Edit Authentication Settings
</a>
</td>
</tr>
</table>
</div>
</div>
<!-- Playlist Management Card -->
<div class="card" style="margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">🎬 Playlist Management</h3>
</div>
{% if playlist %}
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 15px;">
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;">
<div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Items</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ playlist|length }}</div>
</div>
<div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Duration</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">
{% set total_duration = namespace(value=0) %}
{% for item in playlist %}
{% set total_duration.value = total_duration.value + (item.duration or 10) %}
{% endfor %}
{{ total_duration.value }}s
</div>
</div>
<div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Playlist Version</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ player.playlist_version }}</div>
</div>
</div>
</div>
{% endif %}
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}"
class="btn btn-primary"
style="display: inline-block; width: 100%; text-align: center; padding: 15px; font-size: 16px;">
🎬 Open Playlist Manager
</a>
{% if not playlist %}
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center; margin-top: 15px;">
⚠️ No content in playlist. Open the playlist manager to add content.
</div>
{% endif %}
</div>
<!-- Player Activity Log Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
📊 Recent Activity & Feedback
</h3>
{% if recent_feedback %}
<div style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: white;">
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Time</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Message</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Error</th>
</tr>
</thead>
<tbody>
{% for feedback in recent_feedback %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; white-space: nowrap;">
<small style="color: #6c757d;">{{ feedback.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
</td>
<td style="padding: 10px;">
{% if feedback.status == 'playing' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">▶️ Playing</span>
{% elif feedback.status == 'idle' %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">⏸️ Idle</span>
{% elif feedback.status == 'error' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">❌ Error</span>
{% else %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ feedback.status }}</span>
{% endif %}
</td>
<td style="padding: 10px;">
{{ feedback.message or '-' }}
</td>
<td style="padding: 10px;">
{% if feedback.error %}
<span style="color: #dc3545; font-family: monospace; font-size: 12px;">{{ feedback.error[:50] }}...</span>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px; text-align: center;">
️ No activity logs yet. The player will send feedback once it starts playing content.
</div>
{% endif %}
</div>
</div>
{% endblock %}
+2 -14
View File
@@ -10,14 +10,7 @@ from app.utils.uploads import (
get_file_size, get_file_size,
delete_file delete_file
) )
from app.utils.group_player_management import ( from app.utils.group_player_management import get_player_status_info
get_player_status_info,
get_group_statistics,
assign_player_to_group,
bulk_assign_players_to_group,
get_online_players_count,
get_players_by_status
)
from app.utils.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file from app.utils.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file
__all__ = [ __all__ = [
@@ -36,13 +29,8 @@ __all__ = [
'clear_upload_progress', 'clear_upload_progress',
'get_file_size', 'get_file_size',
'delete_file', 'delete_file',
# Group/Player Management # Player Management
'get_player_status_info', 'get_player_status_info',
'get_group_statistics',
'assign_player_to_group',
'bulk_assign_players_to_group',
'get_online_players_count',
'get_players_by_status',
# PPTX Converter # PPTX Converter
'pptx_to_pdf_libreoffice', 'pptx_to_pdf_libreoffice',
'validate_pptx_file', 'validate_pptx_file',
+93 -170
View File
@@ -37,43 +37,110 @@ class CaddyConfigGenerator:
"""Generate Caddyfile configuration based on HTTPSConfig.""" """Generate Caddyfile configuration based on HTTPSConfig."""
@staticmethod @staticmethod
def generate_caddyfile(config: Optional['HTTPSConfig'] = None) -> str: def generate_caddyfile(config: Optional['HTTPSConfig'] = None,
http_fallback: bool = True,
http_port: int = 80,
https_port: int = 443) -> str:
"""Generate a complete Caddyfile. """Generate a complete Caddyfile.
Behaviour: Design goals
- HTTPS disabled / no domain HTTP-only on port 80 (initial deploy mode). ------------
- HTTPS enabled + real domain Caddy auto-provisions a Let's Encrypt cert * **One HTTP endpoint** (port 80) that always answers, whatever the Host
for that domain; HTTP redirects to HTTPS automatically. header is so ``http://<ip>`` and ``http://<hostname>`` both work.
- HTTPS enabled + IP only (no domain) TLS with Caddy's internal CA * **HTTPS on port 443** for the same names when it is enabled.
(self-signed, trusted within the Docker network). * If HTTPS is disabled or never configured, port 80 simply serves the
app there is no separate "HTTP mode" to configure.
Behaviour by configuration
--------------------------
* HTTPS off, or no address configured plain HTTP on ``:http_port``.
* HTTPS on the app is served on port 80 for every configured name and
on port 443 over TLS. Whether port 80 *serves* or *redirects* to
HTTPS is controlled by ``http_fallback``.
Which certificate each name gets
--------------------------------
* ``domain`` (when set) Caddy obtains a certificate automatically
(Let's Encrypt/ACME). Only valid for a **publicly resolvable** name.
* ``ip_address`` / ``hostname`` ``tls internal`` (Caddy's local CA).
This needs no public DNS and no ACME, which is the right choice for an
intranet name such as ``digiserver.sibiusb.harting.intra``.
Args:
config: HTTPSConfig instance, or None to load from the database.
http_fallback: When True, port 80 keeps *serving* the app alongside
HTTPS. This is the resilient default: clients that cannot trust
the internal CA (e.g. a Kivy player with ``verify_ssl: true``)
are still able to connect. When False, port 80 issues a 301
redirect to HTTPS instead.
http_port: Port Caddy listens on for plain HTTP (default 80).
https_port: Port used to build redirect targets when
``http_fallback`` is False (default 443).
Returns:
The complete Caddyfile as a string.
""" """
if config is None: if config is None:
config = HTTPSConfig.get_config() config = HTTPSConfig.get_config()
email = (config.email or "admin@localhost") if config else "admin@localhost" email = (config.email or "admin@localhost") if config else "admin@localhost"
https_enabled = config.https_enabled if config else False https_enabled = bool(config.https_enabled) if config else False
domain = (config.domain or "").strip() if config else "" domain = (config.domain or "").strip() if config else ""
ip_address = (config.ip_address or "").strip() if config else "" ip_address = (config.ip_address or "").strip() if config else ""
hostname = (config.hostname or "").strip() if config else ""
global_block = f"""{{\n admin 0.0.0.0:2019\n email {email}\n}}\n\n""" # Every name the server should answer to, in priority order, without
# duplicates. The IP comes first because it always resolves.
names: list[str] = []
for candidate in (ip_address, hostname, domain):
if candidate and candidate not in names:
names.append(candidate)
if https_enabled and domain: global_block = f"{{\n admin 0.0.0.0:2019\n email {email}\n"
# Caddy handles Let's Encrypt + HTTP→HTTPS redirect automatically
# when a plain hostname (no scheme) is used. # ── TLS with no SNI ────────────────────────────────────────────────
caddyfile = global_block # Browsers do NOT send SNI when the URL is an IP address (an IP is not
caddyfile += f"{domain} {{\n{_PROXY_SNIPPET}}}\n" # a valid SNI hostname). Without a fallback Caddy would identify such a
# Also accept requests on the raw IP (HTTP only, no cert needed) # connection by the container's own internal IP, match no certificate
if ip_address: # and abort the handshake with:
caddyfile += f"\nhttp://{ip_address} {{\n{_PROXY_SNIPPET}}}\n" # "no certificate available for '<container-ip>'"
elif https_enabled and ip_address: # `default_sni` makes a SNI-less ClientHello resolve to a name we do
# No public domain — use Caddy's internal CA (self-signed) # serve, so https://<ip> works in the browser.
caddyfile = global_block if https_enabled and not domain and ip_address:
caddyfile += f"https://{ip_address} {{\n tls internal\n{_PROXY_SNIPPET}}}\n" global_block += f" default_sni {ip_address}\n"
caddyfile += f"\nhttp://{ip_address} {{\n redir https://{ip_address}{{uri}} 301\n}}\n"
else: global_block += "}\n\n"
# HTTP-only fallback (first deploy, before HTTPS is configured)
caddyfile = "{\n admin 0.0.0.0:2019\n}\n\n" # ── Plain HTTP only: HTTPS disabled, or no address to certify ───────
caddyfile += f":80 {{\n{_PROXY_SNIPPET}}}\n" if not (https_enabled and names):
return global_block + f":{http_port} {{\n{_PROXY_SNIPPET}}}\n"
caddyfile = global_block
# ── Port 80: catch-all so ANY Host header is answered ──────────────
# Without this, a request for an unexpected name (e.g. a bare IP when
# only a hostname is configured) would hit no site block and fail.
caddyfile += f":{http_port} {{\n{_PROXY_SNIPPET}}}\n\n"
# ── Port 80: explicit per-name blocks ──────────────────────────────
for name in names:
if http_fallback:
caddyfile += f"http://{name} {{\n{_PROXY_SNIPPET}}}\n\n"
else:
# Redirect to the port the host actually publishes.
https_url = (f"https://{name}" if https_port == 443
else f"https://{name}:{https_port}")
caddyfile += f"http://{name} {{\n redir {https_url}{{uri}} 301\n}}\n\n"
# ── Port 443: TLS listeners ────────────────────────────────────────
for name in names:
if domain and name == domain:
# Public name → let Caddy obtain a real certificate.
caddyfile += f"https://{name} {{\n{_PROXY_SNIPPET}}}\n\n"
else:
# IP or intranet name → Caddy's internal CA.
caddyfile += (f"https://{name} {{\n tls internal\n"
f"{_PROXY_SNIPPET}}}\n\n")
return caddyfile return caddyfile
@@ -121,148 +188,4 @@ class CaddyConfigGenerator:
return response.status == 200 return response.status == 200
except Exception as e: except Exception as e:
print(f"Caddy reload error: {str(e)}") print(f"Caddy reload error: {str(e)}")
return False
"""Generate complete Caddyfile content.
Args:
config: HTTPSConfig instance or None
Returns:
Complete Caddyfile content as string
"""
# Get config from database if not provided
if config is None:
config = HTTPSConfig.get_config()
# Base configuration
email = "admin@localhost"
if config and config.email:
email = config.email
base_config = f"""{{
# Global options
email {email}
# Admin API for configuration management (listen on all interfaces)
admin 0.0.0.0:2019
# Uncomment for testing to avoid rate limits
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}}
# Shared reverse proxy configuration
(reverse_proxy_config) {{
reverse_proxy digiserver-app:5000 {{
header_up Host {{host}}
header_up X-Real-IP {{remote_host}}
header_up X-Forwarded-Proto {{scheme}}
# Timeouts for large uploads
transport http {{
read_timeout 300s
write_timeout 300s
}}
}}
# File upload size limit (2GB)
request_body {{
max_size 2GB
}}
# Security headers
header {{
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
}}
# Logging
log {{
output file /var/log/caddy/access.log
}}
}}
# Localhost (development/local access)
http://localhost {{
import reverse_proxy_config
}}
"""
# Add main domain/IP configuration if HTTPS is enabled
if config and config.https_enabled and config.domain and config.ip_address:
# Internal domain configuration
domain_config = f"""
# Internal domain (HTTP only - internal use)
http://{config.domain} {{
import reverse_proxy_config
}}
# Handle IP address access
http://{config.ip_address} {{
import reverse_proxy_config
}}
"""
base_config += domain_config
else:
# Default fallback configuration
base_config += """
# Internal domain (HTTP only - internal use)
http://digiserver.sibiusb.harting.intra {
import reverse_proxy_config
}
# Handle IP address access
http://10.76.152.164 {
import reverse_proxy_config
}
"""
# Add catch-all for any other HTTP requests
base_config += """
# Catch-all for any other HTTP requests
http://* {
import reverse_proxy_config
}
"""
return base_config
@staticmethod
def write_caddyfile(caddyfile_content: str, path: str = '/app/Caddyfile') -> bool:
"""Write Caddyfile to disk.
Args:
caddyfile_content: Content to write
path: Path to Caddyfile
Returns:
True if successful, False otherwise
"""
try:
with open(path, 'w') as f:
f.write(caddyfile_content)
return True
except Exception as e:
print(f"Error writing Caddyfile: {str(e)}")
return False
@staticmethod
def reload_caddy() -> bool:
"""Reload Caddy configuration without restart.
Note: Caddy monitoring is handled via file watching. After writing the Caddyfile,
Caddy should automatically reload. If it doesn't, you may need to restart the
Caddy container manually.
Returns:
True if configuration was written successfully (Caddy will auto-reload)
"""
try:
# Just verify that Caddy is reachable
import urllib.request
response = urllib.request.urlopen('http://caddy:2019/config/', timeout=2)
return response.status == 200
except Exception as e:
# Caddy might not be reachable, but Caddyfile was already written
# Caddy should reload automatically when it detects file changes
print(f"Note: Caddy reload check returned: {str(e)}")
return True # Return True anyway since Caddyfile was written
+9 -142
View File
@@ -1,10 +1,13 @@
"""Group and player management utilities.""" """Player status utilities.
from typing import Dict, List, Optional
from datetime import datetime, timedelta
from app.extensions import db Note: the group-management helpers that used to live here were removed along
from app.models import Player, Group, PlayerFeedback with the deprecated Group subsystem (the ``group`` table had no rows and the
from app.utils.logger import log_action ``/api/groups`` endpoint had already been archived).
"""
from typing import Dict
from datetime import datetime
from app.models import Player, PlayerFeedback
def get_player_status_info(player_id: int) -> Dict: def get_player_status_info(player_id: int) -> Dict:
@@ -51,142 +54,6 @@ def get_player_status_info(player_id: int) -> Dict:
} }
def get_group_statistics(group_id: int) -> Dict:
"""Get statistics for a group.
Args:
group_id: Group ID to query
Returns:
Dictionary with group statistics
"""
group = Group.query.get(group_id)
if not group:
return {
'total_players': 0,
'online_players': 0,
'total_content': 0,
'error_count': 0
}
total_players = group.player_count
total_content = group.content_count
# Count online players
online_players = 0
error_count = 0
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
for player in group.players:
if player.last_seen and player.last_seen >= five_min_ago:
online_players += 1
if player.status == 'error':
error_count += 1
return {
'group_id': group_id,
'group_name': group.name,
'total_players': total_players,
'online_players': online_players,
'offline_players': total_players - online_players,
'total_content': total_content,
'error_count': error_count
}
def assign_player_to_group(player_id: int, group_id: Optional[int]) -> bool:
"""Assign a player to a group or unassign if group_id is None.
Args:
player_id: Player ID to assign
group_id: Group ID to assign to, or None to unassign
Returns:
True if successful, False otherwise
"""
try:
player = Player.query.get(player_id)
if not player:
log_action('error', f'Player {player_id} not found')
return False
old_group_id = player.group_id
player.group_id = group_id
db.session.commit()
if group_id:
group = Group.query.get(group_id)
log_action('info', f'Player "{player.name}" assigned to group "{group.name}"')
else:
log_action('info', f'Player "{player.name}" unassigned from group')
return True
except Exception as e:
db.session.rollback()
log_action('error', f'Error assigning player to group: {str(e)}')
return False
def bulk_assign_players_to_group(player_ids: List[int], group_id: Optional[int]) -> int:
"""Assign multiple players to a group.
Args:
player_ids: List of player IDs to assign
group_id: Group ID to assign to, or None to unassign
Returns:
Number of players successfully assigned
"""
count = 0
try:
for player_id in player_ids:
player = Player.query.get(player_id)
if player:
player.group_id = group_id
count += 1
db.session.commit()
if group_id:
group = Group.query.get(group_id)
log_action('info', f'Bulk assigned {count} players to group "{group.name}"')
else:
log_action('info', f'Bulk unassigned {count} players from groups')
return count
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk assigning players: {str(e)}')
return 0
def get_online_players_count() -> int:
"""Get count of online players (seen in last 5 minutes).
Returns:
Number of online players
"""
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
return Player.query.filter(Player.last_seen >= five_min_ago).count()
def get_players_by_status(status: str) -> List[Player]:
"""Get all players with a specific status.
Args:
status: Status to filter by
Returns:
List of Player instances
"""
return Player.query.filter_by(status=status).all()
def _format_time_ago(dt: datetime) -> str: def _format_time_ago(dt: datetime) -> str:
"""Format datetime as 'time ago' string. """Format datetime as 'time ago' string.
-120
View File
@@ -1,120 +0,0 @@
"""Nginx configuration reader utility."""
import os
import re
from typing import Dict, List, Optional, Any
class NginxConfigReader:
"""Read and parse Nginx configuration files."""
def __init__(self, config_path: str = '/etc/nginx/nginx.conf'):
"""Initialize Nginx config reader."""
self.config_path = config_path
self.config_content = None
self.is_available = os.path.exists(config_path)
if self.is_available:
try:
with open(config_path, 'r') as f:
self.config_content = f.read()
except Exception as e:
self.is_available = False
self.error = str(e)
def get_status(self) -> Dict[str, Any]:
"""Get Nginx configuration status."""
if not self.is_available:
return {
'available': False,
'error': 'Nginx configuration not found',
'path': self.config_path
}
return {
'available': True,
'path': self.config_path,
'file_exists': os.path.exists(self.config_path),
'ssl_enabled': self._check_ssl_enabled(),
'http_ports': self._extract_http_ports(),
'https_ports': self._extract_https_ports(),
'upstream_servers': self._extract_upstream_servers(),
'server_names': self._extract_server_names(),
'ssl_protocols': self._extract_ssl_protocols(),
'client_max_body_size': self._extract_client_max_body_size(),
'gzip_enabled': self._check_gzip_enabled(),
}
def _check_ssl_enabled(self) -> bool:
"""Check if SSL is enabled."""
if not self.config_content:
return False
return 'ssl_certificate' in self.config_content
def _extract_http_ports(self) -> List[int]:
"""Extract HTTP listening ports."""
if not self.config_content:
return []
pattern = r'listen\s+(\d+)'
matches = re.findall(pattern, self.config_content)
return sorted(list(set(int(p) for p in matches if int(p) < 1000)))
def _extract_https_ports(self) -> List[int]:
"""Extract HTTPS listening ports."""
if not self.config_content:
return []
pattern = r'listen\s+(\d+).*ssl'
matches = re.findall(pattern, self.config_content)
return sorted(list(set(int(p) for p in matches)))
def _extract_upstream_servers(self) -> List[str]:
"""Extract upstream servers."""
if not self.config_content:
return []
upstream_match = re.search(r'upstream\s+\w+\s*{([^}]+)}', self.config_content)
if upstream_match:
upstream_content = upstream_match.group(1)
servers = re.findall(r'server\s+([^\s;]+)', upstream_content)
return servers
return []
def _extract_server_names(self) -> List[str]:
"""Extract server names."""
if not self.config_content:
return []
pattern = r'server_name\s+([^;]+);'
matches = re.findall(pattern, self.config_content)
result = []
for match in matches:
names = match.strip().split()
result.extend(names)
return result
def _extract_ssl_protocols(self) -> List[str]:
"""Extract SSL protocols."""
if not self.config_content:
return []
pattern = r'ssl_protocols\s+([^;]+);'
match = re.search(pattern, self.config_content)
if match:
return match.group(1).strip().split()
return []
def _extract_client_max_body_size(self) -> Optional[str]:
"""Extract client max body size."""
if not self.config_content:
return None
pattern = r'client_max_body_size\s+([^;]+);'
match = re.search(pattern, self.config_content)
return match.group(1).strip() if match else None
def _check_gzip_enabled(self) -> bool:
"""Check if gzip is enabled."""
if not self.config_content:
return False
return bool(re.search(r'gzip\s+on\s*;', self.config_content))
def get_nginx_status() -> Dict[str, Any]:
"""Get Nginx configuration status."""
reader = NginxConfigReader()
return reader.get_status()
+281 -57
View File
@@ -8,12 +8,25 @@ Admins use the "Build player files" admin page to:
The SSH deployment flow then ships this staged directory to player devices, so The SSH deployment flow then ships this staged directory to player devices, so
the version admins build here is exactly what gets deployed. the version admins build here is exactly what gets deployed.
Performance note
----------------
The player repository is large (~200 MB) and a full clone takes ~90 s. Because
the build runs inside an HTTP request, that would exceed gunicorn's worker
timeout and the worker would be killed mid-clone, leaving a broken checkout.
Two mitigations are used together:
* **Shallow clones** (``--depth 1``) only the tip of the requested branch is
fetched, which is all a deployment needs. Drastically reduces transfer size.
* **Background execution** the admin route starts the build in a daemon
thread and the page polls for progress, so no worker ever blocks on git.
""" """
import os import os
import json import json
import shutil import shutil
import subprocess import subprocess
import logging import logging
import threading
from datetime import datetime from datetime import datetime
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@@ -24,90 +37,154 @@ logger = logging.getLogger(__name__)
# Metadata file name stored in the Flask instance folder. # Metadata file name stored in the Flask instance folder.
BUILD_META_FILENAME = 'player_build.json' BUILD_META_FILENAME = 'player_build.json'
# Only the tip of the branch is needed to deploy a player, so history is not
# fetched. Keeps the transfer small enough to avoid worker timeouts.
CLONE_DEPTH = '1'
def _run_git(args, cwd=None, timeout=300) -> subprocess.CompletedProcess: # Never let git wait for a human. Without this, a private/renamed repository
return subprocess.run( # makes git block on a username prompt until the worker is killed.
['git'] + args, GIT_ENV = {
cwd=cwd, 'GIT_TERMINAL_PROMPT': '0', # never prompt for credentials
capture_output=True, 'GIT_ASKPASS': 'true', # answer any credential request immediately
text=True, 'GIT_SSH_COMMAND': 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new',
timeout=timeout, }
)
def _git_env() -> Dict[str, str]:
"""Environment for git subprocesses: inherit the process env plus our flags."""
env = dict(os.environ)
env.update(GIT_ENV)
return env
def _run_git(args, cwd=None, timeout=120) -> subprocess.CompletedProcess:
"""Run a git command, never prompting for input.
Args:
args: git arguments (without the leading 'git').
cwd: working directory for the command.
timeout: hard cap in seconds. Defaults to 120 to stay within a
reasonable window even when running in the foreground.
Returns:
The completed process. ``returncode`` is 124 on timeout so callers can
distinguish a timeout from a normal failure.
"""
try:
return subprocess.run(
['git'] + args,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
env=_git_env(),
)
except subprocess.TimeoutExpired as e:
# Surface timeouts as a normal result so callers do not need try/except.
out = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or '')
err = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or '')
return subprocess.CompletedProcess(
args=['git'] + list(args), returncode=124,
stdout=out, stderr=(err + f'\ngit {" ".join(args)} timed out after {timeout}s').strip(),
)
def get_short_head(player_code_dir: str) -> str: def get_short_head(player_code_dir: str) -> str:
"""Return the short git commit of the staged code, or 'unknown'.""" """Return the short git commit of the staged code, or 'unknown'."""
try: result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10)
result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10) if result.returncode == 0:
if result.returncode == 0: return result.stdout.strip()
return result.stdout.strip()
except Exception:
pass
return 'unknown' return 'unknown'
def is_valid_checkout(path: str) -> bool:
"""True when *path* is a usable git checkout with a resolvable HEAD."""
if not os.path.isdir(os.path.join(path, '.git')):
return False
return get_short_head(path) != 'unknown'
def _clone(path: str, repo_url: str, branch: str) -> subprocess.CompletedProcess:
"""Shallow-clone a single branch into *path*."""
return _run_git([
'clone', '--depth', CLONE_DEPTH, '--single-branch',
'--branch', branch, repo_url, path,
], timeout=600)
def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main') -> Dict[str, Any]: def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main') -> Dict[str, Any]:
"""Clone or refresh the player source into ``player_code_dir``. """Clone or refresh the player source into ``player_code_dir``.
If the directory is already a git checkout of ``repo_url`` it is updated in Uses a **shallow single-branch clone/update** so only the tip of the wanted
place (fetch + hard reset to the chosen branch). Otherwise it is cloned branch is transferred. If the directory is a usable checkout it is updated
fresh (an existing non-git directory is replaced). (fetch + hard reset to the branch). A directory that exists but is NOT a
usable checkout e.g. left behind by an interrupted clone is removed and
re-cloned, since updating it can never work.
Returns a dict: ``success`` (bool), ``message`` (str), ``version`` (str), Args:
``branch`` (str). player_code_dir: Destination directory for the staged player code.
repo_url: Git repository to pull from.
branch: Branch to stage.
Returns:
``{'success': bool, 'message': str, 'version': str|None, 'branch': str}``
""" """
branch = (branch or 'main').strip() branch = (branch or 'main').strip()
repo_url = (repo_url or '').strip() repo_url = (repo_url or '').strip()
def fail(message: str) -> Dict[str, Any]:
return {'success': False, 'message': message,
'version': get_short_head(player_code_dir), 'branch': branch}
if not repo_url: if not repo_url:
return {'success': False, 'message': 'Repository URL is required.', 'version': None, 'branch': branch} return {'success': False, 'message': 'Repository URL is required.',
'version': None, 'branch': branch}
try: try:
git_dir = os.path.join(player_code_dir, '.git') usable = is_valid_checkout(player_code_dir)
is_git_repo = os.path.isdir(git_dir)
if is_git_repo: if usable:
# Update existing checkout in place. # Update in place. Depth 1 keeps the update cheap; fetch by ref so
fetch = _run_git(['-C', player_code_dir, 'fetch', '--prune', 'origin']) # it works on a shallow clone.
fetch = _run_git(
['-C', player_code_dir, 'fetch', '--depth', CLONE_DEPTH,
'--prune', 'origin', branch])
if fetch.returncode != 0: if fetch.returncode != 0:
return { return fail(f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}')
'success': False,
'message': f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
# Point origin at the requested URL in case it changed. # Point origin at the requested URL in case it changed.
_run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url]) _run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url])
checkout = _run_git(['-C', player_code_dir, 'checkout', branch]) checkout = _run_git(['-C', player_code_dir, 'checkout', branch])
if checkout.returncode != 0: if checkout.returncode != 0:
return { return fail(f'git checkout {branch} failed: {checkout.stderr.strip()}')
'success': False,
'message': f'git checkout {branch} failed: {checkout.stderr.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}']) reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}'])
if reset.returncode != 0: if reset.returncode != 0:
return { return fail(f'git reset failed: {reset.stderr.strip()}')
'success': False,
'message': f'git reset failed: {reset.stderr.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
action = 'Updated' action = 'Updated'
else: else:
# Fresh clone. Replace any existing (non-git) directory. # Fresh clone. A previous attempt may have left a partial directory
# (e.g. killed mid-clone) — it must go, or the clone will fail with
# "destination path already exists and is not an empty directory".
parent = os.path.dirname(player_code_dir.rstrip('/')) parent = os.path.dirname(player_code_dir.rstrip('/'))
os.makedirs(parent, exist_ok=True) if parent:
os.makedirs(parent, exist_ok=True)
if os.path.exists(player_code_dir): if os.path.exists(player_code_dir):
shutil.rmtree(player_code_dir) logger.info('Removing unusable directory before clone: %s', player_code_dir)
clone = _run_git(['clone', '--branch', branch, repo_url, player_code_dir]) shutil.rmtree(player_code_dir, ignore_errors=True)
clone = _clone(player_code_dir, repo_url, branch)
if clone.returncode != 0: if clone.returncode != 0:
return { # Do not leave a half-written directory behind.
'success': False, shutil.rmtree(player_code_dir, ignore_errors=True)
'message': f'git clone failed: {clone.stderr.strip() or clone.stdout.strip()}', detail = clone.stderr.strip() or clone.stdout.strip()
'version': None, if clone.returncode == 124 or 'timed out' in detail:
'branch': branch, return fail(f'git clone timed out. The repository may be very '
} f'large or unreachable: {detail}')
return fail(f'git clone failed: {detail}')
action = 'Cloned' action = 'Cloned'
version = get_short_head(player_code_dir) version = get_short_head(player_code_dir)
@@ -118,11 +195,15 @@ def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main'
'version': version, 'version': version,
'branch': branch, 'branch': branch,
} }
except subprocess.TimeoutExpired: except Exception as e: # noqa: BLE001 - surface any failure
return {'success': False, 'message': 'Git operation timed out.', 'version': None, 'branch': branch}
except Exception as e:
logger.exception('build_player_files failed') logger.exception('build_player_files failed')
return {'success': False, 'message': f'Build failed: {str(e)}', 'version': None, 'branch': branch} # Never leave a broken checkout behind for the next attempt.
try:
if not is_valid_checkout(player_code_dir):
shutil.rmtree(player_code_dir, ignore_errors=True)
except Exception:
pass
return fail(f'Build failed: {str(e)}')
def write_base_config( def write_base_config(
@@ -221,3 +302,146 @@ def make_build_record(repo_url, branch, server_ip, port, use_https, verify_ssl,
'built_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z', 'built_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
'built_by': built_by, 'built_by': built_by,
} }
# ---------------------------------------------------------------------------
# Background builds
#
# A full clone/refresh takes far longer than gunicorn's worker timeout, so the
# build must not run inside the request. The admin route starts it here and the
# page polls `build_state()` for progress.
# ---------------------------------------------------------------------------
# Serialises writes to _build_state between the request thread and the worker.
_build_lock = threading.Lock()
# Coarse progress for the admin UI. 'state' is one of:
# idle | running | success | error
_build_state: Dict[str, Any] = {'state': 'idle'}
def get_build_state() -> Dict[str, Any]:
"""Return a snapshot of the current/last build for the admin UI."""
with _build_lock:
return dict(_build_state)
def is_build_running() -> bool:
"""True while a build is in progress."""
with _build_lock:
return _build_state.get('state') == 'running'
def _set_build_state(**fields: Any) -> None:
with _build_lock:
_build_state.update(fields)
def _run_build_job(app, player_code_dir: str, repo_url: str, branch: str,
config_payload: Optional[Dict[str, Any]],
meta_path: str, built_by: str) -> None:
"""Worker body: build files, optionally write config, then persist settings.
Runs in a daemon thread with its own Flask app context so it is independent
of the request/response cycle that triggered it.
"""
started = datetime.utcnow()
try:
_set_build_state(state='running', step='Fetching player source…',
started_at=started.isoformat(timespec='seconds') + 'Z',
message='', version=None)
result = build_player_files(player_code_dir, repo_url, branch)
version = result.get('version')
if not result['success']:
_set_build_state(state='error', step='', message=result['message'],
version=version,
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
logger.error('Background player build failed: %s', result['message'])
return
# Optional step 2: write the base config.
if config_payload:
_set_build_state(step='Writing player config…')
cfg = write_base_config(player_code_dir=player_code_dir, **config_payload)
if not cfg['success']:
_set_build_state(state='error', step='', message=cfg['message'],
version=version,
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
logger.error('Background player config write failed: %s', cfg['message'])
return
result = {**result, 'message': f"{result['message']} {cfg['message']}"}
if version is None:
version = get_short_head(player_code_dir)
save_build_settings(
meta_path,
make_build_record(
repo_url=repo_url, branch=branch,
server_ip=(config_payload or {}).get('server_ip', ''),
port=(config_payload or {}).get('port', ''),
use_https=(config_payload or {}).get('use_https', False),
verify_ssl=(config_payload or {}).get('verify_ssl', False),
orientation=(config_payload or {}).get('orientation', 'Landscape'),
max_resolution=(config_payload or {}).get('max_resolution', '1920x1080'),
version=version, built_by=built_by,
),
)
_set_build_state(state='success', step='', message=result['message'],
version=version,
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
logger.info('Background player build complete (version %s)', version)
except Exception as e: # noqa: BLE001 - never kill the thread silently
logger.exception('Background player build crashed')
_set_build_state(state='error', step='', message=f'Build failed: {e}',
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
def start_background_build(player_code_dir: str, repo_url: str, branch: str,
config_payload: Optional[Dict[str, Any]],
meta_path: str, built_by: str) -> bool:
"""Start a player build in a daemon thread.
Args:
player_code_dir: Where to stage the player source.
repo_url: Git repository URL.
branch: Branch to stage.
config_payload: Keyword args for :func:`write_base_config`, or None to
skip writing the config.
meta_path: Where to persist the build record.
built_by: Username shown in the UI/logs.
Returns:
False if a build is already running (callers should tell the user),
True if a new build was started.
Raises:
RuntimeError: if called with no Flask application context the worker
thread needs a real app object to push its own context.
"""
from flask import current_app
if is_build_running():
return False
# Capture the real app object now. `current_app` resolves inside either a
# request or a plain application context; the worker thread pushes its own
# context later, since the caller's context is gone by then.
app = current_app._get_current_object()
_set_build_state(state='running', step='Starting…', message='', version=None,
started_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z',
finished_at=None, built_by=built_by,
repo_url=repo_url, branch=branch)
thread = threading.Thread(
target=_run_build_job,
args=(app, player_code_dir, repo_url, branch, config_payload, meta_path, built_by),
name='player-build',
daemon=True,
)
thread.start()
return True
+197 -62
View File
@@ -16,10 +16,17 @@ echo -e "${BLUE}║ DigiServer Automated Deployment
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}" echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
echo "" echo ""
# Check if docker compose is available # Check if docker compose is available. Accept either the modern plugin
if ! docker compose version &> /dev/null; then # (`docker compose`) or the standalone v1 binary (`docker-compose`), since not
# every Docker installation ships the Compose plugin.
if docker compose version &> /dev/null; then
COMPOSE="docker compose"
elif command -v docker-compose &> /dev/null; then
COMPOSE="docker-compose"
echo -e "${YELLOW}⚠️ Using legacy 'docker-compose' (v1); the 'docker compose' plugin is unavailable.${NC}"
else
echo -e "${RED}❌ docker compose not found!${NC}" echo -e "${RED}❌ docker compose not found!${NC}"
echo "Please install docker compose first" echo "Please install the docker compose plugin or docker-compose first"
exit 1 exit 1
fi fi
@@ -31,67 +38,133 @@ if [ ! -f "docker-compose.yml" ]; then
fi fi
# ============================================================================ # ============================================================================
# INITIALIZATION: Create data directories and copy nginx configs # INITIALIZATION: Create data directories and seed the Caddy config
# ============================================================================ # ============================================================================
echo -e "${YELLOW}📁 Initializing data directories...${NC}" echo -e "${YELLOW}📁 Initializing data directories...${NC}"
# Create necessary data directories # Create necessary data directories
mkdir -p data/instance mkdir -p data/instance
mkdir -p data/uploads mkdir -p data/uploads
mkdir -p data/nginx-ssl mkdir -p data/caddy-data
mkdir -p data/nginx-logs mkdir -p data/caddy-config
mkdir -p data/certbot mkdir -p data/caddy-logs
# Copy nginx configuration files from repo root to data folder # Seed the Caddyfile. It is bind-mounted as a FILE, so it MUST exist before
if [ -f "nginx.conf" ]; then # `docker compose up` — otherwise Docker creates a directory in its place and
cp nginx.conf data/nginx.conf # Caddy fails to start.
echo -e " ${GREEN}${NC} nginx.conf copied to data/" if [ -f "data/Caddyfile" ]; then
echo -e " ${GREEN}${NC} data/Caddyfile present"
elif [ -f "Caddyfile.example" ]; then
cp Caddyfile.example data/Caddyfile
echo -e " ${GREEN}${NC} data/Caddyfile seeded from Caddyfile.example"
else else
echo -e " ${RED}nginx.conf not found in repo root!${NC}" echo -e " ${RED}Caddyfile.example not found in repo root!${NC}"
exit 1
fi
if [ -f "nginx-custom-domains.conf" ]; then
cp nginx-custom-domains.conf data/nginx-custom-domains.conf
echo -e " ${GREEN}${NC} nginx-custom-domains.conf copied to data/"
else
echo -e " ${RED}❌ nginx-custom-domains.conf not found in repo root!${NC}"
exit 1 exit 1
fi fi
echo -e "${GREEN}✅ Data directories initialized${NC}" echo -e "${GREEN}✅ Data directories initialized${NC}"
echo "" echo ""
# ============================================================================ # ============================================================================
# CONFIGURATION VARIABLES # CONFIGURATION VARIABLES
# ============================================================================ # ============================================================================
HOSTNAME="${HOSTNAME:-digiserver}" # NOTE: do NOT use the name HOSTNAME here — it is a bash built-in that already
DOMAIN="${DOMAIN:-digiserver.sibiusb.harting.intra}" # holds the machine's hostname (e.g. "development"), so `${HOSTNAME:-default}`
IP_ADDRESS="${IP_ADDRESS:-10.76.152.164}" # would silently ignore the default and leak the OS hostname into the Caddyfile.
SERVER_HOSTNAME="${SERVER_HOSTNAME:-digiserver}"
EMAIL="${EMAIL:-admin@example.com}" EMAIL="${EMAIL:-admin@example.com}"
PORT="${PORT:-443}"
# Auto-detect the primary LAN IP unless one was supplied explicitly.
if [ -z "${IP_ADDRESS:-}" ]; then
IP_ADDRESS="$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' | head -1)"
if [ -z "$IP_ADDRESS" ]; then
IP_ADDRESS="$(hostname -I 2>/dev/null | awk '{print $1}')"
fi
fi
if [ -z "$IP_ADDRESS" ]; then
echo -e "${RED}❌ Could not determine the server IP. Set IP_ADDRESS=... and re-run.${NC}"
exit 1
fi
# HTTPS_MODE selects how TLS is provided:
# internal : Caddy's internal CA, IP-only. Needs NO public DNS and NO ACME.
# Correct for an intranet name such as digiserver.sibiusb.harting.intra.
# acme : Let's Encrypt — requires DOMAIN to be publicly resolvable.
# off : plain HTTP only.
HTTPS_MODE="${HTTPS_MODE:-internal}"
case "$HTTPS_MODE" in
internal)
# An empty DOMAIN is what makes CaddyConfigGenerator choose the
# internal-CA path instead of Let's Encrypt.
DOMAIN=""
;;
acme)
if [ -z "${DOMAIN:-}" ]; then
echo -e "${RED}❌ HTTPS_MODE=acme requires DOMAIN to be set.${NC}"
exit 1
fi
;;
off)
DOMAIN=""
;;
*)
echo -e "${RED}❌ Invalid HTTPS_MODE '$HTTPS_MODE' (use internal|acme|off).${NC}"
exit 1
;;
esac
# Published ports. These MUST match docker-compose.yml, which maps Caddy's
# internal 80/443 to these host ports (HTTP_PORT / HTTPS_PORT).
HTTP_PORT="${HTTP_PORT:-80}"
HTTPS_PORT="${HTTPS_PORT:-443}"
echo -e "${BLUE}Configuration:${NC}" echo -e "${BLUE}Configuration:${NC}"
echo " Hostname: $HOSTNAME" echo " Hostname: $SERVER_HOSTNAME"
echo " Domain: $DOMAIN" echo " HTTPS mode: $HTTPS_MODE"
echo " Domain: ${DOMAIN:-(none — internal CA)}"
echo " IP Address: $IP_ADDRESS" echo " IP Address: $IP_ADDRESS"
echo " Email: $EMAIL" echo " Email: $EMAIL"
echo " Port: $PORT" echo " HTTP port: $HTTP_PORT"
echo " HTTPS port: $HTTPS_PORT"
echo "" echo ""
# ============================================================================ # ============================================================================
# STEP 1: Start containers # STEP 1: Build and start containers
# ============================================================================ # ============================================================================
echo -e "${YELLOW}📦 [1/6] Starting containers...${NC}" echo -e "${YELLOW}📦 [1/6] Building and starting containers...${NC}"
docker compose up -d
# Compose v1 refuses to build unless the buildx plugin is >= 0.17:
# "compose build requires buildx 0.17.0 or later"
# Distro Packaged Docker ships older buildx (or none). Detect that and fall back
# to a plain `docker build` + `up --no-build`, which needs no buildx at all.
APP_IMAGE="digiserver-v2-digiserver-app:latest"
BUILDX_VER="$(docker buildx version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1)"
BUILDX_OK=0
if [ -n "$BUILDX_VER" ]; then
_bx_major="${BUILDX_VER%%.*}"
_bx_minor="${BUILDX_VER##*.}"
if [ "$_bx_major" -gt 0 ] || { [ "$_bx_major" -eq 0 ] && [ "$_bx_minor" -ge 17 ]; }; then
BUILDX_OK=1
fi
fi
if [ "$BUILDX_OK" -eq 1 ]; then
echo -e " ${GREEN}${NC} buildx ${BUILDX_VER} — building via compose"
$COMPOSE up -d --build
else
echo -e " ${YELLOW}${NC} buildx ${BUILDX_VER:-not found} (< 0.17) — falling back to 'docker build'"
docker build -t "$APP_IMAGE" .
$COMPOSE up -d --no-build
fi
echo -e "${YELLOW}⏳ Waiting for containers to be healthy...${NC}" echo -e "${YELLOW}⏳ Waiting for containers to be healthy...${NC}"
sleep 10 sleep 15
# Verify containers are running # Verify containers are running
if ! docker compose ps | grep -q "Up"; then if ! $COMPOSE ps | grep -q "Up"; then
echo -e "${RED}❌ Containers failed to start!${NC}" echo -e "${RED}❌ Containers failed to start!${NC}"
docker compose logs $COMPOSE logs
exit 1 exit 1
fi fi
echo -e "${GREEN}✅ Containers started successfully${NC}" echo -e "${GREEN}✅ Containers started successfully${NC}"
@@ -103,15 +176,15 @@ echo ""
echo -e "${YELLOW}📊 [2/6] Running database migrations...${NC}" echo -e "${YELLOW}📊 [2/6] Running database migrations...${NC}"
echo -e " • Creating https_config table..." echo -e " • Creating https_config table..."
docker compose exec -T digiserver-app python /app/migrations/add_https_config_table.py $COMPOSE exec -T digiserver-app python /app/migrations/add_https_config_table.py
echo -e " • Creating player_user table..." echo -e " • Creating player_user table..."
docker compose exec -T digiserver-app python /app/migrations/add_player_user_table.py $COMPOSE exec -T digiserver-app python /app/migrations/add_player_user_table.py
echo -e " • Adding email to https_config..." echo -e " • Adding email to https_config..."
docker compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py $COMPOSE exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
echo -e " • Migrating player_user global settings..." echo -e " • Migrating player_user global settings..."
docker compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py $COMPOSE exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
echo -e " • Adding original_filename to content..." echo -e " • Adding original_filename to content..."
docker compose exec -T digiserver-app python /app/migrations/add_original_filename_to_content.py $COMPOSE exec -T digiserver-app python /app/migrations/add_original_filename_to_content.py
echo -e "${GREEN}✅ All database migrations completed${NC}" echo -e "${GREEN}✅ All database migrations completed${NC}"
echo "" echo ""
@@ -119,16 +192,41 @@ echo ""
# ============================================================================ # ============================================================================
# STEP 3: Configure HTTPS # STEP 3: Configure HTTPS
# ============================================================================ # ============================================================================
echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS...${NC}" echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS (mode: $HTTPS_MODE)...${NC}"
docker compose exec -T digiserver-app python /app/https_manager.py enable \ # https_manager.py mirrors the Admin UI code path: persist HTTPSConfig →
"$HOSTNAME" \ # regenerate the Caddyfile → hot-reload Caddy → verify the TLS listener and
"$DOMAIN" \ # automatically fall back to HTTP if it does not come up.
"$EMAIL" \ #
"$IP_ADDRESS" \ # `bootstrap` (rather than `enable`) is used deliberately: it records provenance
"$PORT" # via HTTPSConfig.updated_by, so an admin's later change in the UI is not
# silently overwritten on the next restart.
#
# The values computed above are injected with -e so this run uses them instead
# of whatever happens to be in the container's .env.
#
# Exit code 2 means "config applied but Caddy did not reload" — not fatal, so it
# must not abort the deployment.
set +e
if [ "$HTTPS_MODE" = "off" ]; then
$COMPOSE exec -T digiserver-app python /app/https_manager.py disable
else
$COMPOSE exec -T \
-e HOSTNAME_INTERNAL="$SERVER_HOSTNAME" \
-e HOST_IP="$IP_ADDRESS" \
-e DOMAIN="$DOMAIN" \
-e SSL_EMAIL="$EMAIL" \
-e HTTPS_PORT="$HTTPS_PORT" \
digiserver-app python /app/https_manager.py bootstrap
fi
HTTPS_RC=$?
set -e
echo -e "${GREEN}✅ HTTPS configured successfully${NC}" case "$HTTPS_RC" in
0) echo -e "${GREEN}✅ HTTPS configured${NC}" ;;
2) echo -e "${YELLOW}⚠️ HTTPS config applied but Caddy did not reload — restart the caddy container.${NC}" ;;
*) echo -e "${YELLOW}⚠️ HTTPS not configured (or verification failed and it fell back to HTTP); continuing.${NC}" ;;
esac
echo "" echo ""
# ============================================================================ # ============================================================================
@@ -136,20 +234,21 @@ echo ""
# ============================================================================ # ============================================================================
echo -e "${YELLOW}🔍 [4/6] Verifying database setup...${NC}" echo -e "${YELLOW}🔍 [4/6] Verifying database setup...${NC}"
docker compose exec -T digiserver-app python -c " $COMPOSE exec -T digiserver-app python -c "
from app.app import create_app from app.app import create_app
from app.extensions import db
from sqlalchemy import inspect from sqlalchemy import inspect
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
inspector = inspect(app.extensions.db.engine) inspector = inspect(db.engine)
tables = inspector.get_table_names() tables = inspector.get_table_names()
print(' Database tables:') print(' Database tables:')
for table in sorted(tables): for table in sorted(tables):
print(f' ✓ {table}') print(f' ✓ {table}')
print(f'') print(f'')
print(f' ✅ Total tables: {len(tables)}') print(f' ✅ Total tables: {len(tables)}')
" 2>/dev/null || echo " ⚠️ Database verification skipped" " || echo " ⚠️ Database verification skipped"
echo "" echo ""
# ============================================================================ # ============================================================================
@@ -157,7 +256,7 @@ echo ""
# ============================================================================ # ============================================================================
echo -e "${YELLOW}🔧 [5/6] Verifying Caddy configuration...${NC}" echo -e "${YELLOW}🔧 [5/6] Verifying Caddy configuration...${NC}"
docker compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1 $COMPOSE exec -T caddy caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
echo -e " ${GREEN}✅ Caddy configuration is valid${NC}" echo -e " ${GREEN}✅ Caddy configuration is valid${NC}"
else else
@@ -171,7 +270,7 @@ echo ""
echo -e "${YELLOW}📋 [6/6] Displaying configuration summary...${NC}" echo -e "${YELLOW}📋 [6/6] Displaying configuration summary...${NC}"
echo "" echo ""
docker compose exec -T digiserver-app python /app/https_manager.py status $COMPOSE exec -T digiserver-app python /app/https_manager.py status
echo "" echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
@@ -179,21 +278,57 @@ echo -e "${GREEN}║ 🎉 Deployment Complete!
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}" echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
echo "" echo ""
# Build host:port suffixes, omitting the default ports for readability.
_http_url="http://$IP_ADDRESS"
[ "$HTTP_PORT" != "80" ] && _http_url="http://$IP_ADDRESS:$HTTP_PORT"
_https_url="https://$IP_ADDRESS"
[ "$HTTPS_PORT" != "443" ] && _https_url="https://$IP_ADDRESS:$HTTPS_PORT"
echo -e "${BLUE}📍 Access Points:${NC}" echo -e "${BLUE}📍 Access Points:${NC}"
echo " 🔒 https://$HOSTNAME" echo -e " 🌐 ${_http_url} (always available)"
echo " 🔒 https://$IP_ADDRESS" case "$HTTPS_MODE" in
echo " 🔒 https://$DOMAIN" internal)
echo -e " 🔒 ${_https_url} (internal CA — see note below)"
echo -e " 🔒 https://$SERVER_HOSTNAME (needs DNS or an /etc/hosts entry)"
;;
acme)
echo -e " 🔒 https://$DOMAIN"
;;
off)
echo -e " ️ HTTPS disabled (HTTPS_MODE=off)"
;;
esac
echo "" echo ""
echo -e "${BLUE}📝 Default Credentials:${NC}" if [ "$HTTPS_MODE" = "internal" ]; then
echo " Username: admin" echo -e "${YELLOW}⚠️ Internal CA certificate notice:${NC}"
echo " Password: admin123 (⚠️ CHANGE IN PRODUCTION)" echo " The TLS certificate is signed by Caddy's LOCAL CA, which is not in any"
echo " client's trust store. Browsers will warn and players will reject it"
echo " unless you either:"
echo " a) install the root CA on each device, or"
echo " b) use the plain HTTP endpoint above (simplest for players)."
echo ""
echo " Export the root CA with:"
echo " $COMPOSE cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt"
echo ""
fi
echo -e "${BLUE}📝 Administrator Account:${NC}"
if [ -f ".deployment-credentials" ]; then
echo " Credentials are in .deployment-credentials (chmod 600)."
else
echo " Username: ${ADMIN_USERNAME:-admin}"
echo " Password: see ADMIN_PASSWORD in .env (or the container's ADMIN_PASSWORD)"
fi
if grep -qs '^ADMIN_PASSWORD=admin123' .env 2>/dev/null; then
echo -e " ${RED}⚠️ ADMIN_PASSWORD is still the default — change it now!${NC}"
fi
echo "" echo ""
echo -e "${BLUE}📚 Documentation:${NC}" echo -e "${BLUE}📚 Documentation:${NC}"
echo " • DEPLOYMENT_COMMANDS.md - Detailed docker exec commands" echo " • docs/07-deployment.md - Deployment + HTTPS details"
echo " • HTTPS_CONFIGURATION.md - HTTPS setup details" echo " • docs/06-utils-services.md - Caddy / https_manager internals"
echo " • setup_https.sh - Manual configuration script" echo " • Caddyfile.example - Caddy template (seeded into data/)"
echo "" echo ""
echo -e "${YELLOW}Next Steps:${NC}" echo -e "${YELLOW}Next Steps:${NC}"
@@ -204,5 +339,5 @@ echo "4. Configure your players and content"
echo "" echo ""
echo -e "${BLUE}📞 Support:${NC}" echo -e "${BLUE}📞 Support:${NC}"
echo "For troubleshooting, see DEPLOYMENT_COMMANDS.md section 7" echo "For troubleshooting, see docs/07-deployment.md"
echo "" echo ""
+2 -2
View File
@@ -104,8 +104,8 @@ echo ""
echo "Test certificate:" echo "Test certificate:"
echo " openssl s_client -connect your-domain.com:443 -showcerts" echo " openssl s_client -connect your-domain.com:443 -showcerts"
echo "" echo ""
echo "Check SSL certificate expiry:" echo "Check TLS certificate (Caddy internal CA root):"
echo " openssl x509 -enddate -noout -in data/nginx-ssl/cert.pem" echo " openssl x509 -enddate -noout -in data/caddy-data/caddy/pki/authorities/local/root.crt"
echo "" echo ""
# ============================================================================ # ============================================================================
+47 -3
View File
@@ -15,11 +15,46 @@ services:
# Only mount persistent data folders: # Only mount persistent data folders:
- ./data/instance:/app/instance - ./data/instance:/app/instance
- ./data/uploads:/app/app/static/uploads - ./data/uploads:/app/app/static/uploads
# Staged player source (git clone). Persisted so a rebuild of the app
# container does not throw away the ~140 MB staged checkout, and so the
# SSH deployment step has something to ship.
- ./data/player:/app/data/player
# The app GENERATES the Caddyfile (env bootstrap at startup, and the
# Admin → HTTPS Configuration page at runtime) then asks Caddy to reload.
# It therefore needs write access to the same file Caddy reads, so this
# must be mounted here as well as in the caddy service.
# The file is host-owned (uid 1000 == appuser), so writes succeed.
- ./data/Caddyfile:/etc/caddy/Caddyfile:rw
environment: environment:
- FLASK_ENV=production - FLASK_ENV=production
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this} - SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
# ---------------------------------------------------------------------
# Deploy-time TLS bootstrap (all optional).
#
# If HOSTNAME_INTERNAL and HOST_IP are BOTH set, the container configures
# Caddy for HTTPS using that address at startup — no manual step needed.
# If either is missing, the app stays on the plain-HTTP fallback and you
# can enable HTTPS later from Admin → HTTPS Configuration (which reloads
# Caddy live, no restart required).
#
# Leave DOMAIN empty for Caddy's internal CA. That needs NO public DNS
# and NO ACME, which is the right choice for an intranet name that is not
# resolvable from the internet.
# ---------------------------------------------------------------------
- HOSTNAME_INTERNAL=${HOSTNAME_INTERNAL:-}
- HOST_IP=${HOST_IP:-}
- DOMAIN=${DOMAIN:-}
- SSL_EMAIL=${SSL_EMAIL:-}
# Externally published ports (must match the caddy service mappings below).
# They are used to build correct HTTP→HTTPS redirect targets.
- HTTP_PORT=${HTTP_PORT:-80}
- HTTPS_PORT=${HTTPS_PORT:-443}
# Set to "false" to serve TLS only and redirect plain HTTP to HTTPS.
- HTTPS_HTTP_FALLBACK=${HTTPS_HTTP_FALLBACK:-true}
# Post-bootstrap check: probe HTTPS and fall back to HTTP if it is broken.
- HTTPS_VERIFY=${HTTPS_VERIFY:-true}
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()"]
@@ -30,14 +65,23 @@ services:
networks: networks:
- digiserver-network - digiserver-network
# Caddy reverse proxy — auto-provisions Let's Encrypt certs when a real domain is configured # Caddy reverse proxy.
# Port 80 → always answers, for both the IP and the hostname.
# Port 443 → HTTPS when configured; plain HTTP is served alongside it by
# default so clients that cannot trust the internal CA still work.
# Ports are configurable so the stack also works on a host where 80/443 are
# already taken (e.g. HTTP_PORT=8080 HTTPS_PORT=8443).
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine
container_name: digiserver-caddy container_name: digiserver-caddy
ports: ports:
- "8080:80" - "${HTTP_PORT:-80}:80"
- "8443:443" - "${HTTPS_PORT:-443}:443"
volumes: volumes:
# The app container regenerates this file on startup (env bootstrap) and
# whenever HTTPS is changed in the Admin UI, then hot-reloads Caddy via
# its admin API (http://caddy:2019/load). Because the file on disk is
# always current, a plain restart of Caddy picks up the latest config.
- ./data/Caddyfile:/etc/caddy/Caddyfile:rw - ./data/Caddyfile:/etc/caddy/Caddyfile:rw
- ./data/caddy-data:/data - ./data/caddy-data:/data
- ./data/caddy-config:/config - ./data/caddy-config:/config
+133 -15
View File
@@ -3,50 +3,168 @@ set -e
echo "Starting DigiServer v2..." echo "Starting DigiServer v2..."
# ---------------------------------------------------------------------------
# Pin the database explicitly.
#
# The migration scripts and the application must target the SAME SQLite file.
# Without this, they do not: the config classes default to different files
# (dev.db for DevelopmentConfig, dashboard.db for ProductionConfig), and most
# migration scripts call create_app() without an argument, which selects the
# *development* config. The app itself runs as create_app('production').
# Exporting DATABASE_URL makes every code path below resolve to the same
# database, regardless of which config gets loaded.
# ---------------------------------------------------------------------------
export DATABASE_URL="${DATABASE_URL:-sqlite:////app/instance/dashboard.db}"
export FLASK_ENV="${FLASK_ENV:-production}"
echo "Database: ${DATABASE_URL}"
# Create necessary directories # Create necessary directories
mkdir -p /app/instance mkdir -p /app/instance
mkdir -p /app/app/static/uploads mkdir -p /app/app/static/uploads
# Staged player source (bind-mounted from ./data/player). Created here so the
# container works even if the volume was not pre-created on the host.
mkdir -p /app/data/player
# Initialize database if it doesn't exist # ---------------------------------------------------------------------------
if [ ! -f /app/instance/dashboard.db ]; then # Ensure the schema exists and bootstrap the admin user.
echo "Initializing database..." #
python -c " # Both operations are idempotent, so this runs on every container start:
# db.create_all() only creates missing tables, and the admin block creates the
# user if absent or otherwise refreshes its password from the environment.
# ---------------------------------------------------------------------------
echo "Ensuring database schema and admin user..."
python -c "
from app.app import create_app from app.app import create_app
from app.extensions import db, bcrypt from app.extensions import db, bcrypt
from app.models import User from app.models import User
import os
app = create_app('production') app = create_app('production')
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
# Create or update admin user from environment variables
import os
admin_username = os.getenv('ADMIN_USERNAME', 'admin') admin_username = os.getenv('ADMIN_USERNAME', 'admin')
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123') admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
admin = User.query.filter_by(username=admin_username).first() admin = User.query.filter_by(username=admin_username).first()
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
if not admin: if not admin:
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
admin = User(username=admin_username, password=hashed, role='admin') admin = User(username=admin_username, password=hashed, role='admin')
db.session.add(admin) db.session.add(admin)
db.session.commit()
print(f'✅ Admin user created ({admin_username})') print(f'✅ Admin user created ({admin_username})')
else: else:
# Update password if it exists # Keep the stored password in sync with the environment.
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
admin.password = hashed admin.password = hashed
db.session.commit()
print(f'✅ Admin user password updated ({admin_username})') print(f'✅ Admin user password updated ({admin_username})')
db.session.commit()
" "
echo "Database initialized!"
# ---------------------------------------------------------------------------
# Apply schema migrations.
#
# Every migration script is idempotent (it guards against duplicate columns and
# already-migrated tables), so this is safe to run on every start and upgrades
# databases created by older releases.
#
# ORDER MATTERS: migrations that create/repair a table must run before the ones
# that alter that table (e.g. add_player_user_table.py before
# migrate_player_user_global.py, add_https_config_table.py before
# add_email_to_https_config.py).
#
# Migrations are deliberately NON-FATAL: a failure is logged and startup
# continues, so one bad migration cannot strand the container in a restart
# loop. Look for the WARNING lines in the logs if something looks wrong.
# ---------------------------------------------------------------------------
MIGRATIONS=(
"add_https_config_table.py"
"add_player_user_table.py"
"add_email_to_https_config.py"
"migrate_player_user_global.py"
"add_url_to_content.py"
"add_original_filename_to_content.py"
"add_deployment_fields_to_player.py"
)
echo "Running database migrations..."
FAILED_MIGRATIONS=()
for migration in "${MIGRATIONS[@]}"; do
migration_path="/app/migrations/${migration}"
if [ ! -f "$migration_path" ]; then
echo "⚠️ Skipping missing migration: ${migration}"
continue
fi
echo "${migration}"
if ! python "$migration_path"; then
echo "⚠️ WARNING: migration failed: ${migration}"
FAILED_MIGRATIONS+=("${migration}")
fi
done
if [ ${#FAILED_MIGRATIONS[@]} -gt 0 ]; then
echo "⚠️ WARNING: ${#FAILED_MIGRATIONS[@]} migration(s) failed: ${FAILED_MIGRATIONS[*]}"
echo "⚠️ Starting anyway — check the errors above."
else
echo "✅ All database migrations applied."
fi fi
# ---------------------------------------------------------------------------
# Bootstrap HTTPS from environment variables.
#
# HOSTNAME_INTERNAL + HOST_IP (compose → env) configure Caddy for HTTPS at
# startup, so a fresh deployment is reachable over TLS without a manual step.
# When either is unset this is a deliberate NO-OP: the app stays on plain HTTP
# and HTTPS can be enabled later from Admin → HTTPS Configuration, which
# reloads Caddy live.
#
# ORDERING MATTERS: gunicorn is started FIRST (below) and only then is HTTPS
# configured. The bootstrap probes https://…/api/health to decide whether the
# certificate really works; if it ran before the app was listening, Caddy would
# return 502 and the probe would wrongly conclude HTTPS was broken.
#
# Non-fatal: if this fails the app still runs, and the admin page remains the
# fallback path for configuring HTTPS.
# ---------------------------------------------------------------------------
bootstrap_https() {
echo "Checking HTTPS bootstrap..."
if ! python /app/https_manager.py bootstrap; then
echo "⚠️ WARNING: HTTPS bootstrap failed — continuing; configure HTTPS from the admin UI."
fi
}
# Start the application # Start the application
# --timeout is a safety net for any remaining synchronous long operation. The
# player build no longer blocks a worker (it runs in a background thread), but
# a generous margin avoids surprise worker kills during large uploads or
# dependency installs triggered from the admin UI.
echo "Starting Gunicorn..." echo "Starting Gunicorn..."
exec gunicorn \ gunicorn \
--bind 0.0.0.0:5000 \ --bind 0.0.0.0:5000 \
--workers 4 \ --workers 4 \
--timeout 120 \ --timeout 300 \
--access-logfile - \ --access-logfile - \
--error-logfile - \ --error-logfile - \
"app.app:create_app('production')" "app.app:create_app('production')" &
GUNICORN_PID=$!
# Wait for the app to answer before configuring HTTPS, then run the bootstrap.
for _ in $(seq 1 30); do
if python -c "
import sys, urllib.request
try:
urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=2)
except Exception:
sys.exit(1)
" 2>/dev/null; then
break
fi
sleep 1
done
bootstrap_https
# Keep the container attached to gunicorn so signals and healthchecks behave.
wait $GUNICORN_PID
+3 -4
View File
@@ -56,7 +56,7 @@ Graphify assigns each node a **level** (0 = entry/global → 3 = utility):
|---|---|---| |---|---|---|
| **L0 — Entry / Global** | 0 | `create_app()`, `app.py`, config classes, error handlers, CLI commands | | **L0 — Entry / Global** | 0 | `create_app()`, `app.py`, config classes, error handlers, CLI commands |
| **L1 — Strategic / Core** | 1 | Blueprint route handlers (players, content, api, admin), model classes | | **L1 — Strategic / Core** | 1 | Blueprint route handlers (players, content, api, admin), model classes |
| **L2 — Implementation** | 2 | Playlist/group management helpers, processing helpers, model methods | | **L2 — Implementation** | 2 | Playlist management helpers, processing helpers, model methods |
| **L3 — Utility** | 3 | `logger.py`, `ssh_deploy.py`, `caddy_manager.py`, `uploads.py`, `pptx_converter.py`, migrations | | **L3 — Utility** | 3 | `logger.py`, `ssh_deploy.py`, `caddy_manager.py`, `uploads.py`, `pptx_converter.py`, migrations |
```mermaid ```mermaid
@@ -81,7 +81,6 @@ flowchart TD
log["log_action()"] log["log_action()"]
up["uploads.py"] up["uploads.py"]
pptx["pptx_converter.py"] pptx["pptx_converter.py"]
nginx["NginxConfigReader"]
end end
create_app --> bp create_app --> bp
create_app --> models create_app --> models
@@ -102,14 +101,14 @@ Graphify clustered the code into **41 communities**. The 12 meaningful ones are
| Community | Domain (derived) | Files | Role | | Community | Domain (derived) | Files | Role |
|---|---|---|---| |---|---|---|---|
| **C0** (90) | **Authentication + legacy groups** | `blueprints/auth.py`, `blueprints/content_old.py`, `models/server_log.py`, `utils/logger.py`, `utils/group_player_management.py`, `old_code_documentation/blueprint_groups.py` | Auth flows + audit logging + (legacy) group features | | **C0** (90) | **Authentication + audit** | `blueprints/auth.py`, `models/server_log.py`, `utils/logger.py`, `old_code_documentation/blueprint_groups.py` | Auth flows + audit logging |
| **C1** (80) | **Admin + HTTPS + player users** | `blueprints/admin.py`, `models/https_config.py`, `models/player_user.py`, `utils/caddy_manager.py`, `migrations/add_player_user_table.py` | Admin panel, Caddy HTTPS generation, editing-user registry | | **C1** (80) | **Admin + HTTPS + player users** | `blueprints/admin.py`, `models/https_config.py`, `models/player_user.py`, `utils/caddy_manager.py`, `migrations/add_player_user_table.py` | Admin panel, Caddy HTTPS generation, editing-user registry |
| **C2** (68) | **Playlist & content workflows** | `blueprints/content.py`, `blueprints/playlist.py`, `models/playlist.py` | Modern playlist-centric content management + legacy redirects | | **C2** (68) | **Playlist & content workflows** | `blueprints/content.py`, `blueprints/playlist.py`, `models/playlist.py` | Modern playlist-centric content management + legacy redirects |
| **C3** (62) | **Player API + edits** | `blueprints/api.py`, `models/player_edit.py` | Player-facing REST, edited-media pipeline | | **C3** (62) | **Player API + edits** | `blueprints/api.py`, `models/player_edit.py` | Player-facing REST, edited-media pipeline |
| **C4** (55) | **Application core** | `app.py`, `config.py`, `models/user.py`, `utils/portal_sso.py`, `utils/script_name_fix.py` | App factory, config, auth identity, middleware | | **C4** (55) | **Application core** | `app.py`, `config.py`, `models/user.py`, `utils/portal_sso.py`, `utils/script_name_fix.py` | App factory, config, auth identity, middleware |
| **C5** (54) | **Content & player models** | `models/content.py`, `models/player.py`, `models/player_feedback.py`, `blueprints/main.py` | Media model, player model, feedback, dashboard | | **C5** (54) | **Content & player models** | `models/content.py`, `models/player.py`, `models/player_feedback.py`, `blueprints/main.py` | Media model, player model, feedback, dashboard |
| **C6** (51) | **Player management UI** | `blueprints/players.py`, `migrations/add_https_config_table.py` | Player CRUD, manage page, deployment polling | | **C6** (51) | **Player management UI** | `blueprints/players.py`, `migrations/add_https_config_table.py` | Player CRUD, manage page, deployment polling |
| **C7** (42) | **Groups (legacy)** | `models/group.py`, `utils/nginx_config_reader.py` | Archived groups feature + legacy nginx reader | | **C7** (42) | ***(removed)*** | — | Groups model + legacy nginx reader were deleted during sanitization |
| **C8** (24) | **Dev tooling** | `old_code_documentation/test_edit_media_api.py`, `Colors`, integrity checker | Test/analysis utilities | | **C8** (24) | **Dev tooling** | `old_code_documentation/test_edit_media_api.py`, `Colors`, integrity checker | Test/analysis utilities |
| **C9** (21) | **Upload processing** | `utils/uploads.py` | Upload progress, video/image processing | | **C9** (21) | **Upload processing** | `utils/uploads.py` | Upload progress, video/image processing |
| **C10** (20) | **Deployment** | `utils/background_tasks.py`, `utils/ssh_deploy.py` | Background SSH deployment engine | | **C10** (20) | **Deployment** | `utils/background_tasks.py`, `utils/ssh_deploy.py` | Background SSH deployment engine |
+1 -1
View File
@@ -84,7 +84,7 @@ These are the most-connected nodes (graph centrality). They are the architectura
Graphify groups related code into communities. See [01-architecture.md §3](01-architecture.md#3-component-map-files--communities) for the full mapping. The largest: Graphify groups related code into communities. See [01-architecture.md §3](01-architecture.md#3-component-map-files--communities) for the full mapping. The largest:
- **C0 · Auth & logging** (90 nodes) — login/logout/register + audit logging + legacy groups - **C0 · Auth & logging** (90 nodes) — login/logout/register + audit logging
- **C1 · Admin & HTTPS** (80 nodes) — admin panel, Caddy generation, editing users - **C1 · Admin & HTTPS** (80 nodes) — admin panel, Caddy generation, editing users
- **C2 · Playlists & content** (68 nodes) — the modern content/playlist workflow - **C2 · Playlists & content** (68 nodes) — the modern content/playlist workflow
- **C3 · Player API & edits** (62 nodes) — player-facing REST + edited-media pipeline - **C3 · Player API & edits** (62 nodes) — player-facing REST + edited-media pipeline
+8 -12
View File
@@ -15,7 +15,6 @@ erDiagram
content ||--o{ player_edit : "edited (cascade)" content ||--o{ player_edit : "edited (cascade)"
content ||--o{ player_feedback : "playing" content ||--o{ player_feedback : "playing"
playlist ||--o{ content : "playlist_content M2M (position,duration,muted,edit_on_player_enabled)" playlist ||--o{ content : "playlist_content M2M (position,duration,muted,edit_on_player_enabled)"
content }o--o{ group : "group_content M2M (legacy)"
player_user ||--o{ player_edit : "user_code" player_user ||--o{ player_edit : "user_code"
https_config ||--o| https_config : "singleton row" https_config ||--o| https_config : "singleton row"
``` ```
@@ -74,8 +73,8 @@ Methods: `is_online` (5-min window), `update_status()`, `set_password()/check_pa
| `description` | Text | nullable | | `description` | Text | nullable |
| `uploaded_at` | DateTime | NOT NULL, indexed | | `uploaded_at` | DateTime | NOT NULL, indexed |
Relationships: `playlists` (M2M via `playlist_content`), `groups` (M2M via `group_content`). Relationships: `playlists` (M2M via `playlist_content`).
Properties/methods: `file_size_mb`, `group_count`, `original_display_name`, `original_media_path`, `current_media_path`, `is_image()/is_video()/is_pdf()/is_weblink()`, `has_player_edits`. Properties/methods: `file_size_mb`, `original_display_name`, `original_media_path`, `current_media_path`, `is_image()/is_video()/is_pdf()/is_weblink()`, `has_player_edits`.
### `playlist` — ordered collections of content ### `playlist` — ordered collections of content
| Column | Type | Notes | | Column | Type | Notes |
@@ -168,15 +167,13 @@ Classmethods: `log_info`, `log_warning`, `log_error`.
Classmethods: `get_config()` (first row), `create_or_update(...)`. Method: `to_dict()`. Classmethods: `get_config()` (first row), `create_or_update(...)`. Method: `to_dict()`.
### `group` + `group_content` — **ARCHIVED / LEGACY** ### `group` + `group_content` — **REMOVED**
| Column | Type | Notes |
|---|---|---|
| `id` | Integer | PK |
| `name` | String(100) | unique, NOT NULL, indexed |
| `description` | Text | nullable |
| `created_at` / `updated_at` | DateTime | NOT NULL |
`group_content(group_id, content_id)` composite-PK M2M. The current `Player` model has **no** `group_id` column — group features are archived (see [09 · Legacy](09-legacy-and-migrations.md)). The `Group` model, the `group_content` association table, `Content.groups` /
`Content.group_count`, and the group-management utility functions were **deleted**
during the code sanitization pass (the feature was archived and the table had no
rows). `Player` never had a `group_id` column. See
[SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md).
--- ---
@@ -185,7 +182,6 @@ Classmethods: `get_config()` (first row), `create_or_update(...)`. Method: `to_d
| Relationship | Cardinality | FK / Mechanism | | Relationship | Cardinality | FK / Mechanism |
|---|---|---| |---|---|---|
| `playlist``content` | M:N | `playlist_content` (positioned, with extras) | | `playlist``content` | M:N | `playlist_content` (positioned, with extras) |
| `group``content` | M:N | `group_content` (legacy) |
| `player``playlist` | N:1 | `player.playlist_id` (ON DELETE SET NULL) | | `player``playlist` | N:1 | `player.playlist_id` (ON DELETE SET NULL) |
| `player``player_feedback` | 1:N | `player_feedback.player_id` (cascade) | | `player``player_feedback` | 1:N | `player_feedback.player_id` (cascade) |
| `player``player_edit` | 1:N | `player_edit.player_id` (cascade) | | `player``player_edit` | 1:N | `player_edit.player_id` (cascade) |
+5 -4
View File
@@ -40,7 +40,9 @@ def register_blueprints(app):
... ...
``` ```
> Note: `app.blueprints.content_old` is **not** imported — it is dead/legacy code. > Note: `content_old.py` (legacy content routes) was **deleted** during the code
> sanitization pass — see [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md). The
> legacy `playlist.py` blueprint remains (redirect-only).
--- ---
@@ -153,9 +155,8 @@ app/templates/
├── admin/ admin.html, user_management.html, leftover_media.html, ├── admin/ admin.html, user_management.html, leftover_media.html,
│ dependencies.html, customize_logos.html, editing_users.html, │ dependencies.html, customize_logos.html, editing_users.html,
│ https_config.html, build_player.html │ https_config.html, build_player.html
├── content/ content_list.html (legacy), content_list_new.html (modern), ├── content/ content_list_new.html, media_library.html,
media_library.html, upload_content.html (legacy), upload_media.html, manage_playlist_content.html
│ upload_media.html, manage_playlist_content.html, edit_content.html
├── players/ players_list.html, add_player.html, edit_player.html, ├── players/ players_list.html, add_player.html, edit_player.html,
│ manage_player.html, player_page.html, player_fullscreen.html, │ manage_player.html, player_page.html, player_fullscreen.html,
│ edited_media.html, edited_media_report.html, _deploy_badge.html │ edited_media.html, edited_media_report.html, _deploy_badge.html
+2 -2
View File
@@ -1,6 +1,6 @@
# 05 · Blueprints & REST API # 05 · Blueprints & REST API
DigiServer v2 registers **7 active blueprints** (`content_old.py` is legacy dead code and is not registered). DigiServer v2 registers **7 active blueprints**.
--- ---
@@ -203,7 +203,7 @@ Redirects/legacy — kept for compatibility:
|---|---|---|---| |---|---|---|---|
| `/player-feedback` | POST | `receive_player_feedback` | Status (playing/paused/error/restarting); infers player; **auto-marks deployment `deployed`** | | `/player-feedback` | POST | `receive_player_feedback` | Status (playing/paused/error/restarting); infers player; **auto-marks deployment `deployed`** |
| `/player-status/<player_id>` | GET | `get_player_status` | Online (5-min), latest feedback | | `/player-status/<player_id>` | GET | `get_player_status` | Online (5-min), latest feedback |
| `/system-info` | GET | — | Counts: players online/total, groups, content, 24h logs | | `/system-info` | GET | — | Counts: players online/total, content, 24h logs |
| `/content` | GET | — | List content with counts | | `/content` | GET | — | List content with counts |
| `/logs` | GET | `get_logs` | Query logs (`limit`/`level`/`since`) | | `/logs` | GET | `get_logs` | Query logs (`limit`/`level`/`since`) |
+35 -6
View File
@@ -9,7 +9,7 @@ All shared services live in `app/utils/`. This document details each module, its
| Module | Community | Responsibility | Key symbols | | Module | Community | Responsibility | Key symbols |
|---|---|---|---| |---|---|---|---|
| `logger.py` | C0 | DB-backed audit logging | `log_action()`, `get_recent_logs()`, `clear_old_logs()` | | `logger.py` | C0 | DB-backed audit logging | `log_action()`, `get_recent_logs()`, `clear_old_logs()` |
| `group_player_management.py` | C0 | Group/player stats (legacy) | `get_player_status_info()`, `assign_player_to_group()`, `get_online_players_count()` | | `group_player_management.py` | C0 | Player status reporting | `get_player_status_info()` |
| `caddy_manager.py` | C1 | HTTPS Caddyfile generation | `CaddyConfigGenerator`, `write_caddyfile()`, `reload_caddy()` | | `caddy_manager.py` | C1 | HTTPS Caddyfile generation | `CaddyConfigGenerator`, `write_caddyfile()`, `reload_caddy()` |
| `background_tasks.py` | C10 | Async task execution | `run_background_task()`, `background_player_deployment()` | | `background_tasks.py` | C10 | Async task execution | `run_background_task()`, `background_player_deployment()` |
| `ssh_deploy.py` | C10 | Remote player provisioning | `deploy_player_to_host()`, `test_ssh_connection()`, `generate_player_config()` | | `ssh_deploy.py` | C10 | Remote player provisioning | `deploy_player_to_host()`, `test_ssh_connection()`, `generate_player_config()` |
@@ -18,7 +18,9 @@ All shared services live in `app/utils/`. This document details each module, its
| `uploads.py` | C9 | Upload progress + file ops | `get/set/clear_upload_progress()`, `save_uploaded_file()`, `process_video_file()` | | `uploads.py` | C9 | Upload progress + file ops | `get/set/clear_upload_progress()`, `save_uploaded_file()`, `process_video_file()` |
| `portal_sso.py` | C4 | SSO auto-login | `init_portal_sso()`, `_get_or_create_user()` | | `portal_sso.py` | C4 | SSO auto-login | `init_portal_sso()`, `_get_or_create_user()` |
| `script_name_fix.py` | C4 | WSGI sub-path middleware | `ScriptNameFix` | | `script_name_fix.py` | C4 | WSGI sub-path middleware | `ScriptNameFix` |
| `nginx_config_reader.py` | C7 | Legacy nginx status parsing | `NginxConfigReader`, `get_nginx_status()` |
> `nginx_config_reader.py` (C7) was **removed** during the sanitization pass — the
> reverse proxy is Caddy. See [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md).
--- ---
@@ -81,11 +83,31 @@ Other helpers:
| Method | Purpose | | Method | Purpose |
|---|---| |---|---|
| `generate_caddyfile(config)` | Pick template by mode: **HTTP-only** (`:80`), **domain** (Let's Encrypt), or **IP** (internal CA self-signed). Includes `reverse_proxy digiserver-app:5000`, 2 GB body limit, gzip, security headers | | `generate_caddyfile(config, http_fallback=True)` | Pick template by mode: **HTTP-only** (`:80`), **domain** (Let's Encrypt), or **IP-only** (internal CA `tls internal`). Includes `reverse_proxy digiserver-app:5000`, 2 GB body limit, gzip, security headers. `http_fallback` also serves plain HTTP alongside internal-CA TLS so clients that cannot trust the local CA still work |
| `write_caddyfile(content, path=/etc/caddy/Caddyfile)` | Write to disk | | `write_caddyfile(content, path=/etc/caddy/Caddyfile)` | Write to disk |
| `reload_caddy()` | POST to Caddy admin API `http://caddy:2019/load` | | `reload_caddy()` | POST to Caddy admin API `http://caddy:2019/load` |
Triggered from `admin.update_https_config` after saving `HTTPSConfig`. Triggered from `admin.update_https_config` (Admin UI) **or** `https_manager.py` (CLI),
both of which save `HTTPSConfig` first so the two paths stay in sync.
> **Internal CA vs Let's Encrypt:** an intranet name (e.g. `*.harting.intra`) is not
> resolvable publicly, so ACME challenges cannot succeed. Leaving `domain` empty selects
> `tls internal`, which needs no DNS and no external service. See
> [07 · Deployment §6](07-deployment.md#6-https-setup-caddy).
---
## 4b. `https_manager.py` — HTTPS CLI (repo root)
Command-line equivalent of the Admin HTTPS page, used by `deploy.sh`.
| Command | Purpose |
|---|---|
| `enable <hostname> <domain> <email> <ip> [port]` | Persist `HTTPSConfig`, regenerate + write the Caddyfile, hot-reload Caddy. Empty `<domain>` → internal CA. `--redirect-only` to disable the HTTP fallback; `--no-https` for HTTP only |
| `disable` | Turn HTTPS off (HTTP only) |
| `status` | Print the stored configuration and resolved mode |
Exit codes: `0` success · `1` bad args/config · `2` config applied but Caddy did not reload.
--- ---
@@ -131,9 +153,16 @@ Used by the upload pipeline: **PPTX → PDF → PNG slides (Full HD)**.
--- ---
## 9. `nginx_config_reader.py` — Legacy (informational) ## 9. `group_player_management.py` — Player Status
`NginxConfigReader` parses an `nginx.conf` and reports `ssl_enabled`, ports, upstreams, `server_names`, `ssl_protocols`, `client_max_body_size`, `gzip`. **Legacy** — the current reverse proxy is Caddy; retained for reference and the old deployment stack. Only `get_player_status_info(player_id)` remains: it returns the online flag
(5-minute window), status, last-seen plus a humanised "time ago", and the latest
`PlayerFeedback`. Used by `players.list` and `players.manage_player`.
The group helpers (`get_group_statistics`, `assign_player_to_group`,
`bulk_assign_players_to_group`) and the status-list helpers
(`get_online_players_count`, `get_players_by_status`) were **removed** with the
archived Group subsystem.
--- ---
+217 -23
View File
@@ -63,40 +63,232 @@ flowchart LR
## 4. `docker-entrypoint.sh` ## 4. `docker-entrypoint.sh`
1. Create `/app/instance` and `/app/app/static/uploads`. 1. **Pin the database**: export `DATABASE_URL` (default `sqlite:////app/instance/dashboard.db`) so migrations and the app resolve to the *same* file. Required because the config classes default to different files (`dev.db` vs `dashboard.db`) and most migration scripts call `create_app()` without an argument (→ development config), while the app runs `create_app('production')`.
2. If `dashboard.db` is missing: create app + `db.create_all()`, then create/update the admin user from `ADMIN_USERNAME` / `ADMIN_PASSWORD`. 2. Create `/app/instance` and `/app/app/static/uploads`.
3. Start **Gunicorn**: `--bind 0.0.0.0:5000 --workers 4 --timeout 120 app.app:create_app('production')`. 3. Ensure schema + admin user — runs on **every** start (idempotent): `db.create_all()`, then create the admin from `ADMIN_USERNAME` / `ADMIN_PASSWORD` or refresh its password.
4. Run the **migration chain** (idempotent, ordered — table-creating migrations run before those that alter them):
```
add_https_config_table.py
add_player_user_table.py
add_email_to_https_config.py
migrate_player_user_global.py
add_url_to_content.py
add_original_filename_to_content.py
add_deployment_fields_to_player.py
```
Migrations are **non-fatal**: failures are logged as `⚠️ WARNING` and startup continues, so one bad migration can't strand the container in a restart loop.
5. Start **Gunicorn**: `--bind 0.0.0.0:5000 --workers 4 --timeout 120 app.app:create_app('production')`.
> Because migrations now run automatically on startup, `deploy.sh` step 4 is redundant (harmless — the scripts are idempotent).
### Data layout (bind mounts)
| Host path | Container path | Contents |
|---|---|---|
| `data/instance` | `/app/instance` | SQLite DB (`dashboard.db`), `player_build.json` |
| `data/uploads` | `/app/app/static/uploads` | Media files + `edited_media/` |
| `data/Caddyfile` | `/etc/caddy/Caddyfile` | Reverse-proxy config (**a file, not a directory**) |
| `data/caddy-data` | `/data` | Caddy state (instance UUID, certificates) |
| `data/caddy-config` | `/config` | Caddy autosave |
| `data/caddy-logs` | `/var/log/caddy` | Access logs |
> ⚠️ **`data/Caddyfile` must exist before `docker compose up`.** It is bind-mounted as a *file*;
> if it is missing, Docker creates a **directory** in its place and Caddy fails to start.
> `deploy.sh` seeds it from the version-controlled `Caddyfile.example`. For a manual start:
> ```
> mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs
> cp Caddyfile.example data/Caddyfile
> docker compose up -d --build
> ```
> ️ The legacy `data/nginx-*` and `data/certbot` folders are **obsolete** — the reverse proxy is
> Caddy. They are no longer created by `deploy.sh`.
### Clean start (wipe all runtime data)
`data/` is **gitignored**, so wiping it is irreversible. To reset to a pristine deployment:
```
docker compose down
docker rmi digiserver-v2-digiserver-app:latest # drop stale image
docker image prune -f && docker builder prune -a -f # reclaim build cache
rm -rf data # WIPES db, uploads, certs
mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs
cp Caddyfile.example data/Caddyfile
./deploy.sh
```
> ⚠️ Files under `data/caddy-*` are created **root-owned** by the Caddy container, so a plain
> `rm -rf data` may fail with *Permission denied*. Remove them via a helper container:
> ```
> docker run --rm -v "$PWD/data:/data" caddy:2-alpine sh -c 'rm -rf /data/caddy-config /data/caddy-data'
> ```
> Avoid `docker system prune --volumes` — this host also holds volumes for **other** projects.
--- ---
## 5. `deploy.sh` (One-Shot Deployment) ## 5. `deploy.sh` (One-Shot Deployment)
``` ```
1. Validate compose + project; create data/ subdirs; copy nginx configs 1. Detect compose: `docker compose` (plugin) or `docker-compose` (v1 fallback)
2. docker compose up -d + verify containers "Up" — stored in $COMPOSE and used for every subsequent call
3. Run migration scripts (add_https_config_table, add_player_user_table, 2. Create data/ subdirs (instance, uploads, caddy-data, caddy-config, caddy-logs)
and seed data/Caddyfile from Caddyfile.example
3. $COMPOSE up -d + verify containers "Up"
4. Run migration scripts (add_https_config_table, add_player_user_table,
add_email_to_https_config, migrate_player_user_global, add_email_to_https_config, migrate_player_user_global,
add_original_filename_to_content) add_original_filename_to_content)
4. Run /app/https_manager.py enable <hostname> <domain> <email> <ip> <port> ↳ NOTE: the container entrypoint already applies all seven on startup.
⚠ https_manager.py is NOT in the current repo — this step needs attention This step is idempotent and therefore redundant.
5. Verify DB tables via SQLAlchemy inspector 5. /app/https_manager.py enable <hostname> <domain> <email> <ip> <port>
6. caddy validate + https_manager.py status; print access URLs + default creds ↳ Exit code 2 ("config applied, Caddy not reloaded") is non-fatal.
6. Verify DB tables via SQLAlchemy inspector; caddy validate;
https_manager.py status; print access URLs + default creds
``` ```
### Configuration variables
| Variable | Default | Meaning |
|---|---|---|
| `HOSTNAME` | `digiserver` | Display hostname |
| `HTTPS_MODE` | `internal` | `internal` \| `acme` \| `off` |
| `DOMAIN` | *(empty)* | Required only when `HTTPS_MODE=acme` |
| `IP_ADDRESS` | **auto-detected** | Primary LAN IP (override if needed) |
| `EMAIL` | `admin@example.com` | ACME account email (unused by internal CA) |
| `PORT` | `8443` | Externally published HTTPS port |
> If `IP_ADDRESS` is unset, `deploy.sh` auto-detects it
> (`ip -4 route get 1.1.1.1``src` address, falling back to `hostname -I`).
> The old hard-coded defaults (`10.76.152.164`, a `.intra` domain) were wrong for
> most hosts and have been removed.
--- ---
## 6. HTTPS Setup (Caddy) ## 6. HTTPS Setup (Caddy)
HTTPS is configured through the **Admin → HTTPS Configuration** page, which: Three equivalent entry points drive the **same** code path
(`HTTPSConfig` + `CaddyConfigGenerator`) so CLI, env bootstrap and UI cannot diverge:
1. **Env bootstrap (deploy time)** — the container entrypoint runs
`python /app/https_manager.py bootstrap`, which reads `HOSTNAME_INTERNAL` and
`HOST_IP` from the environment and configures Caddy for HTTPS automatically.
2. **CLI**`python /app/https_manager.py enable … | verify | status | disable`
3. **UI***Admin → HTTPS Configuration* (reloads Caddy live; the ongoing source of truth)
### Addressing model — one HTTP endpoint, one HTTPS endpoint
| Port | What it does |
|---|---|
| **80** | Always answers. A catch-all `:80` block serves **any** Host header, plus explicit blocks for the IP and hostname so both work. |
| **443** | HTTPS for the same names, using the internal CA (or ACME for a public domain). |
If HTTPS is disabled or never configured, port 80 simply serves the app — there is
no separate "HTTP mode" to set.
> ⚠️ **`default_sni` is required for IP access.** Browsers send **no SNI** when the
> URL is an IP address (an IP is not a valid SNI hostname). Caddy then identifies the
> connection by the container's own internal IP and aborts the handshake with
> `no certificate available for '<container-ip>'`. To prevent this, the generator emits
> `default_sni <ip>` whenever internal-CA mode is used, so `https://<ip>` works in a
> plain browser. This was found by end-to-end testing — see
> `docs/tools/test_http_https_runtime.sh`.
### Mode selection
| Condition | Result |
|---|---|
| HTTPS off, or no IP/domain | Plain HTTP on port 80 |
| HTTPS on + `ip_address` / `hostname` | `tls internal` per name (no DNS, no ACME) |
| HTTPS on + `domain` set | Let's Encrypt for that name |
### Automatic fallback if HTTPS does not work
After applying a config, `https_manager.py` probes the HTTPS endpoint
(`/api/health`, certificate validation deliberately disabled). If the TLS listener
does not come up, the configuration is **automatically reverted to plain HTTP** so a
failed certificate can never make the site unreachable:
```
enable HTTPS → reload Caddy → probe https://<host>:<port>/api/health
├─ OK → keep HTTPS
└─ FAIL → revert to HTTP-only, log a warning
```
Disable the probe with `HTTPS_VERIFY=false` (or `enable --no-verify`).
Re-check at any time with `python /app/https_manager.py verify`.
### Deploy-time bootstrap via `.env`
Copy `.env.example``.env` and set the host address. `docker-compose.yml`
forwards these to the app container:
| Variable | Effect |
|---|---|
| `HOSTNAME_INTERNAL` | Hostname served (both HTTP and HTTPS) |
| `HOST_IP` | IP served and certified |
| `DOMAIN` | **Leave empty for an intranet name** → internal CA. Set only for Let's Encrypt |
| `SSL_EMAIL` | ACME contact (ignored by internal CA) |
| `HTTP_PORT` / `HTTPS_PORT` | Host ports mapped to Caddy's 80/443 (default `80`/`443`) |
| `HTTPS_HTTP_FALLBACK` | `true` (default) also serves plain HTTP; `false` redirects instead |
| `HTTPS_VERIFY` | `true` (default) probe HTTPS and auto-fall back on failure |
```bash
cp .env.example .env
# set HOSTNAME_INTERNAL and HOST_IP
docker compose up -d --build
```
> **If either `HOSTNAME_INTERNAL` or `HOST_IP` is missing the bootstrap is a no-op**
> the app starts on plain HTTP and stays reachable. HTTPS can then be enabled from
> *Admin → HTTPS Configuration*, which regenerates the Caddyfile and reloads Caddy
> **without a restart**.
### Who owns the config (env vs admin UI)
The admin UI is the **ongoing source of truth**. The bootstrap runs on every container
start, so it guards against silently overwriting an admin's change by tracking
provenance in `HTTPSConfig.updated_by`:
| Current config | Bootstrap behaviour |
|---|---|
| *(none — first deploy)* | Apply from env ✅ |
| Written by env/CLI (`updated_by='deploy.sh'`) | Apply from env ✅ — so editing `HOST_IP` and redeploying works |
| Written by a user (`updated_by='<username>'`) | **Skip** — the admin's setting is preserved |
So an admin change made in the UI survives restarts even while the env vars remain set.
### Why internal CA (and not Let's Encrypt) for `.intra`
An intranet name such as `digiserver.sibiusb.harting.intra` is **not resolvable from the
public internet**, so Let's Encrypt's HTTP-01/TLS-ALPN challenge cannot succeed. Setting
`DOMAIN=` empty makes Caddy sign the certificate itself with its local CA — no external
dependency at all.
**Trust caveat:** the internal CA is not in any client trust store, so browsers show a
warning and a Kivy player with `verify_ssl: true` will **reject the connection**. Options:
1. **Use HTTP** — port 80 always serves the app, so players need no trust configuration.
2. **Install the root CA** on each device:
```
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
```
### Ports
| Host → Container | Purpose |
|---|---|
| `80 → 80` | HTTP (always available) |
| `443 → 443` | HTTPS |
| `5000 → 5000` | Direct app access (bypasses Caddy; dev/testing) |
> Ports are configurable via `HTTP_PORT`/`HTTPS_PORT` so the stack also works where
> 80/443 are already taken (e.g. `HTTP_PORT=8080 HTTPS_PORT=8443`).
1. Saves `HTTPSConfig` (hostname, domain, IP, email, port, enabled).
2. `CaddyConfigGenerator.generate_caddyfile(config)` picks a template:
- **HTTP-only**`:80` reverse proxy
- **Domain** → automatic Let's Encrypt
- **IP** → internal CA self-signed
3. Writes `/etc/caddy/Caddyfile` and reloads Caddy via `POST http://caddy:2019/load`.
The current `data/Caddyfile` (HTTP mode) includes: admin API on `0.0.0.0:2019`, `:80` block → `digiserver-app:5000`, 2 GB body limit, gzip, security headers, access log.
--- ---
@@ -105,17 +297,19 @@ The current `data/Caddyfile` (HTTP mode) includes: admin API on `0.0.0.0:2019`,
Sections checked (pass/fail/warn counters): Sections checked (pass/fail/warn counters):
- git status - git status
- `.env` / `.env.example` - `.env` / `.env.example`
- Docker + Compose versions + `compose config` syntax - Docker + Compose versions (plugin **or** v1) + `compose config` syntax
- Dockerfile best practices (HEALTHCHECK, non-root, slim base) - Dockerfile best practices (HEALTHCHECK, non-root, slim base)
- `requirements.txt` critical packages + versions - `requirements.txt` critical packages + versions
- migrations directory - migrations directory
- **SSL cert expiry** (openssl) - **TLS certificate** — Caddy internal CA expiry (`data/caddy-data/caddy/pki/authorities/local/root.crt`)
- Flask config (`ProductionConfig`, `SESSION_COOKIE_SECURE`) - Flask config (`ProductionConfig`, `SESSION_COOKIE_SECURE`)
- nginx.conf checks - `data/Caddyfile` checks (reverse_proxy, admin API, TLS mode)
- runtime container health - runtime container health + live HTTP/HTTPS endpoint probes
- security best practices - security best practices
> ⚠ Note: the script still references `docker-compose` (v1) and `digiserver-nginx` — the current stack uses Compose v2 + Caddy. > The script now detects `docker compose` (plugin) or `docker-compose` (v1) and warns when
> buildx is too old for compose v1 builds — matching `deploy.sh`, which then falls back to
> `docker build`.
--- ---
+9 -6
View File
@@ -33,21 +33,24 @@ add_original_filename_to_content.py
| Component | Status | Notes | | Component | Status | Notes |
|---|---|---| |---|---|---|
| `app/blueprints/content_old.py` | **Dead code** | Legacy per-player content routes (`/`, `/upload`, `/<id>/edit`, `/bulk/delete`, `/upload-progress`, `/preview`, `/statistics`, `/check-duplicates`, `/<id>/groups`). Not imported by `create_app`. | | `app/blueprints/content_old.py` | **DELETED** | Legacy per-player content routes. Removed in the sanitization pass together with its templates (`content_list.html`, `edit_content.html`, `upload_content.html`). |
| `app/blueprints/playlist.py` | **Active but legacy** | Per-player playlist routes kept as redirects to the modern content workflow. | | `app/blueprints/playlist.py` | **Active but legacy** | Per-player playlist routes kept as redirects to the modern content workflow. |
| `Group` model + group routes | **Archived** | `Player` no longer has `group_id`; group routes commented out; `group_player_management.py` and `group_content` association remain for reference. | | `Group` model + group routes | **DELETED** | `models/group.py`, the `group_content` association, `Content.groups` / `Content.group_count`, and the group utility functions were removed. `Player` never had a `group_id` column. |
| `nginx` stack | **Replaced by Caddy** | `data/nginx.conf`, `data/nginx-custom-domains.conf`, `data/nginx-logs/`, `data/nginx-ssl/` retained. `utils/nginx_config_reader.py` still parses it. | | `utils/nginx_config_reader.py` | **DELETED** | Legacy nginx parsing — the reverse proxy is Caddy. |
| `nginx` stack | **Replaced by Caddy** | `data/nginx.conf`, `data/nginx-custom-domains.conf`, `data/nginx-logs/`, `data/nginx-ssl/` removed with the Caddy migration. `migrate_network.sh` no longer generates self-signed certs — Caddy issues them. |
| `https_manager.py` | **Missing** | Referenced by `deploy.sh` but not in repo — likely merged into `CaddyConfigGenerator`. | | `https_manager.py` | **Missing** | Referenced by `deploy.sh` but not in repo — likely merged into `CaddyConfigGenerator`. |
| `old_code_documentation/` | **Archive** | Full legacy docs, old scripts (`blueprint_groups.py`, `add_muted_column.py`, `fix_player_user_schema.py`, `test_edit_media_*.py`, `check_fix_player.py`, `migrate_add_edit_enabled.py`), deployment guides, HTTPS analysis, player analysis. | | `old_code_documentation/` | **Archive** | Full legacy docs, old scripts (`blueprint_groups.py`, `add_muted_column.py`, `fix_player_user_schema.py`, `test_edit_media_*.py`, `check_fix_player.py`, `migrate_add_edit_enabled.py`), deployment guides, HTTPS analysis, player analysis. |
| `QUICK_DEPLOYMENT.md`, `deployment-commands-reference.sh` | **Active reference** | Manual deployment notes. | | `QUICK_DEPLOYMENT.md`, `deployment-commands-reference.sh` | **Active reference** | Manual deployment notes. |
| `docs/legacy code/` | **Snapshot** | Full pre-sanitization copy of the codebase. Excluded from the Docker build via `.dockerignore`. |
--- ---
## 3. Recommended Cleanup (optional) ## 3. Recommended Cleanup (optional)
- Remove `content_old.py` and `old_code_documentation/*.py` scripts that are no longer needed (keep the `.md` docs). - Remove `old_code_documentation/*.py` scripts that are no longer needed (keep the `.md` docs).
- Resolve the missing `https_manager.py` in `deploy.sh` (use `CaddyConfigGenerator` equivalents). - Resolve the missing `https_manager.py` in `deploy.sh` **done**: `https_manager.py` now exists at the repo root.
- Update `verify-deployment.sh` to reference Compose v2 and Caddy instead of `docker-compose`/nginx. - Update `verify-deployment.sh` to reference Caddy instead of nginx — **done**.
- Consider removing the legacy `playlist.py` blueprint (see [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md) batch B1).
--- ---
+1 -2
View File
@@ -51,7 +51,7 @@ graphify-out/
| Application type | Digital signage content/playlist/player management | | Application type | Digital signage content/playlist/player management |
| Graph size | **631 nodes · 1162 edges · 41 communities** | | Graph size | **631 nodes · 1162 edges · 41 communities** |
| Blueprints | 7 active (`main`, `auth`, `admin`, `players`, `content`, `playlist`, `api`) | | Blueprints | 7 active (`main`, `auth`, `admin`, `players`, `content`, `playlist`, `api`) |
| Database tables | 10 (`user`, `player`, `player_edit`, `player_feedback`, `player_user`, `content`, `group`, `playlist`, `server_log`, `https_config`) | | Database tables | 9 (`user`, `player`, `player_edit`, `player_feedback`, `player_user`, `content`, `playlist`, `server_log`, `https_config`) |
| Reverse proxy | Caddy 2 (automatic HTTPS / Let's Encrypt) | | Reverse proxy | Caddy 2 (automatic HTTPS / Let's Encrypt) |
| Deployment | Docker Compose (app + Caddy) + remote SSH player provisioning | | Deployment | Docker Compose (app + Caddy) + remote SSH player provisioning |
| Key externals | LibreOffice, Poppler (pdf2image), FFmpeg, sshpass/rsync | | Key externals | LibreOffice, Poppler (pdf2image), FFmpeg, sshpass/rsync |
@@ -100,7 +100,6 @@ erDiagram
content ||--o{ player_edit : "cascade" content ||--o{ player_edit : "cascade"
content ||--o{ player_feedback : "" content ||--o{ player_feedback : ""
playlist ||--o{ content : "playlist_content (M2M)" playlist ||--o{ content : "playlist_content (M2M)"
content }o--o{ group : "group_content (M2M)"
player_user ||--o{ player_edit : "user_code" player_user ||--o{ player_edit : "user_code"
https_config ||--|| https_config : "single row config" https_config ||--|| https_config : "single row config"
``` ```
+206
View File
@@ -0,0 +1,206 @@
# DigiServer v2 — Code Sanitization Review
**Generated:** 2026-09-10
**Snapshot:** `docs/legacy code/` (1.7 MB, 171 files, 49 Python files) — full restore point.
Analysed **42 Python files / 240 functions / 104 routes / 32 templates** under `app/` and `migrations/`.
> Review each section below and reply with the IDs you want deleted (e.g. `A1, A2, B1`).
> Nothing is deleted until you confirm. Everything is recoverable from `docs/legacy code/`.
---
## ✅ Status — Applied 2026-09-10
**Removed (A1, A2, D1, D2):**
| ID | Removed | Notes |
|---|---|---|
| A1 | `app/blueprints/content_old.py` | + 3 templates orphaned by its removal |
| A2 | `app/utils/nginx_config_reader.py` | |
| D1 | 5 group functions + their `__init__.py` exports | `get_player_status_info` kept (live) |
| D2 | `app/models/group.py`, `Content.groups`, `Content.group_count` | |
**Extra cleanup triggered by A1/D2:**
- Deleted orphaned templates: `upload_content.html` (278), `edit_content.html` (11)
- Removed the dead `groups` key from `/api/system-info` and `group_count` from `/api/content`
- Removed the `'group_id': getattr(player, 'group_id', None)` compat shim from `/api/player-status`
- Updated 8 documentation files
**Also removed (B1, B2) — applied in a second pass:**
| ID | Removed | Notes |
|---|---|---|
| B1 | `app/blueprints/playlist.py` (310 LOC) + its registration in `app.py` | Whole legacy blueprint; its only real route redirected to `content.manage_playlist_content` |
| B2 | 3 routes in `players.py`: `reorder_content`, `reorder_playlist`, `remove_from_playlist` (~103 LOC) | Two queried nonexistent columns (`Content.player_id`, `Content.position`, `Player.playlist_version`) → guaranteed 500s |
**Extra cleanup triggered by B1:**
- Deleted `content_list.html` (202 lines) — its only remaining reference was `url_for('playlist.manage_playlist')`. It was **already orphaned** (no Python file rendered it) after `content_old.py` was deleted, so it has been removed for real this time.
- Deleted `players/player_page.html` (227 lines) — it was **never rendered** by any view (the `players.player_page` route redirects to `manage_player`), so it was dead UI. Corrects the earlier "false positive" note below.
> ⚠️ **Functional note:** `players.regenerate_auth_code` (`POST /players/<id>/regenerate-auth`) is now
> referenced by **no template**`player_page.html` was its only caller. The endpoint still works if
> invoked directly. The equivalent control lives in `manage_player.html` via the quickconnect flow.
> Restore `player_page.html` from `docs/legacy code/` if you want that button back.
**Result (A + B + D combined):**
```
42 → 38 Python modules 9,311 → 8,296 LOC
104 → 82 routes 32 → 28 templates
7 → 6 blueprints dead modules: 2 → 0, orphan templates: 0
```
**Verified:** all files compile; smoke test passes on a fresh DB (every API endpoint + key UI route returns
< 500); `db.metadata` no longer registers `group`/`group_content`; app boots against a migrated copy of the
real `dashboard.db` with all data intact; `app.blueprints` no longer contains `playlist`.
> ⚠️ **Still open:** the real `data/instance/dashboard.db` predates the `original_filename` migration.
> On startup the entrypoint now applies it automatically. To fix locally, run:
> ```
> DATABASE_URL=sqlite:///$PWD/data/instance/dashboard.db ./.venv/bin/python migrations/add_original_filename_to_content.py
> ```
---
## Section A — Dead modules (zero importers)
| ID | Target | LOC | Evidence | Risk |
|---|---|---|---|---|
| **A1** | `app/blueprints/content_old.py` | 500 | `app.py` imports `content.py`; this file's `content_bp` is **never registered**. Superseded "old" content workflow. | **Low** |
| **A2** | `app/utils/nginx_config_reader.py` | 120 | Never imported anywhere. Stack migrated nginx → Caddy, so the reader is obsolete. | **Low** |
```mermaid
graph LR
app_py["app.py<br/>register_blueprints()"] --> content["content.py<br/>content_bp ✅ ACTIVE"]
content_old["content_old.py<br/>content_bp ❌ DEAD"] -.->|never imported| x1[" "]
nginx["nginx_config_reader.py<br/>❌ DEAD"] -.->|never imported| x2[" "]
caddy["caddy_manager.py<br/>✅ ACTIVE"] --> app_py
style content_old fill:#7f1d1d,color:#fff
style nginx fill:#7f1d1d,color:#fff
```
---
## Section B — Legacy duplicate route surface — ✅ **REMOVED**
Two blueprints exposed **parallel implementations of the same operations**. Only the `content.*`
versions were wired to the UI; the legacy ones had no template references.
| ID | Target | LOC | Evidence | Risk |
|---|---|---|---|---|
| **B1** ✅ | `app/blueprints/playlist.py` (whole file + registration) | 310 | Entire file was legacy per-player playlist. Its `manage_playlist` route did nothing but **redirect** to the modern `content.manage_playlist_content`. The other 6 routes had **no template reference**. | **LowMed** |
| **B2** ✅ | 3 routes in `players.py`: `reorder_content`, `reorder_playlist`, `remove_from_playlist` | ~103 | Superseded by `content.*`. Two queried nonexistent columns (`Content.player_id`, `.position`, `Player.playlist_version`) → guaranteed HTTP 500 if called. | **Low** |
**Duplicate operation matrix**
| Operation | Modern (LIVE) | Legacy (DEAD) |
|---|---|---|
| Add content | `content.add_content_to_playlist` | `playlist.add_to_playlist` |
| Remove content | `content.remove_content_from_playlist` | `playlist.remove_from_playlist`, `players.remove_from_playlist` ⚠️broken |
| Reorder | `content.reorder_playlist_content` | `playlist.reorder_playlist`, `players.reorder_content` ⚠️broken, `players.reorder_playlist` ⚠️broken |
| Set duration | `content.update_playlist_content_duration` | `playlist.update_duration` |
| Mute audio | `content.update_playlist_content_muted` | `playlist.update_muted` |
| Toggle edit | `content.update_playlist_content_edit_enabled` | — |
| Clear | — | `playlist.clear_playlist` |
---
## Section C — Broken code: references to columns that do not exist
Confirmed against the live DB schema. These raised `AttributeError`/`OperationalError` at runtime.
**All resolved by deleting A1 + B2.**
| ID | Location | Broken reference | Reachable? |
|---|---|---|---|
| **C1** ✅ | `players.py:768,775` (`remove_from_playlist`) | `player.playlist_version` | Via route only (no UI link) |
| **C2** ✅ | `players.py:~700` (`reorder_playlist`) | `Content.player_id`, `Content.position` | Via route only (no UI link) |
| **C3** | `content_old.py:189-190` | `player.playlist_version` | No (dead module) |
| **C4** | `content_old.py:50,57` | `content.player_id`, `player.group` | No (dead module) |
> ✅ **Resolved by deleting A1 + B2.** If you keep those files, they must be rewritten.
**Not a bug (verified by hand):** `Content._playlist_duration` /
`._playlist_position` / `._playlist_muted` in `api.py` are set **dynamically** by
`Playlist.get_content_ordered()`. These are intentional and work correctly — the
static analyzer flags them because it only sees model class attributes.
---
## Section D — Legacy groups subsystem
Groups are fully deprecated (0 rows, `/api/groups` already commented out), but the code lingers.
| ID | Target | LOC | Evidence | Risk |
|---|---|---|---|---|
| **D1** | 5 group functions in `app/utils/group_player_management.py`: `get_group_statistics`, `assign_player_to_group`, `bulk_assign_players_to_group`, `get_online_players_count`, `get_players_by_status` + their `__init__.py` exports | ~130 | **Zero callers outside the module.** All three group functions reference `player.group_id`, **which does not exist** → broken. | **Low** |
| **D2** | `app/models/group.py` (Group model + `group_content` table) | 71 | Retained only because `Content.groups` FK relationship and `Content.group_count` reference it. Requires touching the Content model. | **Medium** |
> ⚠️ **Keep:** `get_player_status_info()` (top of the same file) is **live** — used at
> `players.py:28` and `players.py:432`. Only the group functions should go.
---
## Section E — Legacy redirect stubs
Thin compatibility shims that only redirect to the modern UI. They're harmless but keep dead
URL surface alive.
| ID | Target | Evidence |
|---|---|---|
| **E1** | `players.player_page` (`/players/<id>`) | Body is a single `redirect(url_for('players.manage_player'))`. Still `url_for`-referenced by 2 templates, so keeping it is fine. |
| **E2** | `playlist.manage_playlist` (`/playlist/<id>`) | Part of B1 — already covered by deleting that file. |
---
## Section F — Orphan templates / assets
| ID | Target | LOC | Evidence |
|---|---|---|---|
| — | (none) | — | After the B1/B2 pass the codebase has **0 orphan templates**. |
---
## Recommended batches — ✅ **ALL APPLIED**
| Batch | Contents | Total removed | Status |
|---|---|---|---|
| **Batch 1 — Safe clean** | **A1, A2** | ~620 LOC | ✅ done |
| **Batch 2 — Legacy playlist** | **B1, B2** | ~413 LOC | ✅ done |
| **Batch 3 — Groups** | **D1** | ~130 LOC | ✅ done |
| **Batch 4 — Group model** | **D2** | ~71 LOC | ✅ done |
**Every batch was followed by:** compile-all + app-factory boot + route smoke test, so hidden
dependencies were caught before moving on.
---
## How I verified this (so you can trust it)
| Check | Method |
|---|---|
| Module reachability | AST import extraction + `register_blueprint` cross-reference |
| Route usage | `url_for('endpoint')` **and** literal path matching against all templates/JS |
| Broken columns | Compared every `var.attr` access against live SQLite `PRAGMA table_info` |
| Dynamic attributes | Manually inspected `get_content_ordered()` to rule out false positives |
| Duplicate bodies | `ast.dump` body hashing across all functions (result: 0 exact duplicates) |
**Reproduce anytime:**
```
./.venv/bin/python docs/tools/sanitize_report.py # dead code + broken refs
./.venv/bin/python docs/tools/sanitize_audit.py # full function inventory
./.venv/bin/python docs/tools/sanitize_templates.py # orphan templates
./.venv/bin/python docs/tools/smoke_test.py # post-change smoke test
```
---
## ⚠️ Rollback / hygiene notes
1. **`docs/legacy code/` is excluded from the Docker image** (via `legacy code/` and
`**/legacy code/` in `.dockerignore`) so it never
ships to production or bloats the build.
2. It is **not** git-ignored, so it will appear in `git status`. Decide:
- commit it as a recovery point, or
- add `legacy code/` to `.gitignore` if you'd rather rely on git history.
3. Deleting files listed here is **not** recoverable from git unless committed first — the
`docs/legacy code/` copy is your safety net.

Some files were not shown because too many files have changed in this diff Show More