Compare commits

39 Commits

Author SHA1 Message Date
Quality App Developer
a4262da7c9 chore: fix file permissions and ownership across project
- Changed ownership of all files to scheianu:scheianu
- Set directories to 755 permissions (rwxr-xr-x)
- Set files to 644 permissions (rw-r--r--)
- Made shell scripts executable (755)
- Allows development without requiring sudo for file modifications
- Improves development workflow and security
2026-01-15 22:39:51 +02:00
Quality App Developer
024430754c docs: add comprehensive quick deployment guide
- Complete deployment workflow documentation
- Container architecture explanation
- Step-by-step deployment process
- Common commands reference
- Troubleshooting section
- SSL certificate management
- Performance tuning guide
- Security checklist
- Health checks information
- Database backup/restore procedures
2026-01-15 22:31:37 +02:00
Quality App Developer
d17ed79e29 chore: remove caddy-related and obsolete files
Removed:
- Caddyfile: Caddy reverse proxy config (replaced by nginx.conf)
- setup_https.sh: Caddy HTTPS setup script
- https_manager.py: Caddy HTTPS management utility
- HTTPS_STATUS.txt: Old HTTPS documentation
- docker-compose.http.yml: HTTP-only Caddy compose file
- player_auth_module.py: Old authentication module (unused)
- player_config_template.ini: Old player config template (unused)
- test connection.txr: Test file

Updated:
- init-data.sh: Removed references to deleted caddy/obsolete files
- .dockerignore: Removed obsolete ignore entries

This completes the Caddy → Nginx migration cleanup.
2026-01-15 22:25:13 +02:00
root
21eb63659a feat: complete nginx migration from caddy
- Replace Caddy reverse proxy with Nginx (nginx:alpine)
- Add nginx.conf with HTTP/HTTPS, gzip, and proxy settings
- Add nginx-custom-domains.conf template for custom domains
- Update docker-compose.yml to use Nginx service
- Add ProxyFix middleware to Flask app for proper header handling
- Create nginx_config_reader.py utility to read Nginx configuration
- Update admin blueprint to display Nginx status in https_config page
- Add Nginx configuration display to https_config.html template
- Generate self-signed SSL certificates for localhost
- Add utility scripts: generate_nginx_certs.sh
- Add documentation: NGINX_SETUP_QUICK.md, PROXY_FIX_SETUP.md
- All containers now running, HTTPS working, HTTP redirects to HTTPS
- Session cookies marked as Secure
- Security headers properly configured
2026-01-15 22:15:11 +02:00
root
2ea24a98cd feat: add real-time HTTPS status detection on admin config page
- Detect actual HTTPS status from current request (scheme + X-Forwarded-Proto)
- Auto-sync database when HTTPS status mismatch is detected
- Show real-time connection info (protocol, host, port)
- Display helpful message when accessing via HTTPS but config shows disabled
- Add CSS styling for detection status display
- Users now see accurate HTTPS status even if database wasn't in sync
2026-01-14 21:22:22 +02:00
root
2f0e9ffdf9 feat: include migrations and utility scripts in data folder
- Copy migrations/ folder for database setup
- Copy https_manager.py for HTTPS configuration
- Copy player_auth_module.py and fix_player_user_schema.py
- All necessary files now centralized in ./data for complete deployments
2026-01-14 21:16:50 +02:00
root
b7afa9736b fix: correct data folder mount structure for Python imports
- Mount ./data:/app instead of ./data/app:/app to preserve directory nesting
- This allows Python imports like 'from app.config' to work correctly
- data/app/ now contains the Python package (app.py, config.py, etc.)
- Verified containers start healthy with proper module resolution
2026-01-14 21:15:34 +02:00
root
c879bbaed0 chore: consolidate all persistent data into ./data folder
- Update docker-compose.yml to mount Caddyfile from ./data
- Remove Dockerfile directory creation - handled by init-data.sh
- Add init-data.sh script to initialize ./data with all required files
- Add DATA_DEPLOYMENT.md documentation for deployment workflow
- Update .gitignore to exclude ./data folder
- All persistent data (app, config, database, uploads) now centralized in ./data
2026-01-14 21:07:57 +02:00
root
a39dbdd613 chore: reorganize persistent data into ./data folder
- Move all persistent data (instance, uploads, caddy configs) to ./data folder
- Update docker-compose.yml volumes to use ./data structure
- All data is now centralized for easier backup and portability
- Simplify volume management by removing named volumes
- Add data folder to .gitignore
2026-01-14 20:55:58 +02:00
Quality App Developer
cedb411536 feat: Complete HTTPS multi-endpoint configuration and deployment automation
- Enhanced Caddyfile with consolidated HTTPS block supporting all access points
- Added support for https://digiserver, https://10.76.152.164, and https://digiserver.sibiusb.harting.intra
- Configured Caddy reverse proxy with HTTP/3 (QUIC), TLS 1.3+, and HTTP/2 support
- Implemented security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection)
- Added HTTP to HTTPS automatic redirects for all endpoints
- Enhanced setup_https.sh with improved error handling and progress reporting
- Created deploy.sh for fully automated one-command deployment
- Added comprehensive deployment documentation (5 guides)
- Configured 2GB file upload limit and 300s request/response timeouts
- Added Caddy admin API on port 2019 for configuration management
- Implemented health checks and container dependency management
- All volumes persistent and properly isolated
- Production-ready configuration with environment variable parameterization
2026-01-14 20:40:26 +02:00
Quality App Developer
361e0bc459 Update TLS configuration for IP address access
- Switch IP address HTTPS to use on_demand TLS mode
- Allows proper certificate generation for IP-based access
- Maintains self-signed certificates for internal use
2026-01-14 15:08:54 +02:00
Quality App Developer
1e08fa45a1 Add HTTPS support with self-signed certificates for internal domain
- Add HTTPS blocks for digiserver.sibiusb.harting.intra with internal TLS
- Add HTTPS support for IP address 10.76.152.164
- Add catch-all HTTPS handler with self-signed certificate
- Fixes connection refused error on port 443
2026-01-14 14:28:16 +02:00
Quality App Developer
48f1bfbcad Add HTTPS configuration management system
- Add HTTPSConfig model for managing HTTPS settings
- Add admin routes for HTTPS configuration management
- Add beautiful admin template for HTTPS configuration
- Add database migration for https_config table
- Add CLI utility for HTTPS management
- Add setup script for automated configuration
- Add Caddy configuration generator and manager
- Add comprehensive documentation (3 guides)
- Add HTTPS Configuration card to admin dashboard
- Implement input validation and security features
- Add admin-only access control with audit trail
- Add real-time configuration preview
- Integrate with existing Caddy reverse proxy

Features:
- Enable/disable HTTPS from web interface
- Configure domain, hostname, IP address, port
- Automatic SSL certificate management via Let's Encrypt
- Real-time Caddyfile generation and reload
- Full audit trail with admin username and timestamps
- Support for HTTPS and HTTP fallback access points
- Beautiful, mobile-responsive UI

Modified files:
- app/models/__init__.py (added HTTPSConfig import)
- app/blueprints/admin.py (added HTTPS routes)
- app/templates/admin/admin.html (added HTTPS card)
- docker-compose.yml (added Caddyfile mount and admin port)

New files:
- app/models/https_config.py
- app/blueprints/https_config.html
- app/utils/caddy_manager.py
- https_manager.py
- setup_https.sh
- migrations/add_https_config_table.py
- migrations/add_email_to_https_config.py
- HTTPS_STATUS.txt
- Documentation files (3 markdown guides)
2026-01-14 12:02:49 +02:00
Quality App Developer
ef17abfe6b Fix HTTPS configuration: use service hostname for networking and add HTTP-only compose file
- Updated service name from 'digiserver' to 'digiserver-app' in docker-compose.yml for proper Docker network DNS resolution
- Fixed Caddyfile to reference correct service hostname 'digiserver-app:5000'
- Changed port mapping from 'ports' to 'expose' for internal-only access
- Added docker-compose.http.yml for HTTP-only deployment on port 80 (development/testing)
- Both Flask app and Caddy now communicate correctly over internal Docker network
- App now accessible at https://localhost or https://your-domain.com on port 443
2026-01-13 15:20:25 +02:00
Quality App Developer
fc4c8a7474 Enable Caddy reverse proxy with automatic HTTPS on port 443 2026-01-13 14:28:07 +02:00
DigiServer Developer
3829d98e91 Implement editing users management and permission updates
- Added auto-creation of PlayerUser records from player metadata (user_card_data)
- Fixed player_user table schema (removed player_id, made user_code unique globally)
- Created admin page for managing editing users (view, update names, delete)
- Updated permissions: normal users can now access admin panel, editing users, and leftover media
- Admin-only access: user management, system dependencies, logo customization
- Fixed edited media workflow to preserve original files
- Content.filename now points to edited_media folder, keeping originals intact
- Added user display names in edited media page (shows name if set, code otherwise)
- Fixed leftover media file size calculation (handle None values)
- Split editing users into separate card on admin panel with description
2025-12-14 14:14:04 +02:00
DigiServer Developer
88e24f8fec sincronized the adding of the edit user 2025-12-13 22:06:58 +02:00
DigiServer Developer
87709bab4d updated to get card name 2025-12-13 21:51:45 +02:00
DigiServer Admin
0dfeb0ef7f modified 2025-12-12 15:52:04 +02:00
DigiServer Admin
4a9616a0f7 Add HTTPS support with Caddy and clean up legacy files
- Add Caddy reverse proxy for automatic HTTPS with Let's Encrypt
- Update docker-compose.yml with Caddy service and internal networking
- Remove all Redis dependencies (not needed for this deployment)
- Fix Dockerfile permissions for instance and uploads directories
- Move legacy scripts to old_code_documentation folder
  - add_muted_column.py, check_fix_player.py, migrate_add_edit_enabled.py
  - docker-start.sh, run_dev.sh, start.sh, clean_for_deployment.sh
- Add HTTPS_SETUP.md documentation for Caddy configuration
- Update .env.example with DOMAIN and EMAIL variables
- Remove redis package from requirements.txt
- Remove rate limiting Redis storage from config.py
2025-12-11 16:56:44 +02:00
DigiServer Developer
328edebe3c Add comprehensive edited media management with expandable cards UI
- Optimized delete modal for light/dark modes with modern gradients
- Added edit_count tracking to media library with warnings in delete confirmation
- Enhanced PlayerEdit model with CASCADE delete on foreign keys
- Improved player management page to show latest 3 edited files with image previews
- Created new edited_media route and template with full version history
- Implemented horizontal expandable cards with two-column layout
- Added interactive version selection with thumbnail grid
- Included original file in versions list with source badge
- Fixed deletion workflow to clean up PlayerEdit records and edited_media folders
- Enhanced UI with smooth animations, hover effects, and dark mode support
2025-12-06 19:17:48 +02:00
DigiServer Developer
ff14e8defb moved some files 2025-12-06 00:09:04 +02:00
DigiServer Developer
8d52c0338f Add player media editing feature with versioning
- Added PlayerEdit model to track edited media history
- Created /api/player-edit-media endpoint for receiving edited files from players
- Implemented versioned storage: edited_media/<content_id>/<filename_vN.ext>
- Automatic playlist update when edited media is received
- Updated content.filename to reference versioned file in playlist
- Added 'Edited Media on the Player' card to player management page
- UI shows version history grouped by original file
- Each edit preserves previous versions in archive folder
- Includes dark mode support for new UI elements
- Documentation: PLAYER_EDIT_MEDIA_API.md
2025-12-06 00:06:11 +02:00
DigiServer Developer
3921a09c4e Fix production config: use simple cache instead of Redis, fix Gunicorn to use production config 2025-12-05 21:46:31 +02:00
DigiServer Developer
8e43f2bd42 Add edit_on_player_enabled feature for playlist content
- Added edit_on_player_enabled column to playlist_content table
- Updated playlist model to track edit enablement per content item
- Added UI checkbox on upload media page to enable/disable editing
- Added toggle column on manage playlist page for existing content
- Updated API endpoint to return edit_on_player_enabled flag to players
- Fixed docker-entrypoint.sh to use production config
- Supports PDF, Images, and PPTX content types
2025-12-05 21:31:04 +02:00
d395240dce Apply timezone fix to all templates - convert UTC to local time 2025-11-28 15:51:10 +02:00
1fce23d3fd Fix timezone display: Convert UTC to local time in players list 2025-11-28 15:22:42 +02:00
610227457c Fix player status display on players list page 2025-11-28 15:12:36 +02:00
DigiServer Developer
f88e332186 updated playlis version 2025-11-26 20:01:16 +02:00
38929da929 Add emoji font support to Docker image for Raspberry Pi deployment 2025-11-25 08:20:46 +02:00
DigiServer Developer
b1dbacc679 updated upload page 2025-11-24 23:01:16 +02:00
DigiServer Developer
69562fbf22 updated to fullscreen view ppt 2025-11-24 22:28:44 +02:00
DigiServer Developer
561b364022 updated view for mobil optimization 2025-11-22 18:43:27 +02:00
DigiServer Developer
b73e10cde7 updated for beter media management 2025-11-22 18:24:40 +02:00
DigiServer Developer
f4df930d82 updated to delete player and edit fields 2025-11-21 22:51:28 +02:00
DigiServer Developer
a2281e90e7 updated view and playlist management 2025-11-20 20:46:09 +02:00
DigiServer Developer
78c83579ee doker updated jor libre install 2025-11-20 19:59:49 +02:00
DigiServer Developer
efb63f2b3f updating libre install 2025-11-20 19:44:07 +02:00
DigiServer Developer
4d411b645d fixed login 2025-11-17 23:12:53 +02:00
81 changed files with 8947 additions and 511 deletions

View File

@@ -52,6 +52,4 @@ PLAYER_AUTH.md
PROGRESS.md
README.md
# Config templates
player_config_template.ini
player_auth_module.py

7
.gitignore vendored
View File

@@ -13,6 +13,9 @@ ENV/
instance/
.webassets-cache
# Persistent data folder (containers, database, uploads)
data/
# IDEs
.vscode/
.idea/
@@ -52,3 +55,7 @@ htmlcov/
dist/
build/
*.egg-info/
#data
data/

View File

@@ -4,15 +4,19 @@ FROM python:3.13-slim
# Set working directory
WORKDIR /app
# Install system dependencies
# Note: LibreOffice is excluded from the base image to reduce size (~500MB)
# It can be installed on-demand via the Admin Panel → System Dependencies
RUN apt-get update && apt-get install -y \
# Install system dependencies including LibreOffice for PPTX conversion
RUN apt-get update && \
apt-get install -y --no-install-recommends \
poppler-utils \
ffmpeg \
libmagic1 \
sudo \
&& rm -rf /var/lib/apt/lists/*
fonts-noto-color-emoji \
libreoffice-core \
libreoffice-impress \
libreoffice-writer \
&& apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
@@ -27,9 +31,6 @@ COPY . .
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Create directories for uploads and database
RUN mkdir -p app/static/uploads instance
# Set environment variables
ENV FLASK_APP=app.app:create_app
ENV PYTHONUNBUFFERED=1
@@ -41,6 +42,8 @@ EXPOSE 5000
# Create a non-root user and grant sudo access for dependency installation
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app /docker-entrypoint.sh && \
echo "Defaults:appuser !requiretty, !use_pty" >> /etc/sudoers && \
echo "appuser ALL=(ALL) NOPASSWD: /usr/bin/apt-get" >> /etc/sudoers && \
echo "appuser ALL=(ALL) NOPASSWD: /app/install_libreoffice.sh" >> /etc/sudoers && \
echo "appuser ALL=(ALL) NOPASSWD: /app/install_emoji_fonts.sh" >> /etc/sudoers && \
chmod +x /app/install_libreoffice.sh /app/install_emoji_fonts.sh

487
QUICK_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,487 @@
# DigiServer v2 - Quick Deployment Guide
## 📋 Overview
DigiServer is deployed using Docker Compose with the following architecture:
```
Internet (User)
Nginx Reverse Proxy (Port 80/443)
Internal Docker Network
Flask App (Gunicorn on Port 5000)
SQLite Database
```
---
## 🚀 Quick Start (One Command)
```bash
cd /srv/digiserver-v2
bash deploy.sh
```
This will:
1. ✅ Start Docker containers
2. ✅ Initialize database
3. ✅ Run migrations
4. ✅ Configure HTTPS
5. ✅ Display access information
---
## 📦 Container Architecture
### **Container 1: digiserver-app (Flask)**
- **Image**: Built from Dockerfile (Python 3.13)
- **Port**: 5000 (internal only)
- **Volumes**:
- `./data:/app` - Persistent application data
- `./data/instance:/app/instance` - Database & configuration
- `./data/uploads:/app/app/static/uploads` - User uploads
- **Startup**: Automatically initializes database on first run
- **Health Check**: Every 30 seconds
### **Container 2: nginx (Reverse Proxy)**
- **Image**: nginx:alpine
- **Ports**: 80 & 443 (exposed to internet)
- **Volumes**:
- `nginx.conf` - Main configuration
- `./data/nginx-ssl/` - SSL certificates
- `./data/nginx-logs/` - Access/error logs
- `./data/certbot/` - Let's Encrypt challenges
- **Startup**: Waits for Flask app to start
- **Health Check**: Every 30 seconds
---
## 🔧 Deployment Steps Explained
### **Step 1: Start Containers**
```bash
docker-compose up -d
```
- Builds Flask app image (if needed)
- Starts both containers
- Waits for containers to be healthy
### **Step 2: Database Initialization** (docker-entrypoint.sh)
When Flask container starts:
1. Create required directories
2. Check if database exists
3. If NOT exists:
- Initialize SQLite database (dashboard.db)
- Create admin user from environment variables
4. Start Gunicorn server (4 workers, 120s timeout)
### **Step 3: Run Migrations**
```bash
docker-compose exec -T digiserver-app python /app/migrations/[migration_name].py
```
Applied migrations:
- `add_https_config_table.py` - HTTPS settings
- `add_player_user_table.py` - Player user management
- `add_email_to_https_config.py` - Email configuration
- `migrate_player_user_global.py` - Global settings
### **Step 4: Configure HTTPS**
- SSL certificates stored in `./data/nginx-ssl/`
- Pre-generated self-signed certs for development
- Ready for Let's Encrypt integration
### **Step 5: Reverse Proxy Routing** (nginx.conf)
```
HTTP (80):
• Redirect all traffic to HTTPS
• Allow ACME challenges for Let's Encrypt
HTTPS (443):
• TLS 1.2+, HTTP/2 enabled
• Proxy all requests to Flask app
• Security headers added
• Gzip compression enabled
• Max upload size: 2GB
• Proxy timeout: 300s
```
### **Step 6: ProxyFix Middleware** (app/app.py)
Extracts real client information from Nginx headers:
- `X-Forwarded-For` → Real client IP
- `X-Forwarded-Proto` → Protocol (http/https)
- `X-Forwarded-Host` → Original hostname
- `X-Forwarded-Port` → Original port
---
## 📂 Directory Structure & Persistence
```
/srv/digiserver-v2/
├── app/ (Flask application code)
├── data/ (PERSISTENT - mounted as Docker volume)
│ ├── app/ (Copy of app/ for container)
│ ├── instance/ (dashboard.db - SQLite database)
│ ├── uploads/ (User uploaded files)
│ ├── nginx-ssl/ (SSL certificates)
│ ├── nginx-logs/ (Nginx logs)
│ └── certbot/ (Let's Encrypt challenges)
├── migrations/ (Database schema updates)
├── docker-compose.yml (Container orchestration)
├── Dockerfile (Flask app image definition)
├── nginx.conf (Reverse proxy configuration)
└── docker-entrypoint.sh (Container startup script)
```
---
## 🔐 Default Credentials
```
Username: admin
Password: admin123
```
⚠️ **CHANGE IMMEDIATELY IN PRODUCTION!**
---
## 🌐 Access Points
After deployment, access the app at:
- `https://localhost` (if deployed locally)
- `https://192.168.0.121` (if deployed on server)
- `https://<DOMAIN>` (if DNS configured)
---
## ⚙️ Environment Variables (Optional)
Create `.env` file in project root:
```bash
# Network Configuration
HOSTNAME=digiserver
DOMAIN=digiserver.example.com
IP_ADDRESS=192.168.0.121
# SSL/HTTPS
EMAIL=admin@example.com
# Flask Configuration
SECRET_KEY=your-secret-key-here
FLASK_ENV=production
# Admin User
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
```
Then start with:
```bash
docker-compose up -d
```
---
## 🛠️ Common Commands
### **Start/Stop Containers**
```bash
# Start containers
docker-compose up -d
# Stop containers
docker-compose down
# Restart containers
docker-compose restart
# Restart specific container
docker-compose restart digiserver-app
docker-compose restart nginx
```
### **View Logs**
```bash
# All containers
docker-compose logs
# Follow logs (real-time)
docker-compose logs -f
# Specific container
docker-compose logs -f digiserver-app
docker-compose logs -f nginx
# Show last 50 lines
docker-compose logs --tail=50 digiserver-app
```
### **Container Status**
```bash
# Show running containers
docker-compose ps
# Show container details
docker-compose ps -a
# Check container health
docker ps --format="table {{.Names}}\t{{.Status}}"
```
### **Database Operations**
```bash
# Access database shell
docker-compose exec digiserver-app sqlite3 /app/instance/dashboard.db
# Backup database
docker-compose exec digiserver-app cp /app/instance/dashboard.db /app/instance/dashboard.db.backup
# Restore database
docker-compose exec digiserver-app cp /app/instance/dashboard.db.backup /app/instance/dashboard.db
```
### **Nginx Operations**
```bash
# Validate Nginx configuration
docker exec digiserver-nginx nginx -t
# Reload Nginx (without restart)
docker exec digiserver-nginx nginx -s reload
# View Nginx logs
docker-compose logs -f nginx
```
---
## 🔒 SSL Certificate Management
### **Generate New Self-Signed Certificate**
```bash
bash generate_nginx_certs.sh 192.168.0.121 365
docker-compose restart nginx
```
Parameters:
- `192.168.0.121` - Domain/IP for certificate
- `365` - Certificate validity in days
### **Set Up Let's Encrypt (Production)**
1. Update `DOMAIN` and `EMAIL` in environment
2. Modify `nginx.conf` to enable certbot challenges
3. Run certbot:
```bash
docker run --rm -v $(pwd)/data/certbot:/etc/letsencrypt \
-v $(pwd)/data/nginx-logs:/var/log/letsencrypt \
certbot/certbot certonly --webroot \
-w /var/www/certbot \
-d yourdomain.com \
-m your-email@example.com \
--agree-tos
```
---
## 🐛 Troubleshooting
### **Containers not starting?**
```bash
# Check docker-compose logs
docker-compose logs
# Check system resources
docker stats
# Restart Docker daemon
sudo systemctl restart docker
```
### **Application not responding?**
```bash
# Check app container health
docker-compose ps
# View app logs
docker-compose logs -f digiserver-app
# Test Flask directly
docker-compose exec digiserver-app curl http://localhost:5000/
```
### **HTTPS not working?**
```bash
# Verify Nginx config
docker exec digiserver-nginx nginx -t
# Check SSL certificates exist
ls -la ./data/nginx-ssl/
# View Nginx error logs
docker-compose logs nginx
```
### **Database issues?**
```bash
# Check database file exists
ls -la ./data/instance/dashboard.db
# Verify database permissions
docker-compose exec digiserver-app ls -la /app/instance/
# Check database tables
docker-compose exec digiserver-app sqlite3 /app/instance/dashboard.db ".tables"
```
### **Port already in use?**
```bash
# Find process using port 80
sudo lsof -i :80
# Find process using port 443
sudo lsof -i :443
# Kill process (if needed)
sudo kill -9 <PID>
```
---
## 📊 Health Checks
Both containers have health checks:
**Flask App**: Pings `http://localhost:5000/` every 30 seconds
**Nginx**: Pings `http://localhost:80/` every 30 seconds
Check health status:
```bash
docker-compose ps
# Look for "Up (healthy)" status
```
---
## 🔄 Database Backup & Restore
### **Backup**
```bash
# Create backup
docker-compose exec digiserver-app cp /app/instance/dashboard.db /app/instance/dashboard.backup.db
# Download to local machine
cp ./data/instance/dashboard.backup.db ./backup/
```
### **Restore**
```bash
# Stop containers
docker-compose stop
# Restore database
cp ./backup/dashboard.backup.db ./data/instance/dashboard.db
# Start containers
docker-compose up -d
```
---
## 📈 Performance Tuning
### **Gunicorn Workers** (docker-entrypoint.sh)
```bash
# Default: 4 workers
# Formula: (2 × CPU_count) + 1
# For 4-core CPU: 9 workers
# Modify docker-entrypoint.sh:
gunicorn --workers 9 ...
```
### **Nginx Worker Processes** (nginx.conf)
```nginx
# Default: auto (CPU count)
worker_processes auto;
# Or specify manually:
worker_processes 4;
```
### **Upload Timeout** (nginx.conf)
```nginx
# Default: 300s
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
```
---
## 🔐 Security Checklist
- [ ] Change admin password immediately
- [ ] Set strong `SECRET_KEY` environment variable
- [ ] Enable firewall rules (allow only ports 80, 443)
- [ ] Set up HTTPS with Let's Encrypt
- [ ] Configure regular database backups
- [ ] Review Nginx security headers
- [ ] Update Flask dependencies regularly
- [ ] Monitor container logs for errors
- [ ] Restrict admin panel access (IP whitelist optional)
- [ ] Enable Flask debug mode only in development
---
## 📞 Support & Documentation
- **Nginx Setup**: See `NGINX_SETUP_QUICK.md`
- **ProxyFix Configuration**: See `PROXY_FIX_SETUP.md`
- **Deployment Commands**: See `DEPLOYMENT_COMMANDS.md`
- **Issue Troubleshooting**: Check `old_code_documentation/`
---
## ✅ Deployment Checklist
- [ ] Docker & Docker Compose installed
- [ ] Running from `/srv/digiserver-v2` directory
- [ ] Environment variables configured (optional)
- [ ] Port 80/443 available
- [ ] Sufficient disk space (min 5GB)
- [ ] Sufficient RAM (min 2GB free)
- [ ] Network connectivity verified
- [ ] SSL certificates generated or obtained
- [ ] Admin credentials changed (production)
---
## 🚀 Next Steps After Deployment
1. **Access Web Interface**
- Login with admin credentials
- Change password immediately
2. **Configure Application**
- Set up players
- Upload content
- Configure groups & permissions
3. **Production Hardening**
- Enable Let's Encrypt HTTPS
- Configure firewall rules
- Set up database backups
- Monitor logs
4. **Optional Enhancements**
- Set up custom domain
- Configure email notifications
- Install optional dependencies (LibreOffice, etc.)
---
**Last Updated**: January 15, 2026

View File

@@ -4,6 +4,7 @@ Modern Flask application with blueprint architecture
"""
import os
from flask import Flask, render_template
from werkzeug.middleware.proxy_fix import ProxyFix
from dotenv import load_dotenv
from app.config import DevelopmentConfig, ProductionConfig, TestingConfig
@@ -37,6 +38,10 @@ def create_app(config_name=None):
app.config.from_object(config)
# Apply ProxyFix middleware for reverse proxy (Nginx/Caddy)
# This ensures proper handling of X-Forwarded-* headers
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
# Initialize extensions
db.init_app(app)
bcrypt.init_app(app)
@@ -52,6 +57,7 @@ def create_app(config_name=None):
register_error_handlers(app)
register_commands(app)
register_context_processors(app)
register_template_filters(app)
return app
@@ -181,6 +187,34 @@ def register_context_processors(app):
return {'theme': theme}
def register_template_filters(app):
"""Register custom Jinja2 template filters"""
from datetime import datetime, timezone
@app.template_filter('localtime')
def localtime_filter(dt, format='%Y-%m-%d %H:%M'):
"""Convert UTC datetime to local time and format it.
Args:
dt: datetime object in UTC
format: strftime format string
Returns:
Formatted datetime string in local timezone
"""
if dt is None:
return ''
# If datetime is naive (no timezone), assume it's UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
# Convert to local time
local_dt = dt.astimezone()
return local_dt.strftime(format)
# For backwards compatibility and direct running
if __name__ == '__main__':
app = create_app()

View File

@@ -8,8 +8,10 @@ from datetime import datetime
from typing import Optional
from app.extensions import db, bcrypt
from app.models import User, Player, Group, Content, ServerLog, Playlist
from app.models import User, Player, Group, Content, ServerLog, Playlist, HTTPSConfig
from app.utils.logger import log_action
from app.utils.caddy_manager import CaddyConfigGenerator
from app.utils.nginx_config_reader import get_nginx_status
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -31,7 +33,6 @@ def admin_required(f):
@admin_bp.route('/')
@login_required
@admin_required
def admin_panel():
"""Display admin panel with system overview."""
try:
@@ -351,7 +352,6 @@ def system_info():
@admin_bp.route('/leftover-media')
@login_required
@admin_required
def leftover_media():
"""Display leftover media files not assigned to any playlist."""
from app.models.playlist import playlist_content
@@ -374,12 +374,15 @@ def leftover_media():
leftover_pdfs = [c for c in leftover_content if c.content_type == 'pdf']
leftover_pptx = [c for c in leftover_content if c.content_type == 'pptx']
# Calculate storage
total_leftover_size = sum(c.file_size for c in leftover_content)
images_size = sum(c.file_size for c in leftover_images)
videos_size = sum(c.file_size for c in leftover_videos)
pdfs_size = sum(c.file_size for c in leftover_pdfs)
pptx_size = sum(c.file_size for c in leftover_pptx)
# Calculate storage (handle None values)
def safe_file_size(content_list):
return sum(c.file_size or 0 for c in content_list)
total_leftover_size = safe_file_size(leftover_content)
images_size = safe_file_size(leftover_images)
videos_size = safe_file_size(leftover_videos)
pdfs_size = safe_file_size(leftover_pdfs)
pptx_size = safe_file_size(leftover_pptx)
return render_template('admin/leftover_media.html',
leftover_images=leftover_images,
@@ -401,7 +404,6 @@ def leftover_media():
@admin_bp.route('/delete-leftover-images', methods=['POST'])
@login_required
@admin_required
def delete_leftover_images():
"""Delete all leftover images that are not part of any playlist"""
from app.models.playlist import playlist_content
@@ -409,7 +411,7 @@ def delete_leftover_images():
try:
# Find all leftover image content
leftover_images = db.session.query(Content).filter(
Content.media_type == 'image',
Content.content_type == 'image',
~Content.id.in_(
db.session.query(playlist_content.c.content_id)
)
@@ -421,16 +423,26 @@ def delete_leftover_images():
for content in leftover_images:
try:
# Delete physical file
if content.file_path:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record
db.session.delete(content)
deleted_count += 1
except Exception as e:
errors.append(f"Error deleting {content.file_path}: {str(e)}")
errors.append(f"Error deleting {content.filename}: {str(e)}")
db.session.commit()
@@ -447,7 +459,6 @@ def delete_leftover_images():
@admin_bp.route('/delete-leftover-videos', methods=['POST'])
@login_required
@admin_required
def delete_leftover_videos():
"""Delete all leftover videos that are not part of any playlist"""
from app.models.playlist import playlist_content
@@ -455,7 +466,7 @@ def delete_leftover_videos():
try:
# Find all leftover video content
leftover_videos = db.session.query(Content).filter(
Content.media_type == 'video',
Content.content_type == 'video',
~Content.id.in_(
db.session.query(playlist_content.c.content_id)
)
@@ -467,16 +478,26 @@ def delete_leftover_videos():
for content in leftover_videos:
try:
# Delete physical file
if content.file_path:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record
db.session.delete(content)
deleted_count += 1
except Exception as e:
errors.append(f"Error deleting {content.file_path}: {str(e)}")
errors.append(f"Error deleting {content.filename}: {str(e)}")
db.session.commit()
@@ -493,23 +514,32 @@ def delete_leftover_videos():
@admin_bp.route('/delete-single-leftover/<int:content_id>', methods=['POST'])
@login_required
@admin_required
def delete_single_leftover(content_id):
"""Delete a single leftover content file"""
try:
content = Content.query.get_or_404(content_id)
# Delete physical file
if content.file_path:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record
db.session.delete(content)
db.session.commit()
flash(f'Successfully deleted {content.file_path}', 'success')
flash(f'Successfully deleted {content.filename}', 'success')
except Exception as e:
db.session.rollback()
@@ -615,7 +645,7 @@ def install_libreoffice():
flash('Installation script not found', 'danger')
return redirect(url_for('admin.dependencies'))
result = subprocess.run(['sudo', 'bash', script_path],
result = subprocess.run(['sudo', '-n', script_path],
capture_output=True,
text=True,
timeout=300)
@@ -652,7 +682,7 @@ def install_emoji_fonts():
flash('Installation script not found', 'danger')
return redirect(url_for('admin.dependencies'))
result = subprocess.run(['sudo', 'bash', script_path],
result = subprocess.run(['sudo', '-n', script_path],
capture_output=True,
text=True,
timeout=180)
@@ -742,3 +772,246 @@ def upload_login_logo():
flash(f'Error uploading logo: {str(e)}', 'danger')
return redirect(url_for('admin.customize_logos'))
@admin_bp.route('/editing-users')
@login_required
def manage_editing_users():
"""Display and manage users that edit images on players."""
try:
from app.models.player_user import PlayerUser
from app.models.player_edit import PlayerEdit
# Get all editing users
users = PlayerUser.query.order_by(PlayerUser.created_at.desc()).all()
# Get edit counts for each user
user_stats = {}
for user in users:
edit_count = PlayerEdit.query.filter_by(user=user.user_code).count()
user_stats[user.user_code] = edit_count
return render_template('admin/editing_users.html',
users=users,
user_stats=user_stats)
except Exception as e:
log_action('error', f'Error loading editing users: {str(e)}')
flash('Error loading editing users.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/editing-users/<int:user_id>/update', methods=['POST'])
@login_required
def update_editing_user(user_id: int):
"""Update editing user name."""
try:
from app.models.player_user import PlayerUser
user = PlayerUser.query.get_or_404(user_id)
user_name = request.form.get('user_name', '').strip()
user.user_name = user_name if user_name else None
user.updated_at = datetime.utcnow()
db.session.commit()
log_action('info', f'Updated editing user {user.user_code} name to: {user_name or "None"}')
flash('User name updated successfully!', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating editing user: {str(e)}')
flash(f'Error updating user: {str(e)}', 'danger')
return redirect(url_for('admin.manage_editing_users'))
@admin_bp.route('/editing-users/<int:user_id>/delete', methods=['POST'])
@login_required
def delete_editing_user(user_id: int):
"""Delete editing user."""
try:
from app.models.player_user import PlayerUser
user = PlayerUser.query.get_or_404(user_id)
user_code = user.user_code
db.session.delete(user)
db.session.commit()
log_action('info', f'Deleted editing user: {user_code}')
flash('User deleted successfully!', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting editing user: {str(e)}')
flash(f'Error deleting user: {str(e)}', 'danger')
return redirect(url_for('admin.manage_editing_users'))
# ============================================================================
# HTTPS Configuration Management Routes
# ============================================================================
@admin_bp.route('/https-config', methods=['GET'])
@login_required
@admin_required
def https_config():
"""Display HTTPS configuration management page."""
try:
config = HTTPSConfig.get_config()
# Detect actual current HTTPS status
# Check if current connection is HTTPS
is_https_active = request.scheme == 'https' or request.headers.get('X-Forwarded-Proto') == 'https'
current_host = request.host.split(':')[0] # Remove port if present
# If HTTPS is active but database shows disabled, sync it
if is_https_active and config and not config.https_enabled:
# Update database to reflect actual HTTPS status
config.https_enabled = True
db.session.commit()
log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)')
# Get Nginx configuration status
nginx_status = get_nginx_status()
return render_template('admin/https_config.html',
config=config,
is_https_active=is_https_active,
current_host=current_host,
nginx_status=nginx_status)
except Exception as e:
log_action('error', f'Error loading HTTPS config page: {str(e)}')
flash('Error loading HTTPS configuration page.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/https-config/update', methods=['POST'])
@login_required
@admin_required
def update_https_config():
"""Update HTTPS configuration."""
try:
https_enabled = request.form.get('https_enabled') == 'on'
hostname = request.form.get('hostname', '').strip()
domain = request.form.get('domain', '').strip()
ip_address = request.form.get('ip_address', '').strip()
email = request.form.get('email', '').strip()
port = request.form.get('port', '443').strip()
# Validation
errors = []
if https_enabled:
if not hostname:
errors.append('Hostname is required when HTTPS is enabled.')
if not domain:
errors.append('Domain name is required when HTTPS is enabled.')
if not ip_address:
errors.append('IP address is required when HTTPS is enabled.')
if not email:
errors.append('Email address is required when HTTPS is enabled.')
# Validate domain format (basic)
if domain and '.' not in domain:
errors.append('Please enter a valid domain name (e.g., example.com).')
# Validate IP format (basic)
if ip_address:
ip_parts = ip_address.split('.')
if len(ip_parts) != 4:
errors.append('Please enter a valid IPv4 address (e.g., 10.76.152.164).')
else:
try:
for part in ip_parts:
num = int(part)
if num < 0 or num > 255:
raise ValueError()
except ValueError:
errors.append('Please enter a valid IPv4 address.')
# Validate email format (basic)
if email and '@' not in email:
errors.append('Please enter a valid email address.')
# Validate port
try:
port_num = int(port)
if port_num < 1 or port_num > 65535:
errors.append('Port must be between 1 and 65535.')
port = port_num
except ValueError:
errors.append('Port must be a valid number.')
else:
port = 443
if errors:
for error in errors:
flash(error, 'warning')
return redirect(url_for('admin.https_config'))
# Update configuration
config = HTTPSConfig.create_or_update(
https_enabled=https_enabled,
hostname=hostname if https_enabled else None,
domain=domain if https_enabled else None,
ip_address=ip_address if https_enabled else None,
email=email if https_enabled else None,
port=port if https_enabled else 443,
updated_by=current_user.username
)
# Generate and update Caddyfile
try:
caddyfile_content = CaddyConfigGenerator.generate_caddyfile(config)
if CaddyConfigGenerator.write_caddyfile(caddyfile_content):
# Reload Caddy configuration
if CaddyConfigGenerator.reload_caddy():
caddy_status = '✅ Caddy configuration updated successfully!'
log_action('info', f'Caddy configuration reloaded by {current_user.username}')
else:
caddy_status = '⚠️ Caddyfile updated but reload failed. Please restart containers.'
log_action('warning', f'Caddy reload failed for {current_user.username}')
else:
caddy_status = '⚠️ Configuration saved but Caddyfile update failed.'
log_action('warning', f'Caddyfile write failed for {current_user.username}')
except Exception as caddy_error:
caddy_status = f'⚠️ Configuration saved but Caddy update failed: {str(caddy_error)}'
log_action('error', f'Caddy update error: {str(caddy_error)}')
if https_enabled:
log_action('info', f'HTTPS enabled by {current_user.username}: domain={domain}, hostname={hostname}, ip={ip_address}, email={email}')
flash(f'✅ HTTPS configuration saved successfully!\n{caddy_status}\nServer available at https://{domain}', 'success')
else:
log_action('info', f'HTTPS disabled by {current_user.username}')
flash(f'✅ HTTPS has been disabled. Server running on HTTP only.\n{caddy_status}', 'success')
return redirect(url_for('admin.https_config'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating HTTPS config: {str(e)}')
flash(f'Error updating HTTPS configuration: {str(e)}', 'danger')
return redirect(url_for('admin.https_config'))
@admin_bp.route('/https-config/status')
@login_required
@admin_required
def https_config_status():
"""Get current HTTPS configuration status as JSON."""
try:
config = HTTPSConfig.get_config()
if config:
return jsonify(config.to_dict())
else:
return jsonify({
'https_enabled': False,
'hostname': None,
'domain': None,
'ip_address': None,
'port': 443,
})
except Exception as e:
log_action('error', f'Error getting HTTPS status: {str(e)}')
return jsonify({'error': str(e)}), 500

View File

@@ -96,7 +96,7 @@ def health_check():
@api_bp.route('/auth/player', methods=['POST'])
@rate_limit(max_requests=10, window=60)
@rate_limit(max_requests=120, window=60)
def authenticate_player():
"""Authenticate a player and return auth code and configuration.
@@ -152,7 +152,7 @@ def authenticate_player():
@api_bp.route('/auth/verify', methods=['POST'])
@rate_limit(max_requests=30, window=60)
@rate_limit(max_requests=300, window=60)
def verify_auth_code():
"""Verify an auth code and return player information.
@@ -240,8 +240,8 @@ def get_playlist_by_quickconnect():
'playlist_version': 0
}), 403
# Check if quickconnect matches
if player.quickconnect_code != quickconnect_code:
# Check if quickconnect matches (using bcrypt verification)
if not player.check_quickconnect_code(quickconnect_code):
log_action('warning', f'Invalid quickconnect code for player: {hostname}')
return jsonify({
'error': 'Invalid quickconnect code',
@@ -293,7 +293,7 @@ def get_playlist_by_quickconnect():
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
@rate_limit(max_requests=30, window=60)
@rate_limit(max_requests=300, window=60)
@verify_player_auth
def get_player_playlist(player_id: int):
"""Get playlist for a specific player.
@@ -401,14 +401,15 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
'duration': content._playlist_duration or content.duration or 10,
'position': content._playlist_position or idx,
'url': content_url, # Full URL for downloads
'description': content.description
'description': content.description,
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
})
return playlist_data
@api_bp.route('/player-feedback', methods=['POST'])
@rate_limit(max_requests=100, window=60)
@rate_limit(max_requests=600, window=60)
def receive_player_feedback():
"""Receive feedback/status updates from players (Kivy player compatible).
@@ -427,24 +428,50 @@ def receive_player_feedback():
data = request.json
if not data:
log_action('warning', 'Player feedback received with no data')
return jsonify({'error': 'No data provided'}), 400
player_name = data.get('player_name')
hostname = data.get('hostname') # Also accept hostname
quickconnect_code = data.get('quickconnect_code')
if not player_name or not quickconnect_code:
return jsonify({'error': 'player_name and quickconnect_code required'}), 400
# Find player by hostname first (more reliable), then by name
player = None
if hostname:
player = Player.query.filter_by(hostname=hostname).first()
if not player and player_name:
player = Player.query.filter_by(name=player_name).first()
# Find player by name and validate quickconnect
player = Player.query.filter_by(name=player_name).first()
# If player not found and no credentials provided, try to infer from IP and recent auth
if not player and (not quickconnect_code or (not player_name and not hostname)):
# Try to find player by recent authentication from same IP
client_ip = request.remote_addr
# Look for players with matching IP in recent activity (last 5 minutes)
recent_time = datetime.utcnow() - timedelta(minutes=5)
possible_player = Player.query.filter(
Player.last_seen >= recent_time
).order_by(Player.last_seen.desc()).first()
if possible_player:
player = possible_player
log_action('info', f'Inferred player feedback from {player.name} ({player.hostname}) based on recent activity')
# Still require quickconnect validation if provided
if not player:
if not player_name and not hostname:
log_action('warning', f'Player feedback missing required fields. Data: {data}')
return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400
else:
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
return jsonify({'error': 'Player not found'}), 404
if not player:
log_action('warning', f'Player feedback from unknown player: {player_name}')
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
return jsonify({'error': 'Player not found'}), 404
# Validate quickconnect code
if player.quickconnect_code != quickconnect_code:
log_action('warning', f'Invalid quickconnect in feedback from: {player_name}')
# Validate quickconnect code if provided (using bcrypt verification)
if quickconnect_code and not player.check_quickconnect_code(quickconnect_code):
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
return jsonify({'error': 'Invalid quickconnect code'}), 403
# Create feedback record
@@ -466,7 +493,7 @@ def receive_player_feedback():
db.session.commit()
log_action('info', f'Feedback received from {player_name}: {status} - {message}')
log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}')
return jsonify({
'success': True,
@@ -673,6 +700,154 @@ def get_logs():
return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/player-edit-media', methods=['POST'])
@rate_limit(max_requests=60, window=60)
@verify_player_auth
def receive_edited_media():
"""Receive edited media from player.
Expected multipart/form-data:
- image_file: The edited image file
- metadata: JSON string with metadata
Metadata JSON structure:
{
"time_of_modification": "ISO timestamp",
"original_name": "original_file.jpg",
"new_name": "original_file_v1.jpg",
"version": 1,
"user": "player_user"
}
"""
try:
player = request.player
# Check if file is present
if 'image_file' not in request.files:
return jsonify({'error': 'No image file provided'}), 400
file = request.files['image_file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
# Get metadata
import json
metadata_str = request.form.get('metadata')
if not metadata_str:
return jsonify({'error': 'No metadata provided'}), 400
try:
metadata = json.loads(metadata_str)
except json.JSONDecodeError:
return jsonify({'error': 'Invalid metadata JSON'}), 400
# Validate required metadata fields
required_fields = ['time_of_modification', 'original_name', 'new_name', 'version']
for field in required_fields:
if field not in metadata:
return jsonify({'error': f'Missing required field: {field}'}), 400
# Import required modules
import os
from werkzeug.utils import secure_filename
from app.models.player_edit import PlayerEdit
# Find the original content by filename
original_name = metadata['original_name']
content = Content.query.filter_by(filename=original_name).first()
if not content:
log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}')
return jsonify({'error': f'Original content not found: {original_name}'}), 404
# Create versioned folder structure: edited_media/<content_id>/
base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads')
edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id))
os.makedirs(edited_media_dir, exist_ok=True)
# Save the edited file with version suffix
version = metadata['version']
new_filename = metadata['new_name']
edited_file_path = os.path.join(edited_media_dir, new_filename)
file.save(edited_file_path)
# Save metadata JSON file
metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json"
metadata_path = os.path.join(edited_media_dir, metadata_filename)
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
# Update the content record to reference the edited version path
# Keep original filename unchanged, point to edited_media folder
old_filename = content.filename
content.filename = f"edited_media/{content.id}/{new_filename}"
# Create edit record
time_of_mod = None
if metadata.get('time_of_modification'):
try:
time_of_mod = datetime.fromisoformat(metadata['time_of_modification'].replace('Z', '+00:00'))
except:
time_of_mod = datetime.utcnow()
# Auto-create PlayerUser record if user code is provided
user_code = metadata.get('user_card_data')
log_action('debug', f'Metadata user code: {user_code}')
if user_code:
from app.models.player_user import PlayerUser
existing_user = PlayerUser.query.filter_by(user_code=user_code).first()
if not existing_user:
new_user = PlayerUser(user_code=user_code)
db.session.add(new_user)
log_action('info', f'Auto-created PlayerUser record for code: {user_code}')
else:
log_action('debug', f'PlayerUser already exists for code: {user_code}')
else:
log_action('debug', 'No user code in metadata')
edit_record = PlayerEdit(
player_id=player.id,
content_id=content.id,
original_name=original_name,
new_name=new_filename,
version=version,
user=user_code,
time_of_modification=time_of_mod,
metadata_path=metadata_path,
edited_file_path=edited_file_path
)
db.session.add(edit_record)
# Update playlist version to force player refresh
if player.playlist_id:
from app.models.playlist import Playlist
playlist = db.session.get(Playlist, player.playlist_id)
if playlist:
playlist.version += 1
# Clear playlist cache
cache.delete_memoized(get_cached_playlist, player.id)
db.session.commit()
log_action('info', f'Player {player.name} uploaded edited media: {old_filename} -> {new_filename} (v{version})')
return jsonify({
'success': True,
'message': 'Edited media received and processed',
'edit_id': edit_record.id,
'version': version,
'old_filename': old_filename,
'new_filename': new_filename,
'new_playlist_version': playlist.version if player.playlist_id and playlist else None
}), 200
except Exception as e:
db.session.rollback()
log_action('error', f'Error receiving edited media: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.errorhandler(404)
def api_not_found(error):
"""Handle 404 errors in API."""

View File

@@ -3,7 +3,9 @@ from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app)
from flask_login import login_required
from werkzeug.utils import secure_filename
from typing import Optional
import os
import threading
from app.extensions import db, cache
from app.models import Content, Playlist, Player
@@ -11,6 +13,9 @@ from app.models.playlist import playlist_content
from app.utils.logger import log_action
from app.utils.uploads import process_video_file, set_upload_progress
# Store for background processing status
_background_tasks = {}
content_bp = Blueprint('content', __name__, url_prefix='/content')
@@ -19,15 +24,109 @@ content_bp = Blueprint('content', __name__, url_prefix='/content')
def content_list():
"""Main playlist management page."""
playlists = Playlist.query.order_by(Playlist.created_at.desc()).all()
media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
media_files = Content.query.order_by(Content.uploaded_at.desc()).limit(3).all() # Only last 3
total_media_count = Content.query.count() # Total count for display
players = Player.query.order_by(Player.name).all()
return render_template('content/content_list_new.html',
playlists=playlists,
media_files=media_files,
total_media_count=total_media_count,
players=players)
@content_bp.route('/media-library')
@login_required
def media_library():
"""View all media files in the library."""
from app.models.player_edit import PlayerEdit
media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
# Add edit count to each media item
for media in media_files:
media.edit_count = PlayerEdit.query.filter_by(content_id=media.id).count()
# Group by content type
images = [m for m in media_files if m.content_type == 'image']
videos = [m for m in media_files if m.content_type == 'video']
pdfs = [m for m in media_files if m.content_type == 'pdf']
presentations = [m for m in media_files if m.content_type == 'pptx']
others = [m for m in media_files if m.content_type not in ['image', 'video', 'pdf', 'pptx']]
return render_template('content/media_library.html',
media_files=media_files,
images=images,
videos=videos,
pdfs=pdfs,
presentations=presentations,
others=others)
@content_bp.route('/media/<int:media_id>/delete', methods=['POST'])
@login_required
def delete_media(media_id: int):
"""Delete a media file and remove it from all playlists."""
try:
media = Content.query.get_or_404(media_id)
filename = media.filename
# Get all playlists containing this media
affected_playlists = list(media.playlists.all())
# Delete physical file
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], media.filename)
if os.path.exists(file_path):
os.remove(file_path)
log_action('info', f'Deleted physical file: {filename}')
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(media.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
log_action('info', f'Deleted edited media archive for content ID {media.id}')
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
edit_records = PlayerEdit.query.filter_by(content_id=media.id).all()
if edit_records:
for edit in edit_records:
db.session.delete(edit)
log_action('info', f'Deleted {len(edit_records)} edit record(s) for content: {filename}')
# Remove from all playlists (this will cascade properly)
db.session.delete(media)
# Increment version for all affected playlists
for playlist in affected_playlists:
playlist.version += 1
log_action('info', f'Playlist "{playlist.name}" version updated to {playlist.version} (media removed)')
db.session.commit()
# Clear cache for affected playlists
from app.blueprints.players import get_player_playlist
from app.extensions import cache
for playlist in affected_playlists:
for player in playlist.players:
cache.delete_memoized(get_player_playlist, player.id)
if affected_playlists:
flash(f'Deleted "{filename}" and removed from {len(affected_playlists)} playlist(s). Playlist versions updated.', 'success')
else:
flash(f'Deleted "{filename}" successfully.', 'success')
log_action('info', f'Media deleted: {filename} (affected {len(affected_playlists)} playlists)')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting media: {str(e)}')
flash(f'Error deleting media: {str(e)}', 'danger')
return redirect(url_for('content.media_library'))
@content_bp.route('/playlist/create', methods=['POST'])
@login_required
def create_playlist():
@@ -194,6 +293,48 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
@content_bp.route('/playlist/<int:playlist_id>/bulk-remove', methods=['POST'])
@login_required
def bulk_remove_from_playlist(playlist_id: int):
"""Remove multiple content items from playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
data = request.get_json()
content_ids = data.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
from app.models.playlist import playlist_content
# Remove all selected items
stmt = playlist_content.delete().where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id.in_(content_ids))
)
result = db.session.execute(stmt)
# Increment version
playlist.increment_version()
db.session.commit()
cache.clear()
removed_count = result.rowcount if hasattr(result, 'rowcount') else len(content_ids)
log_action('info', f'Bulk removed {removed_count} items from playlist "{playlist.name}"')
return jsonify({
'success': True,
'message': f'Removed {removed_count} item(s) from playlist',
'removed_count': removed_count
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk removing from playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/playlist/<int:playlist_id>/reorder', methods=['POST'])
@login_required
def reorder_playlist_content(playlist_id: int):
@@ -235,6 +376,88 @@ def reorder_playlist_content(playlist_id: int):
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/playlist/<int:playlist_id>/update-muted/<int:content_id>', methods=['POST'])
@login_required
def update_playlist_content_muted(playlist_id: int, content_id: int):
"""Update content muted setting in playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
content = Content.query.get_or_404(content_id)
muted = request.form.get('muted', 'true').lower() == 'true'
from app.models.playlist import playlist_content
from sqlalchemy import update
# 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 playlist "{playlist.name}"')
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
@content_bp.route('/playlist/<int:playlist_id>/update-edit-enabled/<int:content_id>', methods=['POST'])
@login_required
def update_playlist_content_edit_enabled(playlist_id: int, content_id: int):
"""Update content edit_on_player_enabled setting in playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
content = Content.query.get_or_404(content_id)
edit_enabled = request.form.get('edit_enabled', 'false').lower() == 'true'
from app.models.playlist import playlist_content
from sqlalchemy import update
# Update edit_on_player_enabled in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id == content_id)
).values(edit_on_player_enabled=edit_enabled)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated edit_on_player_enabled={edit_enabled} for "{content.filename}" in playlist "{playlist.name}"')
return jsonify({
'success': True,
'message': 'Edit setting updated',
'edit_enabled': edit_enabled,
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating edit setting: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/upload-media-page')
@login_required
def upload_media_page():
@@ -365,6 +588,182 @@ def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]:
return False, f"PDF processing error: {str(e)}"
def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
duration: int, playlist_id: Optional[int], task_id: str, edit_on_player_enabled: bool = False):
"""Process large files (PDF, PPTX, Video) in background thread."""
with app.app_context():
try:
_background_tasks[task_id] = {'status': 'processing', 'message': f'Processing {filename}...'}
upload_folder = app.config['UPLOAD_FOLDER']
processing_success = True
processing_message = ""
detected_type = file_ext
# Process based on file type
if file_ext == 'pdf':
processing_success, processing_message = process_pdf_file(filepath, filename)
if processing_success and "converted to" in processing_message.lower():
base_name = os.path.splitext(filename)[0]
page_pattern = f"{base_name}_page*.png"
import glob
page_files = sorted(glob.glob(os.path.join(upload_folder, page_pattern)))
if page_files:
max_position = 0
if playlist_id:
playlist = Playlist.query.get(playlist_id)
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
for page_file in page_files:
page_filename = os.path.basename(page_file)
page_content = Content(
filename=page_filename,
content_type='image',
duration=duration,
file_size=os.path.getsize(page_file)
)
db.session.add(page_content)
db.session.flush()
if playlist_id:
max_position += 1
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=page_content.id,
position=max_position,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
if playlist_id and page_files:
playlist = Playlist.query.get(playlist_id)
if playlist:
playlist.version += 1
db.session.commit()
cache.clear()
if os.path.exists(filepath):
os.remove(filepath)
_background_tasks[task_id] = {
'status': 'complete',
'message': f'PDF converted to {len(page_files)} images successfully!'
}
log_action('info', f'Background: PDF {filename} converted to {len(page_files)} pages')
return
elif file_ext in ['ppt', 'pptx']:
processing_success, processing_message = process_presentation_file(filepath, filename)
if processing_success and "converted to" in processing_message.lower():
base_name = os.path.splitext(filename)[0]
slide_pattern = f"{base_name}_slide_*.png"
import glob
slide_files = sorted(glob.glob(os.path.join(upload_folder, slide_pattern)))
if slide_files:
max_position = 0
if playlist_id:
playlist = Playlist.query.get(playlist_id)
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
for slide_file in slide_files:
slide_filename = os.path.basename(slide_file)
slide_content = Content(
filename=slide_filename,
content_type='image',
duration=duration,
file_size=os.path.getsize(slide_file)
)
db.session.add(slide_content)
db.session.flush()
if playlist_id:
max_position += 1
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=slide_content.id,
position=max_position,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
if playlist_id and slide_files:
playlist = Playlist.query.get(playlist_id)
if playlist:
playlist.version += 1
db.session.commit()
cache.clear()
if os.path.exists(filepath):
os.remove(filepath)
_background_tasks[task_id] = {
'status': 'complete',
'message': f'Presentation converted to {len(slide_files)} slides successfully!'
}
log_action('info', f'Background: PPTX {filename} converted to {len(slide_files)} slides')
return
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']:
processing_success, processing_message = process_video_file_extended(filepath, filename)
detected_type = 'video'
# If file still exists, add as regular content
if processing_success and os.path.exists(filepath):
content = Content(
filename=filename,
content_type=detected_type,
duration=duration,
file_size=os.path.getsize(filepath)
)
db.session.add(content)
db.session.flush()
if playlist_id:
playlist = Playlist.query.get(playlist_id)
if playlist:
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=content.id,
position=max_position + 1,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
playlist.version += 1
db.session.commit()
cache.clear()
_background_tasks[task_id] = {'status': 'complete', 'message': f'{filename} processed successfully!'}
log_action('info', f'Background: {filename} processed successfully')
else:
_background_tasks[task_id] = {
'status': 'error',
'message': f'Failed to process {filename}: {processing_message}'
}
log_action('error', f'Background: Failed to process {filename}')
except Exception as e:
db.session.rollback()
_background_tasks[task_id] = {'status': 'error', 'message': f'Error: {str(e)}'}
log_action('error', f'Background processing error for {filename}: {str(e)}')
def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
"""Process PowerPoint presentation files by converting slides to images."""
try:
@@ -412,17 +811,16 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
temp_ppt = temp_path / filename
shutil.copy2(filepath, temp_ppt)
# Convert presentation to images (PNG format)
# Using LibreOffice headless mode with custom resolution
# Convert presentation to PDF first (for better quality)
convert_cmd = [
libreoffice_cmd,
'--headless',
'--convert-to', 'png',
'--convert-to', 'pdf',
'--outdir', str(temp_path),
str(temp_ppt)
]
log_action('info', f'Converting presentation to images: {filename}')
log_action('info', f'Converting presentation to PDF: {filename}')
try:
result = subprocess.run(
@@ -436,8 +834,40 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
log_action('error', f'LibreOffice conversion failed: {result.stderr}')
return True, "Presentation accepted without conversion (conversion failed)"
# Find generated PDF file
pdf_files = list(temp_path.glob('*.pdf'))
if not pdf_files:
log_action('warning', f'No PDF generated from presentation: {filename}')
return True, "Presentation accepted without conversion"
pdf_file = pdf_files[0]
log_action('info', f'Converting PDF to images at Full HD resolution: {pdf_file.name}')
# Convert PDF to images using pdftoppm at Full HD resolution (1920x1080)
# Calculate DPI for Full HD output (assuming standard presentation is 10x7.5 inches)
# 1920/10 = 192 DPI for width, use 192 DPI for best quality
pdftoppm_cmd = [
'pdftoppm',
'-png',
'-r', '300', # High DPI for quality
'-scale-to', '1920', # Scale width to 1920px
str(pdf_file),
str(temp_path / 'slide')
]
result = subprocess.run(
pdftoppm_cmd,
capture_output=True,
text=True,
timeout=120
)
if result.returncode != 0:
log_action('error', f'pdftoppm conversion failed: {result.stderr}')
return True, "Presentation accepted without conversion (image conversion failed)"
# Find generated PNG files
png_files = sorted(temp_path.glob('*.png'))
png_files = sorted(temp_path.glob('slide-*.png'))
if not png_files:
log_action('warning', f'No images generated from presentation: {filename}')
@@ -447,7 +877,7 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
upload_folder = current_app.config['UPLOAD_FOLDER']
base_name = os.path.splitext(filename)[0]
# Move converted images to upload folder
# Move converted images to upload folder and resize to exact Full HD
slide_count = 0
for idx, png_file in enumerate(png_files, start=1):
# Create descriptive filename
@@ -456,8 +886,8 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
shutil.move(str(png_file), destination)
# Optimize the image to Full HD (1920x1080)
optimize_image_to_fullhd(destination)
# Resize to exact Full HD dimensions (1920x1080) maintaining aspect ratio
resize_image_to_fullhd(destination)
slide_count += 1
@@ -502,6 +932,64 @@ def create_fullhd_image(img):
return fullhd_img
def resize_image_to_fullhd(filepath: str) -> bool:
"""Resize image to exactly Full HD (1920x1080) maintaining aspect ratio with centered crop or padding."""
try:
from PIL import Image
img = Image.open(filepath)
target_width = 1920
target_height = 1080
# Calculate aspect ratios
img_aspect = img.width / img.height
target_aspect = target_width / target_height
if abs(img_aspect - target_aspect) < 0.01:
# Aspect ratio is very close, just resize
img_resized = img.resize((target_width, target_height), Image.Resampling.LANCZOS)
elif img_aspect > target_aspect:
# Image is wider than target, fit height and crop/pad width
new_height = target_height
new_width = int(target_height * img_aspect)
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Crop to center if wider
if new_width > target_width:
left = (new_width - target_width) // 2
img_resized = img_resized.crop((left, 0, left + target_width, target_height))
else:
# Pad with white if narrower
result = Image.new('RGB', (target_width, target_height), (255, 255, 255))
offset = (target_width - new_width) // 2
result.paste(img_resized, (offset, 0))
img_resized = result
else:
# Image is taller than target, fit width and crop/pad height
new_width = target_width
new_height = int(target_width / img_aspect)
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Crop to center if taller
if new_height > target_height:
top = (new_height - target_height) // 2
img_resized = img_resized.crop((0, top, target_width, top + target_height))
else:
# Pad with white if shorter
result = Image.new('RGB', (target_width, target_height), (255, 255, 255))
offset = (target_height - new_height) // 2
result.paste(img_resized, (0, offset))
img_resized = result
# Save optimized image
img_resized.save(filepath, 'PNG', optimize=True, quality=95)
return True
except Exception as e:
log_action('error', f'Image resize error: {str(e)}')
return False
def optimize_image_to_fullhd(filepath: str) -> bool:
"""Optimize and resize image file to Full HD (1920x1080) maintaining aspect ratio."""
try:
@@ -526,6 +1014,7 @@ def upload_media():
content_type = request.form.get('content_type', 'image')
duration = request.form.get('duration', type=int, default=10)
playlist_id = request.form.get('playlist_id', type=int)
edit_on_player_enabled = request.form.get('edit_on_player_enabled', '0') == '1'
if not files or files[0].filename == '':
flash('No files provided.', 'warning')
@@ -535,6 +1024,7 @@ def upload_media():
os.makedirs(upload_folder, exist_ok=True)
uploaded_count = 0
background_count = 0
processing_errors = []
for file in files:
@@ -556,6 +1046,26 @@ def upload_media():
# Determine content type from extension
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
# Check if file needs background processing (large files)
needs_background = file_ext in ['pdf', 'ppt', 'pptx', 'mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']
if needs_background:
# Process in background thread
import uuid
task_id = str(uuid.uuid4())
_background_tasks[task_id] = {'status': 'queued', 'message': f'Queued {filename} for processing...'}
thread = threading.Thread(
target=process_file_in_background,
args=(current_app._get_current_object(), filepath, filename, file_ext, duration, playlist_id, task_id, edit_on_player_enabled)
)
thread.daemon = True
thread.start()
background_count += 1
log_action('info', f'Queued {filename} for background processing (task: {task_id})')
continue
# Process file based on type
processing_success = True
processing_message = ""
@@ -610,7 +1120,8 @@ def upload_media():
playlist_id=playlist_id,
content_id=page_content.id,
position=max_position,
duration=duration
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
@@ -618,7 +1129,7 @@ def upload_media():
# Increment playlist version if pages were added
if playlist_id and page_files:
playlist.version += 1
playlist.increment_version()
# Delete original PDF file
if os.path.exists(filepath):
@@ -669,7 +1180,8 @@ def upload_media():
playlist_id=playlist_id,
content_id=slide_content.id,
position=max_position,
duration=duration
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
@@ -677,7 +1189,7 @@ def upload_media():
# Increment playlist version if slides were added
if playlist_id and slide_files:
playlist.version += 1
playlist.increment_version()
# Delete original PPTX file
if os.path.exists(filepath):
@@ -721,19 +1233,20 @@ def upload_media():
playlist_id=playlist_id,
content_id=content.id,
position=max_position + 1,
duration=duration
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
# Increment playlist version
playlist.version += 1
playlist.increment_version()
uploaded_count += 1
db.session.commit()
cache.clear()
log_action('info', f'Uploaded {uploaded_count} media files')
log_action('info', f'Uploaded {uploaded_count} media files, {background_count} files processing in background')
# Show appropriate flash message
if processing_errors:
@@ -741,6 +1254,14 @@ def upload_media():
if len(processing_errors) > 3:
error_summary += f' and {len(processing_errors) - 3} more...'
flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning')
elif background_count > 0:
bg_msg = f'{background_count} file(s) are being processed in the background (PDF, PPTX, or large videos). '
bg_msg += 'They will appear in the media library/playlist automatically when conversion completes. '
bg_msg += 'This may take a few minutes.'
if uploaded_count > 0:
flash(f'✅ Uploaded {uploaded_count} file(s) immediately. ⏳ {bg_msg}', 'info')
else:
flash(f'{bg_msg}', 'info')
elif playlist_id:
playlist = Playlist.query.get(playlist_id)
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')

View File

@@ -228,21 +228,48 @@ def manage_player(player_id: int):
try:
if action == 'update_credentials':
# Update player name, location, orientation
# Update player name, location, orientation, and authentication
name = request.form.get('name', '').strip()
location = request.form.get('location', '').strip()
orientation = request.form.get('orientation', 'Landscape')
hostname = request.form.get('hostname', '').strip()
password = request.form.get('password', '').strip()
quickconnect_code = request.form.get('quickconnect_code', '').strip()
if not name or len(name) < 3:
flash('Player name must be at least 3 characters long.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
if not hostname or len(hostname) < 3:
flash('Hostname must be at least 3 characters long.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
# Check if hostname is taken by another player
if hostname != player.hostname:
existing = Player.query.filter_by(hostname=hostname).first()
if existing:
flash(f'Hostname "{hostname}" is already in use by another player.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
# Update basic info
player.name = name
player.hostname = hostname
player.location = location or None
player.orientation = orientation
# Update password if provided
if password:
player.set_password(password)
log_action('info', f'Password updated for player "{name}"')
# Update quickconnect code if provided
if quickconnect_code:
player.set_quickconnect_code(quickconnect_code)
log_action('info', f'QuickConnect code updated for player "{name}" to: {quickconnect_code}')
db.session.commit()
log_action('info', f'Player "{name}" credentials updated')
log_action('info', f'Player "{name}" (hostname: {hostname}) credentials updated')
flash(f'Player "{name}" updated successfully.', 'success')
elif action == 'assign_playlist':
@@ -288,6 +315,13 @@ def manage_player(player_id: int):
.limit(20)\
.all()
# Get edited media history from player
from app.models.player_edit import PlayerEdit
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
.order_by(PlayerEdit.created_at.desc())\
.limit(20)\
.all()
# Get player status
status_info = get_player_status_info(player_id)
@@ -296,9 +330,54 @@ def manage_player(player_id: int):
playlists=playlists,
current_playlist=current_playlist,
recent_logs=recent_logs,
edited_media=edited_media,
status_info=status_info)
@players_bp.route('/<int:player_id>/edited-media')
@login_required
def edited_media(player_id: int):
"""Display all edited media files from this player."""
try:
player = Player.query.get_or_404(player_id)
# Get all edited media history from player
from app.models.player_edit import PlayerEdit
from app.models.player_user import PlayerUser
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
.order_by(PlayerEdit.created_at.desc())\
.all()
# Get original content files for each edited media
content_files = {}
for edit in edited_media:
if edit.content_id not in content_files:
content = Content.query.get(edit.content_id)
if content:
content_files[edit.content_id] = content
# Get user mappings for display names
user_mappings = {}
for edit in edited_media:
if edit.user and edit.user not in user_mappings:
player_user = PlayerUser.query.filter_by(user_code=edit.user).first()
if player_user:
user_mappings[edit.user] = player_user.user_name or edit.user
else:
user_mappings[edit.user] = edit.user
return render_template('players/edited_media.html',
player=player,
edited_media=edited_media,
content_files=content_files,
user_mappings=user_mappings)
except Exception as e:
log_action('error', f'Error loading edited media for player {player_id}: {str(e)}')
flash('Error loading edited media.', 'danger')
return redirect(url_for('players.manage_player', player_id=player_id))
@players_bp.route('/<int:player_id>/fullscreen')
def player_fullscreen(player_id: int):
"""Display player fullscreen view (no authentication required for players)."""
@@ -353,6 +432,7 @@ def get_player_playlist(player_id: int) -> List[dict]:
'type': content.content_type,
'duration': getattr(content, '_playlist_duration', content.duration or 10),
'position': getattr(content, '_playlist_position', 0),
'muted': getattr(content, '_playlist_muted', True),
'filename': content.filename
})

View File

@@ -239,6 +239,49 @@ def update_duration(player_id: int, content_id: int):
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):

View File

@@ -29,6 +29,11 @@ class Config:
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
# Reverse proxy trust (for Nginx/Caddy with ProxyFix middleware)
# These are set by werkzeug.middleware.proxy_fix
TRUSTED_PROXIES = os.getenv('TRUSTED_PROXIES', '127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16')
PREFERRED_URL_SCHEME = os.getenv('PREFERRED_URL_SCHEME', 'https')
# Cache
SEND_FILE_MAX_AGE_DEFAULT = 300 # 5 minutes for static files
@@ -71,6 +76,7 @@ class ProductionConfig(Config):
DEBUG = False
TESTING = False
TEMPLATES_AUTO_RELOAD = True # Force template reload
# Database - construct absolute path
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
@@ -79,19 +85,13 @@ class ProductionConfig(Config):
f'sqlite:///{os.path.join(_basedir, "instance", "dashboard.db")}'
)
# Redis Cache
CACHE_TYPE = 'redis'
CACHE_REDIS_HOST = os.getenv('REDIS_HOST', 'redis')
CACHE_REDIS_PORT = int(os.getenv('REDIS_PORT', 6379))
CACHE_REDIS_DB = 0
# Cache - use simple cache instead of Redis
CACHE_TYPE = 'simple'
CACHE_DEFAULT_TIMEOUT = 300
# Security
SESSION_COOKIE_SECURE = True
WTF_CSRF_ENABLED = True
# Rate Limiting
RATELIMIT_STORAGE_URL = f"redis://{os.getenv('REDIS_HOST', 'redis')}:6379/1"
class TestingConfig(Config):

View File

@@ -6,6 +6,9 @@ from app.models.playlist import Playlist, playlist_content
from app.models.content import Content
from app.models.server_log import ServerLog
from app.models.player_feedback import PlayerFeedback
from app.models.player_edit import PlayerEdit
from app.models.player_user import PlayerUser
from app.models.https_config import HTTPSConfig
__all__ = [
'User',
@@ -15,6 +18,9 @@ __all__ = [
'Content',
'ServerLog',
'PlayerFeedback',
'PlayerEdit',
'PlayerUser',
'HTTPSConfig',
'group_content',
'playlist_content',
]

104
app/models/https_config.py Normal file
View File

@@ -0,0 +1,104 @@
"""HTTPS Configuration model."""
from datetime import datetime
from typing import Optional
from app.extensions import db
class HTTPSConfig(db.Model):
"""HTTPS configuration model for managing secure connections.
Attributes:
id: Primary key
https_enabled: Whether HTTPS is enabled
hostname: Server hostname (e.g., 'digiserver')
domain: Full domain name (e.g., 'digiserver.sibiusb.harting.intra')
ip_address: IP address for direct access
email: Email address for SSL certificate notifications
port: HTTPS port (default 443)
created_at: Creation timestamp
updated_at: Last update timestamp
updated_by: User who made the last update
"""
__tablename__ = 'https_config'
id = db.Column(db.Integer, primary_key=True)
https_enabled = db.Column(db.Boolean, default=False, nullable=False)
hostname = db.Column(db.String(255), nullable=True)
domain = db.Column(db.String(255), nullable=True)
ip_address = db.Column(db.String(45), nullable=True) # Support IPv6
email = db.Column(db.String(255), nullable=True)
port = db.Column(db.Integer, default=443, nullable=False)
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)
updated_by = db.Column(db.String(255), nullable=True)
def __repr__(self) -> str:
"""String representation of HTTPSConfig."""
status = 'ENABLED' if self.https_enabled else 'DISABLED'
return f'<HTTPSConfig [{status}] {self.domain or "N/A"}>'
@classmethod
def get_config(cls) -> Optional['HTTPSConfig']:
"""Get the current HTTPS configuration.
Returns:
HTTPSConfig instance or None if not configured
"""
return cls.query.first()
@classmethod
def create_or_update(cls, https_enabled: bool, hostname: str = None,
domain: str = None, ip_address: str = None,
email: str = None, port: int = 443,
updated_by: str = None) -> 'HTTPSConfig':
"""Create or update HTTPS configuration.
Args:
https_enabled: Whether HTTPS is enabled
hostname: Server hostname
domain: Full domain name
ip_address: IP address
email: Email for SSL certificates
port: HTTPS port
updated_by: Username of who made the update
Returns:
HTTPSConfig instance
"""
config = cls.get_config()
if not config:
config = cls()
config.https_enabled = https_enabled
config.hostname = hostname
config.domain = domain
config.ip_address = ip_address
config.email = email
config.port = port
config.updated_by = updated_by
config.updated_at = datetime.utcnow()
db.session.add(config)
db.session.commit()
return config
def to_dict(self) -> dict:
"""Convert configuration to dictionary.
Returns:
Dictionary representation of config
"""
return {
'id': self.id,
'https_enabled': self.https_enabled,
'hostname': self.hostname,
'domain': self.domain,
'ip_address': self.ip_address,
'email': self.email,
'port': self.port,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'updated_by': self.updated_by,
}

60
app/models/player_edit.py Normal file
View File

@@ -0,0 +1,60 @@
"""Player edit model for tracking media edited on players."""
from datetime import datetime
from typing import Optional
from app.extensions import db
class PlayerEdit(db.Model):
"""Player edit model for tracking media files edited on player devices.
Attributes:
id: Primary key
player_id: Foreign key to player
content_id: Foreign key to content that was edited
original_name: Original filename
new_name: New filename after editing
version: Edit version number (v1, v2, etc.)
user: User who made the edit (from player)
time_of_modification: When the edit was made
metadata_path: Path to the metadata JSON file
edited_file_path: Path to the edited file
created_at: Record creation timestamp
"""
__tablename__ = 'player_edit'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('player.id', ondelete='CASCADE'), nullable=False, index=True)
content_id = db.Column(db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), nullable=False, index=True)
original_name = db.Column(db.String(255), nullable=False)
new_name = db.Column(db.String(255), nullable=False)
version = db.Column(db.Integer, default=1, nullable=False)
user = db.Column(db.String(255), nullable=True)
time_of_modification = db.Column(db.DateTime, nullable=True)
metadata_path = db.Column(db.String(512), nullable=True)
edited_file_path = db.Column(db.String(512), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relationships
player = db.relationship('Player', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
content = db.relationship('Content', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
def __repr__(self) -> str:
"""String representation of PlayerEdit."""
return f'<PlayerEdit {self.original_name} v{self.version} by {self.user or "unknown"}>'
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
'id': self.id,
'player_id': self.player_id,
'player_name': self.player.name if self.player else None,
'content_id': self.content_id,
'original_name': self.original_name,
'new_name': self.new_name,
'version': self.version,
'user': self.user,
'time_of_modification': self.time_of_modification.isoformat() if self.time_of_modification else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'edited_file_path': self.edited_file_path
}

37
app/models/player_user.py Normal file
View File

@@ -0,0 +1,37 @@
"""Player user model for managing user codes and names."""
from datetime import datetime
from app.extensions import db
class PlayerUser(db.Model):
"""Player user model for managing user codes and names globally.
Attributes:
id: Primary key
user_code: User code received from player (unique)
user_name: Display name for the user
created_at: Record creation timestamp
updated_at: Record update timestamp
"""
__tablename__ = 'player_user'
id = db.Column(db.Integer, primary_key=True)
user_code = db.Column(db.String(255), nullable=False, unique=True, index=True)
user_name = db.Column(db.String(255), 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)
def __repr__(self) -> str:
"""String representation of PlayerUser."""
return f'<PlayerUser {self.user_code} -> {self.user_name or "Unnamed"}>'
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
'id': self.id,
'user_code': self.user_code,
'user_name': self.user_name,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -10,7 +10,9 @@ playlist_content = db.Table('playlist_content',
db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id', ondelete='CASCADE'), primary_key=True),
db.Column('content_id', db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), primary_key=True),
db.Column('position', db.Integer, default=0),
db.Column('duration', db.Integer, default=10)
db.Column('duration', db.Integer, default=10),
db.Column('muted', db.Boolean, default=True),
db.Column('edit_on_player_enabled', db.Boolean, default=False)
)
@@ -76,7 +78,9 @@ class Playlist(db.Model):
from sqlalchemy import select
stmt = select(playlist_content.c.content_id,
playlist_content.c.position,
playlist_content.c.duration).where(
playlist_content.c.duration,
playlist_content.c.muted,
playlist_content.c.edit_on_player_enabled).where(
playlist_content.c.playlist_id == self.id
).order_by(playlist_content.c.position)
@@ -88,6 +92,8 @@ class Playlist(db.Model):
if content:
content._playlist_position = row.position
content._playlist_duration = row.duration
content._playlist_muted = row.muted if len(row) > 3 else True
content._playlist_edit_on_player_enabled = row.edit_on_player_enabled if len(row) > 4 else False
ordered_content.append(content)
return ordered_content

View File

@@ -48,7 +48,8 @@
</div>
</div>
<!-- User Management Card -->
{% if current_user.is_admin %}
<!-- User Management Card (Admin Only) -->
<div class="card management-card">
<h2>👥 User Management</h2>
<p>Manage application users, roles and permissions</p>
@@ -58,6 +59,18 @@
</a>
</div>
</div>
{% endif %}
<!-- Editing Users Card -->
<div class="card management-card">
<h2>✏️ Editing Users</h2>
<p>Manage user codes from players that edit images on-screen</p>
<div class="card-actions">
<a href="{{ url_for('admin.manage_editing_users') }}" class="btn btn-primary">
Manage Editing Users
</a>
</div>
</div>
<!-- Leftover Media Management Card -->
<div class="card management-card">
@@ -70,7 +83,8 @@
</div>
</div>
<!-- System Dependencies Card -->
{% if current_user.is_admin %}
<!-- System Dependencies Card (Admin Only) -->
<div class="card management-card" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<h2>🔧 System Dependencies</h2>
<p>Check and install required software dependencies</p>
@@ -81,7 +95,7 @@
</div>
</div>
<!-- Logo Customization Card -->
<!-- Logo Customization Card (Admin Only) -->
<div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
<h2>🎨 Logo Customization</h2>
<p>Upload custom logos for header and login page</p>
@@ -92,6 +106,18 @@
</div>
</div>
<!-- HTTPS Configuration Card (Admin Only) -->
<div class="card management-card" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<h2>🔒 HTTPS Configuration</h2>
<p>Manage SSL/HTTPS settings, domain, and access points</p>
<div class="card-actions">
<a href="{{ url_for('admin.https_config') }}" class="btn btn-primary">
Configure HTTPS
</a>
</div>
</div>
{% endif %}
<!-- Quick Actions Card -->
<div class="card">
<h2>⚡ Quick Actions</h2>

View File

@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}Manage Editing Users{% endblock %}
{% block content %}
<div style="margin-bottom: 2rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
<div style="display: flex; align-items: center; gap: 1rem;">
<a href="{{ url_for('admin.admin_panel') }}"
class="btn"
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px;">
← Back to Admin
</a>
<h1 style="margin: 0;">👤 Manage Editing Users</h1>
</div>
</div>
<p style="color: #6c757d;">Manage users who edit images on players. User codes are automatically created from player metadata.</p>
</div>
{% if users %}
<div class="card">
<div class="card-header">
<h3 style="margin: 0;">Editing Users ({{ users|length }})</h3>
</div>
<div class="card-body" style="padding: 0;">
<table class="table" style="margin: 0;">
<thead>
<tr>
<th style="width: 30%;">User Code</th>
<th style="width: 30%;">Display Name</th>
<th style="width: 15%;">Edits Count</th>
<th style="width: 15%;">Created</th>
<th style="width: 10%;">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td style="font-family: monospace; font-weight: 600;">{{ user.user_code }}</td>
<td>
<form method="POST" action="{{ url_for('admin.update_editing_user', user_id=user.id) }}" style="display: flex; gap: 0.5rem; align-items: center;">
<input type="text"
name="user_name"
value="{{ user.user_name or '' }}"
placeholder="Enter display name"
class="form-control"
style="flex: 1;">
<button type="submit" class="btn btn-sm btn-primary">Save</button>
</form>
</td>
<td>
<span class="badge badge-info">{{ user_stats.get(user.user_code, 0) }} edits</span>
</td>
<td>{{ user.created_at | localtime('%Y-%m-%d %H:%M') }}</td>
<td>
<form method="POST"
action="{{ url_for('admin.delete_editing_user', user_id=user.id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this user? This will not delete their edit history.');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="card" style="text-align: center; padding: 4rem 2rem;">
<div style="font-size: 4rem; margin-bottom: 1rem; opacity: 0.5;">👤</div>
<h2 style="color: #6c757d; margin-bottom: 1rem;">No Editing Users Yet</h2>
<p style="color: #6c757d; font-size: 1.1rem;">
User codes will appear here automatically when players edit media files.
</p>
</div>
{% endif %}
<style>
body.dark-mode .table {
color: #e2e8f0;
}
body.dark-mode .table thead th {
background: #2d3748;
color: #e2e8f0;
border-color: #4a5568;
}
body.dark-mode .table tbody tr {
border-color: #4a5568;
}
body.dark-mode .table tbody tr:hover {
background: #2d3748;
}
body.dark-mode .form-control {
background: #2d3748;
color: #e2e8f0;
border-color: #4a5568;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,654 @@
{% extends "base.html" %}
{% block title %}HTTPS Configuration - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<a href="{{ url_for('admin.admin_panel') }}" class="back-link">← Back to Admin Panel</a>
<h1>🔒 HTTPS Configuration</h1>
</div>
<div class="https-config-container">
<!-- Status Display -->
<div class="card status-card">
<h2>Current Status</h2>
<!-- Real-time HTTPS detection -->
<div class="status-detection">
<p class="detection-info">
<strong>🔍 Detected Connection:</strong>
{% if is_https_active %}
<span class="badge badge-success">🔒 HTTPS ({{ request.scheme.upper() }})</span>
{% else %}
<span class="badge badge-warning">🔓 HTTP</span>
{% endif %}
<br>
<small>Current host: <code>{{ current_host }}</code> via {{ request.host }}</small>
</p>
</div>
{% if config and config.https_enabled %}
<div class="status-enabled">
<span class="status-badge">✅ HTTPS ENABLED</span>
<div class="status-details">
<p><strong>Domain:</strong> {{ config.domain }}</p>
<p><strong>Hostname:</strong> {{ config.hostname }}</p>
<p><strong>Email:</strong> {{ config.email }}</p>
<p><strong>IP Address:</strong> {{ config.ip_address }}</p>
<p><strong>Port:</strong> {{ config.port }}</p>
<p><strong>Access URL:</strong> <code>https://{{ config.domain }}</code></p>
<p><strong>Last Updated:</strong> {{ config.updated_at.strftime('%Y-%m-%d %H:%M:%S') }} by {{ config.updated_by }}</p>
</div>
</div>
{% else %}
<div class="status-disabled">
<span class="status-badge-inactive">⚠️ HTTPS DISABLED</span>
{% if is_https_active %}
<p style="color: #156b2e; background: #d1f0e0; padding: 10px; border-radius: 4px; margin: 10px 0;">
<strong>Note:</strong> You are currently accessing this page via HTTPS, but the configuration shows as disabled.
This configuration will be automatically updated. Please refresh the page.
</p>
{% else %}
<p>The application is currently running on HTTP only (port 80)</p>
<p>Enable HTTPS below to secure your application.</p>
{% endif %}
</div>
{% endif %}
</div>
<!-- Configuration Form -->
<div class="card config-card">
<h2>Configure HTTPS Settings</h2>
<p class="info-text">
💡 <strong>Workflow:</strong> First, the app runs on HTTP (port 80). After you configure the HTTPS settings below,
the application will be available over HTTPS (port 443) using the domain and hostname you specify.
</p>
<form method="POST" action="{{ url_for('admin.update_https_config') }}" class="https-form">
<!-- Enable HTTPS Toggle -->
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" name="https_enabled" id="https_enabled"
{% if config and config.https_enabled %}checked{% endif %}
class="toggle-input">
<span class="toggle-slider"></span>
<span class="toggle-text">Enable HTTPS</span>
</label>
<p class="form-hint">Check this box to enable HTTPS/SSL for your application</p>
</div>
<!-- Hostname Field -->
<div class="form-group">
<label for="hostname">Hostname <span class="required">*</span></label>
<input type="text" id="hostname" name="hostname"
value="{{ config.hostname or 'digiserver' }}"
placeholder="e.g., digiserver"
class="form-input"
required>
<p class="form-hint">Short name for your server (e.g., 'digiserver')</p>
</div>
<!-- Domain Field -->
<div class="form-group">
<label for="domain">Full Domain Name <span class="required">*</span></label>
<input type="text" id="domain" name="domain"
value="{{ config.domain or 'digiserver.sibiusb.harting.intra' }}"
placeholder="e.g., digiserver.sibiusb.harting.intra"
class="form-input"
required>
<p class="form-hint">Complete domain name (e.g., digiserver.sibiusb.harting.intra)</p>
</div>
<!-- IP Address Field -->
<div class="form-group">
<label for="ip_address">IP Address <span class="required">*</span></label>
<input type="text" id="ip_address" name="ip_address"
value="{{ config.ip_address or '10.76.152.164' }}"
placeholder="e.g., 10.76.152.164"
class="form-input"
required>
<p class="form-hint">Server's IP address for direct access (e.g., 10.76.152.164)</p>
</div>
<!-- Email Field -->
<div class="form-group">
<label for="email">Email Address <span class="required">*</span></label>
<input type="email" id="email" name="email"
value="{{ config.email or '' }}"
placeholder="e.g., admin@example.com"
class="form-input"
required>
<p class="form-hint">Email address for SSL certificate notifications and Let's Encrypt communications</p>
</div>
<!-- Port Field -->
<div class="form-group">
<label for="port">HTTPS Port</label>
<input type="number" id="port" name="port"
value="{{ config.port or 443 }}"
placeholder="443"
min="1" max="65535"
class="form-input">
<p class="form-hint">Port for HTTPS connections (default: 443)</p>
</div>
<!-- Preview Section -->
<div class="preview-section">
<h3>Access Points After Configuration:</h3>
<ul class="access-points">
<li>
<strong>HTTPS (Recommended):</strong>
<code>https://<span id="preview-domain">digiserver.sibiusb.harting.intra</span></code>
</li>
<li>
<strong>HTTP (Fallback):</strong>
<code>http://<span id="preview-ip">10.76.152.164</span></code>
</li>
</ul>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="submit" class="btn btn-primary btn-lg">
💾 Save HTTPS Configuration
</button>
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
Cancel
</a>
</div>
</form>
</div>
<!-- Nginx Status Card -->
<div class="card nginx-status-card">
<h2>🔧 Nginx Reverse Proxy Status</h2>
{% if nginx_status.available %}
<div class="nginx-status-content">
<div class="status-item">
<strong>Status:</strong>
<span class="badge badge-success">✅ Nginx Configured</span>
</div>
<div class="status-item">
<strong>Configuration Path:</strong>
<code>{{ nginx_status.path }}</code>
</div>
{% if nginx_status.ssl_enabled %}
<div class="status-item">
<strong>SSL/TLS:</strong>
<span class="badge badge-success">🔒 Enabled</span>
</div>
{% else %}
<div class="status-item">
<strong>SSL/TLS:</strong>
<span class="badge badge-warning">⚠️ Not Configured</span>
</div>
{% endif %}
{% if nginx_status.http_ports %}
<div class="status-item">
<strong>HTTP Ports:</strong>
<code>{{ nginx_status.http_ports|join(', ') }}</code>
</div>
{% endif %}
{% if nginx_status.https_ports %}
<div class="status-item">
<strong>HTTPS Ports:</strong>
<code>{{ nginx_status.https_ports|join(', ') }}</code>
</div>
{% endif %}
{% if nginx_status.server_names %}
<div class="status-item">
<strong>Server Names:</strong>
{% for name in nginx_status.server_names %}
<code>{{ name }}</code>{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
{% if nginx_status.upstream_servers %}
<div class="status-item">
<strong>Upstream Servers:</strong>
{% for server in nginx_status.upstream_servers %}
<code>{{ server }}</code>{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
{% if nginx_status.ssl_protocols %}
<div class="status-item">
<strong>SSL Protocols:</strong>
<code>{{ nginx_status.ssl_protocols|join(', ') }}</code>
</div>
{% endif %}
{% if nginx_status.client_max_body_size %}
<div class="status-item">
<strong>Max Body Size:</strong>
<code>{{ nginx_status.client_max_body_size }}</code>
</div>
{% endif %}
{% if nginx_status.gzip_enabled %}
<div class="status-item">
<strong>Gzip Compression:</strong>
<span class="badge badge-success">✅ Enabled</span>
</div>
{% endif %}
</div>
{% else %}
<div class="status-disabled">
<p>⚠️ <strong>Nginx configuration not accessible</strong></p>
<p>Error: {{ nginx_status.error|default('Unknown error') }}</p>
<p style="font-size: 12px; color: #666;">Path checked: {{ nginx_status.path }}</p>
</div>
{% endif %}
</div>
<!-- Information Section -->
<div class="card info-card">
<h2> Important Information</h2>
<div class="info-sections">
<div class="info-section">
<h3>📝 Before You Start</h3>
<ul>
<li>Ensure your DNS is configured to resolve the domain to your server</li>
<li>Verify the IP address matches your server's actual network interface</li>
<li>Check that ports 80, 443, and 443/UDP are open for traffic</li>
</ul>
</div>
<div class="info-section">
<h3>🔐 HTTPS Setup</h3>
<ul>
<li>SSL certificates are automatically managed by Caddy</li>
<li>Certificates are obtained from Let's Encrypt</li>
<li>Automatic renewal is handled by the system</li>
</ul>
</div>
<div class="info-section">
<h3>✅ After Configuration</h3>
<ul>
<li>Your app will restart with the new settings</li>
<li>Both HTTP and HTTPS access points will be available</li>
<li>HTTP requests will be redirected to HTTPS</li>
<li>Check the status above for current configuration</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<style>
.https-config-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.page-header {
margin-bottom: 30px;
}
.back-link {
display: inline-block;
margin-bottom: 15px;
color: #0066cc;
text-decoration: none;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.status-detection {
background: #f0f7ff;
border-left: 4px solid #0066cc;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.detection-info {
margin: 0;
font-size: 14px;
line-height: 1.6;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-left: 8px;
}
.badge-success {
background: #d1f0e0;
color: #156b2e;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
.status-card {
margin-bottom: 30px;
border-left: 5px solid #ddd;
}
.status-enabled {
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
border-radius: 8px;
padding: 20px;
border-left: 5px solid #28a745;
}
.status-disabled {
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
border-radius: 8px;
padding: 20px;
border-left: 5px solid #ffc107;
}
.status-badge {
display: inline-block;
background: #28a745;
color: white;
padding: 8px 16px;
border-radius: 20px;
font-weight: bold;
margin-bottom: 15px;
font-size: 14px;
}
.status-badge-inactive {
display: inline-block;
background: #ffc107;
color: #333;
padding: 8px 16px;
border-radius: 20px;
font-weight: bold;
margin-bottom: 15px;
font-size: 14px;
}
.status-details {
margin-top: 15px;
}
.status-details p {
margin: 8px 0;
font-size: 14px;
}
.status-details code {
background: rgba(0,0,0,0.1);
padding: 4px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
.config-card {
margin-bottom: 30px;
}
.info-text {
background: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 25px;
font-size: 14px;
}
.https-form {
padding: 20px 0;
}
.form-group {
margin-bottom: 25px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.required {
color: #dc3545;
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: all 0.2s;
}
.form-input:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.form-hint {
font-size: 13px;
color: #666;
margin-top: 6px;
}
/* Toggle Switch Styling */
.toggle-label {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.toggle-input {
display: none;
}
.toggle-slider {
display: inline-block;
width: 50px;
height: 28px;
background: #ccc;
border-radius: 14px;
position: relative;
transition: all 0.3s;
}
.toggle-slider::after {
content: '';
position: absolute;
width: 24px;
height: 24px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: all 0.3s;
}
.toggle-input:checked + .toggle-slider {
background: #28a745;
}
.toggle-input:checked + .toggle-slider::after {
left: 24px;
}
.toggle-text {
font-weight: 600;
color: #333;
}
.preview-section {
background: #f8f9fa;
border: 2px dashed #0066cc;
border-radius: 8px;
padding: 20px;
margin: 25px 0;
}
.preview-section h3 {
margin-top: 0;
color: #0066cc;
}
.access-points {
list-style: none;
padding: 0;
margin: 0;
}
.access-points li {
padding: 10px;
background: white;
border-radius: 4px;
margin-bottom: 8px;
border-left: 4px solid #0066cc;
}
.access-points code {
background: #e7f3ff;
padding: 6px 10px;
border-radius: 4px;
font-family: 'Courier New', monospace;
color: #0066cc;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 30px;
padding-top: 25px;
border-top: 1px solid #ddd;
}
.btn-lg {
padding: 12px 30px;
font-size: 16px;
font-weight: 600;
}
.info-card {
background: linear-gradient(135deg, #e7f3ff 0%, #f0f7ff 100%);
}
.info-card h2 {
color: #0066cc;
}
.nginx-status-card {
background: linear-gradient(135deg, #f0f7ff 0%, #e7f3ff 100%);
border-left: 5px solid #0066cc;
margin-bottom: 30px;
}
.nginx-status-card h2 {
color: #0066cc;
margin-top: 0;
}
.nginx-status-content {
padding: 10px 0;
}
.status-item {
padding: 12px;
background: white;
border-radius: 4px;
margin-bottom: 10px;
border-left: 3px solid #0066cc;
font-size: 14px;
}
.status-item strong {
display: inline-block;
min-width: 150px;
color: #333;
}
.status-item code {
background: #f0f7ff;
padding: 4px 8px;
border-radius: 3px;
font-family: 'Courier New', monospace;
color: #0066cc;
word-break: break-all;
}
.info-sections {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.info-section h3 {
color: #0066cc;
margin-top: 0;
font-size: 16px;
}
.info-section ul {
padding-left: 20px;
margin: 0;
}
.info-section li {
margin-bottom: 8px;
font-size: 14px;
color: #555;
}
@media (max-width: 768px) {
.https-config-container {
padding: 10px;
}
.info-sections {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
.btn-lg {
width: 100%;
}
}
</style>
<script>
// Update preview in real-time
document.getElementById('domain').addEventListener('input', function() {
document.getElementById('preview-domain').textContent = this.value || 'digiserver.sibiusb.harting.intra';
});
document.getElementById('ip_address').addEventListener('input', function() {
document.getElementById('preview-ip').textContent = this.value || '10.76.152.164';
});
// Load initial preview
document.getElementById('preview-domain').textContent = document.getElementById('domain').value || 'digiserver.sibiusb.harting.intra';
document.getElementById('preview-ip').textContent = document.getElementById('ip_address').value || '10.76.152.164';
</script>
{% endblock %}

View File

@@ -72,7 +72,7 @@
<td style="padding: 10px;">📷 {{ img.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ img.duration }}s</td>
<td style="padding: 10px;">{{ img.uploaded_at.strftime('%Y-%m-%d %H:%M') if img.uploaded_at else 'N/A' }}</td>
<td style="padding: 10px;">{{ img.uploaded_at | localtime if img.uploaded_at else 'N/A' }}</td>
<td style="padding: 10px;">
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=img.id) }}" style="display: inline;" onsubmit="return confirm('Delete this image?');">
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
@@ -122,7 +122,7 @@
<td style="padding: 10px;">🎥 {{ video.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ video.duration }}s</td>
<td style="padding: 10px;">{{ video.uploaded_at.strftime('%Y-%m-%d %H:%M') if video.uploaded_at else 'N/A' }}</td>
<td style="padding: 10px;">{{ video.uploaded_at | localtime if video.uploaded_at else 'N/A' }}</td>
<td style="padding: 10px;">
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=video.id) }}" style="display: inline;" onsubmit="return confirm('Delete this video?');">
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
@@ -162,7 +162,7 @@
<td style="padding: 10px;">📄 {{ pdf.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(pdf.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ pdf.duration }}s</td>
<td style="padding: 10px;">{{ pdf.uploaded_at.strftime('%Y-%m-%d %H:%M') if pdf.uploaded_at else 'N/A' }}</td>
<td style="padding: 10px;">{{ pdf.uploaded_at | localtime if pdf.uploaded_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -37,8 +37,8 @@
{{ user.role|capitalize }}
</span>
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'N/A' }}</td>
<td>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else 'Never' }}</td>
<td>{{ user.created_at | localtime if user.created_at else 'N/A' }}</td>
<td>{{ user.last_login | localtime if user.last_login else 'Never' }}</td>
<td class="actions">
{% if user.id != current_user.id %}
<button class="btn btn-sm btn-warning" onclick="showEditUserModal({{ user.id }}, '{{ user.username }}', '{{ user.role }}')">

View File

@@ -222,10 +222,6 @@
<button type="submit" class="btn-login">Login</button>
</form>
<div class="register-link">
Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a>
</div>
</div>
</div>
</div>

View File

@@ -69,6 +69,8 @@
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
header h1 {
font-size: 1.5rem;
@@ -80,6 +82,7 @@
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
nav a {
color: white;
@@ -102,6 +105,69 @@
filter: brightness(0) invert(1);
}
/* Mobile Responsive */
@media (max-width: 768px) {
header .container {
flex-direction: column;
align-items: stretch;
padding: 1rem;
}
header h1 {
font-size: 1.25rem;
justify-content: center;
text-align: center;
}
nav {
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
nav a {
justify-content: center;
width: 100%;
padding: 0.75rem 1rem;
}
.dark-mode-toggle {
margin-left: 0;
width: 100%;
padding: 0.75rem;
}
.container {
padding: 15px;
}
.card {
padding: 1rem;
margin-bottom: 1rem;
}
.card-header {
padding: 15px;
margin: -1rem -1rem 1rem -1rem;
}
}
@media (max-width: 480px) {
header h1 {
font-size: 1.1rem;
}
nav a {
font-size: 0.9rem;
padding: 0.6rem 0.8rem;
}
nav a img {
width: 16px;
height: 16px;
}
}
.dark-mode-toggle {
background: rgba(255,255,255,0.2);
border: 2px solid rgba(255,255,255,0.3);
@@ -310,7 +376,7 @@
<header>
<div class="container">
<h1>
<img src="{{ url_for('static', filename='uploads/header_logo.png') }}" alt="DigiServer" style="height: 32px; width: auto;" onerror="this.src='{{ url_for('static', filename='icons/monitor.svg') }}'; this.style.filter='brightness(0) invert(1)'; this.style.width='28px'; this.style.height='28px';">
<img src="{{ url_for('static', filename='uploads/header_logo.png?v=1') }}" alt="DigiServer" style="height: 32px; width: auto; margin-right: 8px;" onerror="this.style.display='none';" onload="this.style.display='inline';">
DigiServer
</h1>
<nav>
@@ -327,9 +393,7 @@
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="">
Playlists
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.admin_panel') }}">Admin</a>
{% endif %}
<a href="{{ url_for('admin.admin_panel') }}">Admin</a>
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a>
<button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle Dark Mode">
<img id="theme-icon" src="{{ url_for('static', filename='icons/moon.svg') }}" alt="Toggle theme">

View File

@@ -75,7 +75,7 @@
{% endif %}
</td>
<td style="padding: 12px;">
<small style="color: #6c757d;">{{ item.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</small>
<small style="color: #6c757d;">{{ item.uploaded_at | localtime }}</small>
</td>
<td style="padding: 12px;">
{% if item.player_count > 0 %}

View File

@@ -249,6 +249,12 @@
background: #1a202c !important;
color: #e2e8f0;
}
/* Dark mode for upload section */
body.dark-mode .card > div[style*="background: #f8f9fa"] {
background: #2d3748 !important;
border: 1px solid #4a5568;
}
</style>
<div class="container" style="max-width: 1400px;">
@@ -258,18 +264,17 @@
</h1>
<div class="main-grid">
<!-- Create/Manage Playlists Card -->
<!-- Create Playlist Card -->
<div class="card">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Playlists
Create New Playlist
</h2>
</div>
<!-- Create New Playlist Form -->
<form method="POST" action="{{ url_for('content.create_playlist') }}" style="margin-bottom: 25px;">
<h3 style="margin-bottom: 15px;">Create New Playlist</h3>
<form method="POST" action="{{ url_for('content.create_playlist') }}">
<div class="form-group">
<label for="playlist_name">Playlist Name *</label>
<input type="text" name="name" id="playlist_name" class="form-control" required
@@ -294,47 +299,6 @@
Create Playlist
</button>
</form>
<hr style="margin: 25px 0;">
<!-- Existing Playlists -->
<h3 style="margin-bottom: 15px;">Existing Playlists</h3>
<div class="playlist-list">
{% if playlists %}
{% for playlist in playlists %}
<div class="playlist-item">
<div class="playlist-info">
<h3>{{ playlist.name }}</h3>
<div class="playlist-stats">
📊 {{ playlist.content_count }} items |
👥 {{ playlist.player_count }} players |
🔄 v{{ playlist.version }}
</div>
</div>
<div class="playlist-actions">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}"
class="btn btn-primary btn-sm">
✏️ Manage
</a>
<form method="POST"
action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}"
style="display: inline;"
onsubmit="return confirm('Delete playlist {{ playlist.name }}?');">
<button type="submit" class="btn btn-danger btn-sm" style="display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/trash.svg') }}" alt="" style="width: 14px; height: 14px; filter: brightness(0) invert(1);">
Delete
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 64px; height: 64px; opacity: 0.3; margin-bottom: 10px;">
<p>No playlists yet. Create your first playlist above!</p>
</div>
{% endif %}
</div>
</div>
<!-- Upload Media Card -->
@@ -356,9 +320,10 @@
<!-- Media Library with Thumbnails -->
<h3 style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between;">
<span>📚 Available Media ({{ media_files|length }})</span>
<span>📚 Last 3 Added Media</span>
<small style="color: #6c757d; font-size: 0.85rem;">Total: {{ total_media_count }}</small>
</h3>
<div class="media-library" style="max-height: 500px; overflow-y: auto;">
<div class="media-library" style="max-height: 350px; overflow-y: auto;">
{% if media_files %}
{% for media in media_files %}
<div class="media-item" title="{{ media.filename }}">
@@ -393,6 +358,63 @@
</div>
{% endif %}
</div>
<!-- View All Media Button -->
{% if total_media_count > 3 %}
<div style="text-align: center; padding: 15px; border-top: 1px solid #dee2e6; margin-top: 10px;">
<a href="{{ url_for('content.media_library') }}" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<span>📚</span>
View All Media ({{ total_media_count }} files)
</a>
</div>
{% endif %}
</div>
</div>
<!-- Existing Playlists Card -->
<div class="card full-width">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Existing Playlists
</h2>
</div>
<div class="playlist-list">
{% if playlists %}
{% for playlist in playlists %}
<div class="playlist-item">
<div class="playlist-info">
<h3>{{ playlist.name }}</h3>
<div class="playlist-stats">
📊 {{ playlist.content_count }} items |
👥 {{ playlist.player_count }} players |
🔄 v{{ playlist.version }}
</div>
</div>
<div class="playlist-actions">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}"
class="btn btn-primary btn-sm">
✏️ Manage
</a>
<form method="POST"
action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}"
style="display: inline;"
onsubmit="return confirm('Delete playlist {{ playlist.name }}?');">
<button type="submit" class="btn btn-danger btn-sm" style="display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/trash.svg') }}" alt="" style="width: 14px; height: 14px; filter: brightness(0) invert(1);">
Delete
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 64px; height: 64px; opacity: 0.3; margin-bottom: 10px;">
<p>No playlists yet. Create your first playlist above!</p>
</div>
{% endif %}
</div>
</div>

View File

@@ -88,6 +88,72 @@
justify-content: space-between;
align-items: center;
}
/* Audio toggle styles */
.audio-toggle {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.audio-checkbox {
display: none;
}
.audio-label {
font-size: 20px;
transition: all 0.3s ease;
}
.audio-checkbox + .audio-label .audio-on {
display: none;
}
.audio-checkbox + .audio-label .audio-off {
display: inline;
}
.audio-checkbox:checked + .audio-label .audio-on {
display: inline;
}
.audio-checkbox:checked + .audio-label .audio-off {
display: none;
}
.audio-label:hover {
transform: scale(1.2);
}
/* Dark mode support */
body.dark-mode .playlist-table th {
background: #1a202c;
color: #cbd5e0;
border-bottom-color: #4a5568;
}
body.dark-mode .playlist-table td {
border-bottom-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .draggable-row:hover {
background: #1a202c;
}
body.dark-mode .drag-handle {
color: #718096;
}
body.dark-mode .content-item {
background: #1a202c;
color: #e2e8f0;
}
body.dark-mode .available-content {
color: #e2e8f0;
}
</style>
<div class="container" style="max-width: 1400px;">
@@ -125,23 +191,36 @@
<div class="content-grid">
<div class="card">
<h2 style="margin-bottom: 20px;">📋 Playlist Content (Drag to Reorder)</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">📋 Playlist Content (Drag to Reorder)</h2>
<button id="bulk-delete-btn" class="btn btn-danger" style="display: none;" onclick="bulkDeleteSelected()">
🗑️ Delete Selected (<span id="selected-count">0</span>)
</button>
</div>
{% if playlist_content %}
<table class="playlist-table" id="playlist-table">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" title="Select all">
</th>
<th style="width: 40px;"></th>
<th style="width: 50px;">#</th>
<th>Filename</th>
<th style="width: 100px;">Type</th>
<th style="width: 100px;">Duration</th>
<th style="width: 80px;">Audio</th>
<th style="width: 80px;">Edit</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody id="playlist-tbody">
{% for content in playlist_content %}
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
<td>
<input type="checkbox" class="content-checkbox" data-content-id="{{ content.id }}" onchange="updateBulkDeleteButton()">
</td>
<td><span class="drag-handle">⋮⋮</span></td>
<td>{{ loop.index }}</td>
<td>{{ content.filename }}</td>
@@ -152,6 +231,40 @@
{% else %}📁 Other{% endif %}
</td>
<td>{{ content._playlist_duration or content.duration }}s</td>
<td>
{% if content.content_type == 'video' %}
<label class="audio-toggle">
<input type="checkbox"
class="audio-checkbox"
data-content-id="{{ content.id }}"
{{ 'checked' if not content._playlist_muted else '' }}
onchange="toggleAudio({{ content.id }}, this.checked)">
<span class="audio-label">
<span class="audio-on">🔊</span>
<span class="audio-off">🔇</span>
</span>
</label>
{% else %}
<span style="color: #999;"></span>
{% endif %}
</td>
<td>
{% if content.content_type in ['image', 'pdf'] %}
<label class="audio-toggle">
<input type="checkbox"
class="edit-checkbox"
data-content-id="{{ content.id }}"
{{ 'checked' if content._playlist_edit_on_player_enabled else '' }}
onchange="toggleEdit({{ content.id }}, this.checked)">
<span class="audio-label">
<span class="audio-on">✏️</span>
<span class="audio-off">🔒</span>
</span>
</label>
{% else %}
<span style="color: #999;"></span>
{% endif %}
</td>
<td>
<form method="POST"
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
@@ -299,6 +412,145 @@ function saveOrder() {
console.error('Error:', error);
});
}
function toggleAudio(contentId, enabled) {
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/update-muted/${contentId}`;
const formData = new FormData();
formData.append('muted', muted ? 'true' : 'false');
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Audio setting updated:', enabled ? 'Enabled' : 'Muted');
} else {
alert('Error updating audio setting: ' + data.message);
// Revert checkbox on error
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
}
})
.catch(error => {
console.error('Error:', error);
alert('Error updating audio setting');
// Revert checkbox on error
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
});
}
function toggleEdit(contentId, enabled) {
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/update-edit-enabled/${contentId}`;
const formData = new FormData();
formData.append('edit_enabled', enabled ? 'true' : 'false');
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Edit setting updated:', enabled ? 'Enabled' : 'Disabled');
} else {
alert('Error updating edit setting: ' + data.message);
// Revert checkbox on error
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
}
})
.catch(error => {
console.error('Error:', error);
alert('Error updating edit setting');
// Revert checkbox on error
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
});
}
function toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('.content-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
updateBulkDeleteButton();
}
function updateBulkDeleteButton() {
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
const count = checkboxes.length;
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
const selectedCount = document.getElementById('selected-count');
if (count > 0) {
bulkDeleteBtn.style.display = 'block';
selectedCount.textContent = count;
} else {
bulkDeleteBtn.style.display = 'none';
}
// Update select-all checkbox state
const allCheckboxes = document.querySelectorAll('.content-checkbox');
const selectAllCheckbox = document.getElementById('select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = allCheckboxes.length > 0 && count === allCheckboxes.length;
selectAllCheckbox.indeterminate = count > 0 && count < allCheckboxes.length;
}
}
function bulkDeleteSelected() {
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
const contentIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.contentId));
if (contentIds.length === 0) {
alert('No items selected');
return;
}
const confirmMsg = `Are you sure you want to remove ${contentIds.length} item(s) from this playlist?`;
if (!confirm(confirmMsg)) {
return;
}
// Show loading state
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
const originalText = bulkDeleteBtn.innerHTML;
bulkDeleteBtn.disabled = true;
bulkDeleteBtn.innerHTML = '⏳ Removing...';
fetch('{{ url_for("content.bulk_remove_from_playlist", playlist_id=playlist.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content_ids: contentIds })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Reload page to show updated playlist
window.location.reload();
} else {
alert('Error removing items: ' + data.message);
bulkDeleteBtn.disabled = false;
bulkDeleteBtn.innerHTML = originalText;
}
})
.catch(error => {
console.error('Error:', error);
alert('Error removing items from playlist');
bulkDeleteBtn.disabled = false;
bulkDeleteBtn.innerHTML = originalText;
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,806 @@
{% extends "base.html" %}
{% block title %}Media Library - DigiServer v2{% endblock %}
{% block content %}
<style>
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
margin-top: 20px;
}
.media-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 10px;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
}
.media-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
body.dark-mode .media-card {
background: #1a202c;
border-color: #4a5568;
}
.media-thumbnail {
width: 100%;
height: 150px;
border-radius: 6px;
overflow: hidden;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
body.dark-mode .media-thumbnail {
background: #2d3748;
}
.media-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-icon {
font-size: 64px;
}
.media-info {
font-size: 12px;
color: #6c757d;
}
body.dark-mode .media-info {
color: #a0aec0;
}
.media-filename {
font-weight: 500;
margin-bottom: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.dark-mode .media-filename {
color: #e2e8f0;
}
.delete-btn {
position: absolute;
top: 5px;
right: 5px;
background: #dc3545;
color: white;
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
opacity: 0;
transition: opacity 0.3s ease;
}
.media-card:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: #a02834;
}
.playlist-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
margin-top: 5px;
}
.playlist-badge.in-use {
background: #ffc107;
color: #000;
}
.playlist-badge.unused {
background: #28a745;
color: white;
}
body.dark-mode .playlist-badge.in-use {
background: #856404;
color: #ffc107;
}
body.dark-mode .upload-section {
background: #2d3748 !important;
border: 1px solid #4a5568;
}
body.dark-mode .playlist-badge.unused {
background: #1a4d2e;
color: #86efac;
}
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.type-badge.image { background: #d4edda; color: #155724; }
.type-badge.video { background: #cce5ff; color: #004085; }
.type-badge.pdf { background: #fff3cd; color: #856404; }
.type-badge.pptx { background: #f8d7da; color: #721c24; }
body.dark-mode .type-badge.image { background: #1a4d2e; color: #86efac; }
body.dark-mode .type-badge.video { background: #1e3a5f; color: #93c5fd; }
body.dark-mode .type-badge.pdf { background: #4a3800; color: #fbbf24; }
body.dark-mode .type-badge.pptx { background: #4a1a1a; color: #fca5a5; }
.stats-box {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.stat-item {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
text-align: center;
}
body.dark-mode .stat-item {
background: #1a202c;
border-color: #4a5568;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #7c3aed;
}
body.dark-mode .stat-value {
color: #a78bfa;
}
.stat-label {
font-size: 14px;
color: #6c757d;
margin-top: 5px;
}
body.dark-mode .stat-label {
color: #a0aec0;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin: 30px 0 15px 0;
padding-bottom: 10px;
border-bottom: 2px solid #7c3aed;
}
body.dark-mode .section-header {
border-bottom-color: #a78bfa;
}
.section-header h2 {
margin: 0;
font-size: 24px;
}
body.dark-mode .section-header h2 {
color: #e2e8f0;
}
</style>
<div style="margin-bottom: 2rem;">
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 32px; height: 32px;">
📚 Media Library
</h1>
<a href="{{ url_for('content.content_list') }}" class="btn" style="float: right; display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Back to Playlists
</a>
</div>
<!-- Statistics -->
<div class="stats-box">
<div class="stat-item">
<div class="stat-value">{{ media_files|length }}</div>
<div class="stat-label">Total Files</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ images|length }}</div>
<div class="stat-label">Images</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ videos|length }}</div>
<div class="stat-label">Videos</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ pdfs|length }}</div>
<div class="stat-label">PDFs</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ presentations|length }}</div>
<div class="stat-label">Presentations</div>
</div>
</div>
<!-- Upload Button -->
<div class="upload-section" style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 8px; margin-bottom: 30px;">
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload New Media
</a>
</div>
<!-- Images Section -->
{% if images %}
<div class="section-header">
<span style="font-size: 32px;">📷</span>
<h2>Images ({{ images|length }})</h2>
</div>
<div class="media-grid">
{% for media in images %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
alt="{{ media.filename }}"
onerror="this.style.display='none'; this.parentElement.innerHTML='<span class=\'media-icon\'>📷</span>'">
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge image">Image</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Videos Section -->
{% if videos %}
<div class="section-header">
<span style="font-size: 32px;">🎥</span>
<h2>Videos ({{ videos|length }})</h2>
</div>
<div class="media-grid">
{% for media in videos %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">🎥</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge video">Video</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- PDFs Section -->
{% if pdfs %}
<div class="section-header">
<span style="font-size: 32px;">📄</span>
<h2>PDFs ({{ pdfs|length }})</h2>
</div>
<div class="media-grid">
{% for media in pdfs %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📄</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge pdf">PDF</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Presentations Section -->
{% if presentations %}
<div class="section-header">
<span style="font-size: 32px;">📊</span>
<h2>Presentations ({{ presentations|length }})</h2>
</div>
<div class="media-grid">
{% for media in presentations %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📊</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge pptx">PPTX</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Others Section -->
{% if others %}
<div class="section-header">
<span style="font-size: 32px;">📁</span>
<h2>Other Files ({{ others|length }})</h2>
</div>
<div class="media-grid">
{% for media in others %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📁</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge">{{ media.content_type }}</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if not media_files %}
<div style="text-align: center; padding: 80px 20px;">
<div style="font-size: 96px; margin-bottom: 20px;">📭</div>
<h2 style="color: #6c757d;">No Media Files Yet</h2>
<p style="color: #999; margin-bottom: 30px;">Start by uploading your first media file!</p>
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload Media
</a>
</div>
{% endif %}
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="delete-modal">
<div class="delete-modal-content">
<div class="delete-modal-header">
<span class="delete-icon">⚠️</span>
<h2>Confirm Delete</h2>
</div>
<div class="delete-modal-body">
<p class="delete-question">
Are you sure you want to delete <strong id="deleteFilename" class="delete-filename"></strong>?
</p>
<div id="playlistWarning" class="warning-box warning-playlist">
<div class="warning-header">
<span>⚠️</span>
<strong>Playlist Warning</strong>
</div>
<p>This file is used in <strong id="playlistCount"></strong> playlist(s). Deleting it will remove it from all playlists and increment their version numbers.</p>
</div>
<div id="editWarning" class="warning-box warning-edit">
<div class="warning-header">
<span>✏️</span>
<strong>Edited Versions</strong>
</div>
<p>This file has <strong id="editCount"></strong> edited version(s) from player devices. All edited versions and their metadata will also be permanently deleted.</p>
</div>
<div class="delete-final-warning">
<strong>⚠️ This action cannot be undone!</strong>
</div>
</div>
<div class="delete-modal-footer">
<button onclick="closeDeleteModal()" class="btn btn-cancel">
Cancel
</button>
<form id="deleteForm" method="POST">
<button type="submit" class="btn btn-delete">
Yes, Delete File
</button>
</form>
</div>
</div>
</div>
<style>
/* Delete Modal - Light Mode Styles */
.delete-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.delete-modal-content {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
margin: 8% auto;
padding: 0;
border-radius: 16px;
width: 90%;
max-width: 550px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideDown 0.3s ease-out;
border: 1px solid rgba(0, 0, 0, 0.1);
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.delete-modal-header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
color: white;
padding: 24px 30px;
border-radius: 16px 16px 0 0;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
}
.delete-icon {
font-size: 2rem;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.delete-modal-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.delete-modal-body {
padding: 30px;
}
.delete-question {
font-size: 1.1rem;
margin: 0 0 20px 0;
color: #2d3748;
line-height: 1.6;
}
.delete-filename {
color: #dc3545;
word-break: break-word;
}
.warning-box {
display: none;
padding: 16px;
margin: 16px 0;
border-radius: 10px;
border-left: 4px solid;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: transform 0.2s ease;
}
.warning-box:hover {
transform: translateX(4px);
}
.warning-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.warning-header span {
font-size: 1.2rem;
}
.warning-header strong {
font-size: 1rem;
font-weight: 600;
}
.warning-box p {
margin: 0;
line-height: 1.5;
font-size: 0.95rem;
}
.warning-playlist {
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
border-color: #ffc107;
}
.warning-playlist .warning-header,
.warning-playlist p {
color: #856404;
}
.warning-edit {
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
border-color: #7c3aed;
}
.warning-edit .warning-header,
.warning-edit p {
color: #5b21b6;
}
.delete-final-warning {
background: linear-gradient(135deg, #fee 0%, #fdd 100%);
border: 2px solid #dc3545;
border-radius: 8px;
padding: 12px 16px;
margin: 20px 0 0 0;
text-align: center;
}
.delete-final-warning strong {
color: #dc3545;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.delete-modal-footer {
padding: 20px 30px;
display: flex;
gap: 12px;
justify-content: flex-end;
background: rgba(0, 0, 0, 0.02);
border-radius: 0 0 16px 16px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.delete-modal-footer form {
margin: 0;
}
.btn-cancel {
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
color: white;
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.btn-cancel:hover {
background: linear-gradient(135deg, #5a6268 0%, #4e555b 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
transform: translateY(-2px);
}
.btn-delete {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
color: white;
padding: 12px 28px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(220, 53, 69, 0.3);
}
.btn-delete:hover {
background: linear-gradient(135deg, #c82333 0%, #bd2130 100%);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.5);
transform: translateY(-2px);
}
/* Delete Modal - Dark Mode Styles */
body.dark-mode .delete-modal {
background-color: rgba(0, 0, 0, 0.8);
}
body.dark-mode .delete-modal-content {
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
border: 1px solid #4a5568;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
body.dark-mode .delete-modal-header {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.4);
}
body.dark-mode .delete-question {
color: #e2e8f0;
}
body.dark-mode .delete-filename {
color: #f87171;
}
body.dark-mode .warning-playlist {
background: linear-gradient(135deg, #422006 0%, #713f12 100%);
border-color: #fbbf24;
}
body.dark-mode .warning-playlist .warning-header,
body.dark-mode .warning-playlist p {
color: #fde68a;
}
body.dark-mode .warning-edit {
background: linear-gradient(135deg, #3b0764 0%, #581c87 100%);
border-color: #a78bfa;
}
body.dark-mode .warning-edit .warning-header,
body.dark-mode .warning-edit p {
color: #ddd6fe;
}
body.dark-mode .delete-final-warning {
background: linear-gradient(135deg, #450a0a 0%, #7f1d1d 100%);
border-color: #f87171;
}
body.dark-mode .delete-final-warning strong {
color: #fca5a5;
}
body.dark-mode .delete-modal-footer {
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid #4a5568;
}
body.dark-mode .btn-cancel {
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
body.dark-mode .btn-cancel:hover {
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
body.dark-mode .btn-delete {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
box-shadow: 0 2px 6px rgba(185, 28, 28, 0.4);
}
body.dark-mode .btn-delete:hover {
background: linear-gradient(135deg, #991b1b 0%, #7f1d1d 100%);
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.6);
}
</style>
<script>
let deleteMediaId = null;
function confirmDelete(mediaId, filename, playlistCount, editCount) {
deleteMediaId = mediaId;
document.getElementById('deleteFilename').textContent = filename;
document.getElementById('deleteForm').action = "{{ url_for('content.delete_media', media_id=0) }}".replace('/0', '/' + mediaId);
// Show playlist warning if file is in use
if (playlistCount > 0) {
document.getElementById('playlistWarning').style.display = 'block';
document.getElementById('playlistCount').textContent = playlistCount;
} else {
document.getElementById('playlistWarning').style.display = 'none';
}
// Show edit versions warning if file has been edited
if (editCount > 0) {
document.getElementById('editWarning').style.display = 'block';
document.getElementById('editCount').textContent = editCount;
} else {
document.getElementById('editWarning').style.display = 'none';
}
document.getElementById('deleteModal').style.display = 'block';
}
function closeDeleteModal() {
document.getElementById('deleteModal').style.display = 'none';
deleteMediaId = null;
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('deleteModal');
if (event.target == modal) {
closeDeleteModal();
}
}
// Close modal with ESC key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeDeleteModal();
}
});
</script>
{% endblock %}

View File

@@ -296,6 +296,17 @@
</small>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; cursor: pointer; user-select: none;">
<input type="checkbox" name="edit_on_player_enabled" id="edit_on_player_enabled"
value="1" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span>Allow editing on player (PDF, Images, PPTX)</span>
</label>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
✏️ Enable local editing of this media on the player device
</small>
</div>
<!-- Upload Button -->
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">

View File

@@ -123,7 +123,7 @@ body.dark-mode .log-item {
[{{ log.level.upper() }}]
</span>
{{ log.message }}
<small class="secondary-text" style="float: right;">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
<small class="secondary-text" style="float: right;">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
</div>
{% endfor %}
</div>

View File

@@ -0,0 +1,525 @@
{% extends "base.html" %}
{% block title %}Edited Media - {{ player.name }}{% endblock %}
{% block content %}
<style>
.expandable-card {
width: 100%;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border: 2px solid #cbd5e1;
border-radius: 12px;
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
}
.expandable-card:hover {
border-color: #7c3aed;
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.expandable-card.expanded {
border-color: #7c3aed;
cursor: default;
}
.card-header {
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.3s ease;
}
.expandable-card:not(.expanded) .card-header:hover {
background: rgba(124, 58, 237, 0.05);
}
.card-header-title {
display: flex;
align-items: center;
gap: 1rem;
font-size: 1.2rem;
font-weight: 600;
color: #1a202c;
}
.card-header-icon {
font-size: 1.5rem;
transition: transform 0.3s ease;
}
.expandable-card.expanded .card-header-icon {
transform: rotate(90deg);
}
.card-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease;
}
.expandable-card.expanded .card-content {
max-height: 800px;
}
.card-body {
padding: 0 1.5rem 1.5rem 1.5rem;
display: grid;
grid-template-columns: 350px 1fr;
gap: 2rem;
}
.preview-area {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.preview-container {
width: 100%;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.preview-container img {
width: 100%;
height: 100%;
object-fit: contain;
}
.preview-info {
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
font-size: 0.9rem;
}
.preview-info-item {
padding: 0.5rem 0;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: start;
}
.preview-info-item:last-child {
border-bottom: none;
}
.preview-info-label {
font-weight: 600;
color: #64748b;
min-width: 80px;
}
.preview-info-value {
color: #1a202c;
text-align: right;
word-break: break-word;
flex: 1;
}
.versions-area {
display: flex;
flex-direction: column;
}
.versions-title {
font-size: 1.1rem;
font-weight: 600;
color: #7c3aed;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.versions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
max-height: 600px;
overflow-y: auto;
padding-right: 0.5rem;
}
.version-item {
background: white;
border: 2px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
cursor: pointer;
}
.version-item:hover {
border-color: #7c3aed;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
}
.version-item.active {
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
}
.version-thumbnail {
width: 100%;
aspect-ratio: 16/9;
background: #000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.version-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-label {
padding: 0.75rem;
text-align: center;
font-weight: 600;
color: #1a202c;
background: #f8f9fa;
}
.version-item.active .version-label {
background: #7c3aed;
color: white;
}
.version-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 0.5rem;
}
.badge-latest {
background: #7c3aed;
color: white;
}
.badge-original {
background: #10b981;
color: white;
}
.download-btn {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
}
.download-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(124, 58, 237, 0.4);
}
/* Dark mode styles */
body.dark-mode .expandable-card {
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
border-color: #4a5568;
}
body.dark-mode .expandable-card:hover,
body.dark-mode .expandable-card.expanded {
border-color: #7c3aed;
}
body.dark-mode .card-header-title {
color: #e2e8f0;
}
body.dark-mode .expandable-card:not(.expanded) .card-header:hover {
background: rgba(124, 58, 237, 0.15);
}
body.dark-mode .preview-info {
background: #1a202c;
}
body.dark-mode .preview-info-item {
border-bottom-color: #4a5568;
}
body.dark-mode .preview-info-label {
color: #94a3b8;
}
body.dark-mode .preview-info-value {
color: #e2e8f0;
}
body.dark-mode .versions-title {
color: #a78bfa;
}
body.dark-mode .version-item {
background: #2d3748;
border-color: #4a5568;
}
body.dark-mode .version-item:hover,
body.dark-mode .version-item.active {
border-color: #7c3aed;
}
body.dark-mode .version-label {
background: #1a202c;
color: #e2e8f0;
}
body.dark-mode .version-item.active .version-label {
background: #7c3aed;
color: white;
}
</style>
<div style="margin-bottom: 2rem;">
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="btn"
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; display: inline-flex; align-items: center; gap: 0.5rem;">
← Back to Player
</a>
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 32px; height: 32px;">
Edited Media - {{ player.name }}
</h1>
</div>
<p style="color: #6c757d; font-size: 1rem;">Complete history of media files edited on this player</p>
</div>
{% if edited_media %}
{% set edited_by_content = {} %}
{% for edit in edited_media %}
{% if edit.content_id not in edited_by_content %}
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'versions': []}}) %}
{% endif %}
{% set _ = edited_by_content[edit.content_id]['versions'].append(edit) %}
{% endfor %}
<!-- Expandable Cards -->
{% for content_id, data in edited_by_content.items() %}
{% set original_content = content_files.get(content_id) %}
<div class="expandable-card" id="card-{{ content_id }}" onclick="toggleCard({{ content_id }})">
<div class="card-header">
<div class="card-header-title">
<span class="card-header-icon"></span>
<span>📄 {{ original_content.filename if original_content else data.original_name }}</span>
<span style="font-size: 0.9rem; color: #64748b; font-weight: normal;">
({{ data.versions|length + 1 }} version{{ 's' if (data.versions|length + 1) > 1 else '' }})
</span>
</div>
</div>
<div class="card-content">
<div class="card-body">
<!-- Preview Area (Left Column) -->
<div class="preview-area">
<div class="preview-container" id="preview-{{ content_id }}">
{% set latest = data.versions|sort(attribute='version', reverse=True)|first %}
{% if latest.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
alt="{{ latest.new_name }}"
id="preview-img-{{ content_id }}">
{% else %}
<div style="color: white; text-align: center;">
<div style="font-size: 3rem; margin-bottom: 0.5rem;">📄</div>
<div>No preview available</div>
</div>
{% endif %}
</div>
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
download
class="download-btn"
id="download-btn-{{ content_id }}">
💾 Download File
</a>
<div class="preview-info" id="info-{{ content_id }}">
<div class="preview-info-item">
<span class="preview-info-label">📄 Filename:</span>
<span class="preview-info-value" id="info-filename-{{ content_id }}">{{ latest.new_name }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">📦 Version:</span>
<span class="preview-info-value" id="info-version-{{ content_id }}">v{{ latest.version }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">👤 Edited by:</span>
<span class="preview-info-value" id="info-user-{{ content_id }}">{{ user_mappings.get(latest.user, latest.user or 'Unknown') }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">🕒 Modified:</span>
<span class="preview-info-value" id="info-date-{{ content_id }}">{{ latest.time_of_modification | localtime('%Y-%m-%d %H:%M') if latest.time_of_modification else 'N/A' }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">📅 Uploaded:</span>
<span class="preview-info-value" id="info-created-{{ content_id }}">{{ latest.created_at | localtime('%Y-%m-%d %H:%M') }}</span>
</div>
</div>
</div>
<!-- Versions Area (Right Column) -->
<div class="versions-area">
<div class="versions-title">
📚 All Versions
</div>
<div class="versions-grid">
{% for edit in data.versions|sort(attribute='version', reverse=True) %}
<div class="version-item {% if loop.first %}active{% endif %}"
id="version-{{ content_id }}-{{ edit.version }}"
onclick="event.stopPropagation(); selectVersion({{ content_id }}, {{ edit.version }}, '{{ edit.new_name }}', '{{ user_mappings.get(edit.user, edit.user or 'Unknown') }}', '{{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') if edit.time_of_modification else 'N/A' }}', '{{ edit.created_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}')">
<div class="version-thumbnail">
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
alt="Version {{ edit.version }}">
{% else %}
<div style="color: white; font-size: 2rem;">📄</div>
{% endif %}
</div>
<div class="version-label">
v{{ edit.version }}
{% if loop.first %}
<span class="version-badge badge-latest">Latest</span>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Original File -->
{% if original_content %}
<div class="version-item"
id="version-{{ content_id }}-original"
onclick="event.stopPropagation(); selectVersion({{ content_id }}, 'original', '{{ original_content.filename }}', 'System', 'N/A', '{{ original_content.uploaded_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/' ~ original_content.filename) }}')">
<div class="version-thumbnail">
{% if original_content.filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<img src="{{ url_for('static', filename='uploads/' ~ original_content.filename) }}"
alt="Original">
{% else %}
<div style="color: white; font-size: 2rem;">📄</div>
{% endif %}
</div>
<div class="version-label">
Original
<span class="version-badge badge-original">Source</span>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
<!-- Summary Statistics -->
<div class="card" style="margin-top: 2rem; background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); color: white; border-radius: 12px; overflow: hidden;">
<div style="padding: 1.5rem; display: flex; justify-content: space-around; align-items: center; flex-wrap: wrap; gap: 2rem;">
<div style="text-align: center;">
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_by_content|length }}</div>
<div style="font-size: 0.9rem; opacity: 0.9;">Total Files Edited</div>
</div>
<div style="text-align: center;">
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_media|length }}</div>
<div style="font-size: 0.9rem; opacity: 0.9;">Total Versions</div>
</div>
<div style="text-align: center;">
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ ((edited_media|length / edited_by_content|length) | round(1)) if edited_by_content else 0 }}</div>
<div style="font-size: 0.9rem; opacity: 0.9;">Avg Versions per File</div>
</div>
</div>
</div>
{% else %}
<div class="card" style="text-align: center; padding: 4rem 2rem;">
<div style="font-size: 4rem; margin-bottom: 1rem; opacity: 0.5;">📭</div>
<h2 style="color: #6c757d; margin-bottom: 1rem;">No Edited Media Yet</h2>
<p style="color: #6c757d; font-size: 1.1rem;">
This player hasn't edited any media files yet. When the player edits content,<br>
all versions will be tracked and displayed here.
</p>
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="btn"
style="margin-top: 2rem; display: inline-block; background: #7c3aed; color: white; padding: 0.75rem 1.5rem; text-decoration: none; border-radius: 6px; font-size: 1rem;">
← Back to Player Management
</a>
</div>
{% endif %}
<script>
function toggleCard(contentId) {
const card = document.getElementById('card-' + contentId);
const wasExpanded = card.classList.contains('expanded');
// Close all cards
document.querySelectorAll('.expandable-card').forEach(c => {
c.classList.remove('expanded');
});
// If this card wasn't expanded, expand it
if (!wasExpanded) {
card.classList.add('expanded');
}
}
function selectVersion(contentId, version, filename, user, modifiedDate, createdDate, fileUrl) {
// Update preview image
const previewImg = document.getElementById('preview-img-' + contentId);
if (previewImg) {
previewImg.src = fileUrl;
}
// Update download button
const downloadBtn = document.getElementById('download-btn-' + contentId);
if (downloadBtn) {
downloadBtn.href = fileUrl;
}
// Update metadata
document.getElementById('info-filename-' + contentId).textContent = filename;
document.getElementById('info-version-' + contentId).textContent = 'v' + version;
document.getElementById('info-user-' + contentId).textContent = user;
document.getElementById('info-date-' + contentId).textContent = modifiedDate;
document.getElementById('info-created-' + contentId).textContent = createdDate;
// Update active state on version items
document.querySelectorAll('.version-item').forEach(item => {
if (item.id.startsWith('version-' + contentId + '-')) {
item.classList.remove('active');
}
});
document.getElementById('version-' + contentId + '-' + version).classList.add('active');
}
</script>
{% endblock %}

View File

@@ -211,18 +211,155 @@
body.dark-mode .playlist-stats > div > div:first-child {
color: #a0aec0;
}
/* Player Logs Dark Mode */
body.dark-mode .card p[style*="color: #6c757d"] {
color: #a0aec0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] {
background: #2d3748 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p {
color: #e2e8f0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p[style*="color: #6c757d"] {
color: #a0aec0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] small {
color: #a0aec0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] details summary {
color: #f87171 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] pre {
background: #1a202c !important;
border-color: #4a5568 !important;
color: #e2e8f0 !important;
}
body.dark-mode .card > div[style*="text-align: center"] {
color: #a0aec0 !important;
}
/* Edited Media Cards Dark Mode */
body.dark-mode .card[style*="border: 2px solid #7c3aed"] {
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%) !important;
border-color: #8b5cf6 !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] h3 {
color: #a78bfa !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] div[style*="background: white"] {
background: #374151 !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] strong {
color: #a78bfa !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #475569"],
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #64748b"] {
color: #cbd5e1 !important;
}
body.dark-mode div[style*="background: #f8f9fa; border-radius: 8px"] {
background: #2d3748 !important;
}
body.dark-mode .card > div[style*="text-align: center"] p {
color: #a0aec0 !important;
}
</style>
<div style="margin-bottom: 2rem;">
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<div style="margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: flex-start;">
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
Manage Player: {{ player.name }}
</h1>
<a href="{{ url_for('players.list') }}" class="btn" style="float: right; display: inline-flex; align-items: center; gap: 0.5rem;">
<a href="{{ url_for('players.list') }}" class="btn" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Back to Players
</a>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4);">
<div style="background-color: #fefefe; margin: 15% auto; padding: 20px; border: 1px solid #888; border-radius: 8px; width: 90%; max-width: 500px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<h2 style="margin-top: 0; color: #dc3545; display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 1.5rem;">⚠️</span>
Confirm Delete
</h2>
<p style="font-size: 1.1rem; margin: 1.5rem 0;">
Are you sure you want to delete player <strong>"{{ player.name }}"</strong>?
</p>
<p style="color: #dc3545; margin: 1rem 0;">
<strong>Warning:</strong> This action cannot be undone. All feedback logs for this player will also be deleted.
</p>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 2rem;">
<button onclick="closeDeleteModal()" class="btn" style="background: #6c757d;">
Cancel
</button>
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}" style="margin: 0;">
<button type="submit" class="btn" style="background: #dc3545;">
Yes, Delete Player
</button>
</form>
</div>
</div>
</div>
<style>
body.dark-mode #deleteModal > div {
background-color: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode #deleteModal h2 {
color: #f87171;
}
body.dark-mode #deleteModal p {
color: #e2e8f0;
}
body.dark-mode #deleteModal p strong {
color: #fbbf24;
}
</style>
<script>
function confirmDelete() {
document.getElementById('deleteModal').style.display = 'block';
}
function closeDeleteModal() {
document.getElementById('deleteModal').style.display = 'none';
}
// Close modal when clicking outside of it
window.onclick = function(event) {
const modal = document.getElementById('deleteModal');
if (event.target == modal) {
closeDeleteModal();
}
}
// Close modal with ESC key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeDeleteModal();
}
});
</script>
<!-- Player Status Overview -->
<div class="card status-card {% if player.status == 'online' %}online{% elif player.status == 'offline' %}offline{% else %}other{% endif %}">
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
@@ -247,7 +384,7 @@
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
<p><strong>Last Seen:</strong>
{% if player.last_seen %}
{{ player.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}
{{ player.last_seen | localtime('%Y-%m-%d %H:%M:%S') }}
{% else %}
Never
{% endif %}
@@ -279,7 +416,7 @@
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Last Updated</div>
<div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }}</div>
<div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at | localtime }}</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div>
@@ -334,23 +471,35 @@
</select>
</div>
<div class="info-box neutral">
<h4 style="margin: 0 0 1rem 0; font-size: 0.95rem; color: #495057;">🔑 Player Credentials</h4>
<div style="border-top: 1px solid #ddd; margin: 1.5rem 0; padding-top: 1.5rem;">
<h4 style="margin: 0 0 1rem 0; font-size: 0.95rem;">🔑 Authentication Settings</h4>
<div class="credential-item">
<span class="credential-label">Hostname</span>
<div class="credential-value">{{ player.hostname }}</div>
<div class="form-group">
<label for="hostname">Hostname *</label>
<input type="text" id="hostname" name="hostname" value="{{ player.hostname }}"
required minlength="3" class="form-control"
placeholder="e.g., tv-terasa">
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
This is the unique identifier for the player
</small>
</div>
<div class="credential-item">
<span class="credential-label">Auth Code</span>
<div class="credential-value">{{ player.auth_code }}</div>
<div class="form-group">
<label for="password">New Password (leave blank to keep current)</label>
<input type="password" id="password" name="password" class="form-control"
placeholder="Enter new password">
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
🔒 Optional: Set a new password for player authentication
</small>
</div>
<div class="credential-item">
<span class="credential-label">Quick Connect Code (Hashed)</span>
<div class="credential-value" style="font-size: 0.75rem;">{{ player.quickconnect_code or 'Not set' }}</div>
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">⚠️ This is the hashed version for security</small>
<div class="form-group">
<label for="quickconnect_code">Quick Connect Code</label>
<input type="text" id="quickconnect_code" name="quickconnect_code" class="form-control"
placeholder="e.g., 8887779">
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
🔗 Enter the plain text code (e.g., 8887779) - will be hashed automatically
</small>
</div>
</div>
@@ -390,7 +539,7 @@
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem; color: #6c757d;">
Updated: {{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }}
Updated: {{ current_playlist.updated_at | localtime }}
</p>
</div>
{% else %}
@@ -421,6 +570,10 @@
Edit Current Playlist
</a>
{% endif %}
<button onclick="confirmDelete()" class="btn" style="width: 100%; margin-top: 0.5rem; background: #dc3545; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<span style="font-size: 1.2rem;">🗑️</span>
Delete Player
</button>
</div>
</div>
@@ -469,7 +622,7 @@
{% endif %}
</div>
<small style="color: #6c757d; white-space: nowrap; margin-left: 1rem;">
{{ log.timestamp.strftime('%m/%d %H:%M') }}
{{ log.timestamp | localtime('%m/%d %H:%M') }}
</small>
</div>
</div>
@@ -485,13 +638,118 @@
</div>
<!-- Edited Media Section - Full Width -->
<div class="card" style="margin-top: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<h2 style="display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
Edited Media on the Player
</h2>
{% if edited_media %}
<a href="{{ url_for('players.edited_media', player_id=player.id) }}"
class="btn"
style="background: #7c3aed; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; transition: background 0.2s;"
onmouseover="this.style.background='#6d28d9'"
onmouseout="this.style.background='#7c3aed'">
📋 View All Edited Media
</a>
{% endif %}
</div>
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.5rem;">Latest 3 edited files with their most recent versions</p>
{% if edited_media %}
{% set edited_by_content = {} %}
{% for edit in edited_media %}
{% if edit.content_id not in edited_by_content %}
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'latest_version': edit}}) %}
{% endif %}
{% endfor %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;">
{% for content_id, data in edited_by_content.items() %}
{% if loop.index <= 3 %}
<div class="card" style="background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%); border: 2px solid #7c3aed; box-shadow: 0 2px 8px rgba(124, 58, 237, 0.1);">
{% set edit = data.latest_version %}
<!-- Image Preview if it's an image -->
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<div style="width: 100%; height: 200px; overflow: hidden; border-radius: 8px 8px 0 0; background: #000;">
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
alt="{{ edit.new_name }}"
style="width: 100%; height: 100%; object-fit: contain;">
</div>
{% endif %}
<div style="padding: 1rem;">
<h3 style="margin: 0 0 0.75rem 0; color: #7c3aed; font-size: 1rem; display: flex; align-items: center; gap: 0.5rem;">
✏️ {{ data.original_name }}
</h3>
<div style="padding: 0.75rem; background: white; border-radius: 6px; border-left: 4px solid #7c3aed; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
<strong style="color: #7c3aed; font-size: 0.95rem;">
Version {{ edit.version }}
<span style="background: #7c3aed; color: white; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; margin-left: 0.5rem;">Latest</span>
</strong>
<small style="color: #6c757d; white-space: nowrap;">
{{ edit.created_at | localtime('%m/%d %H:%M') }}
</small>
</div>
<p style="margin: 0.25rem 0; font-size: 0.85rem; color: #475569;">
📄 {{ edit.new_name }}
</p>
{% if edit.user %}
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
👤 {{ edit.user }}
</p>
{% endif %}
{% if edit.time_of_modification %}
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
🕒 {{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }}
</p>
{% endif %}
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
target="_blank"
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #7c3aed; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
onmouseover="this.style.background='#6d28d9'"
onmouseout="this.style.background='#7c3aed'">
📥 View File
</a>
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
download
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #64748b; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
onmouseover="this.style.background='#475569'"
onmouseout="this.style.background='#64748b'">
💾 Download
</a>
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<div style="text-align: center; padding: 3rem; color: #6c757d; background: #f8f9fa; border-radius: 8px; margin-top: 1.5rem;">
<p style="font-size: 2rem; margin: 0;">📝</p>
<p style="margin: 0.5rem 0 0 0; font-size: 1.1rem; font-weight: 500;">No edited media yet</p>
<p style="font-size: 0.9rem; margin: 0.5rem 0 0 0;">Media edits will appear here once the player sends edited files</p>
</div>
{% endif %}
</div>
<!-- Additional Info Section -->
<div class="card" style="margin-top: 2rem;">
<h2> Player Information</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-top: 1rem;">
<div>
<p><strong>Player ID:</strong> {{ player.id }}</p>
<p><strong>Created:</strong> {{ player.created_at.strftime('%Y-%m-%d %H:%M') if player.created_at else 'N/A' }}</p>
<p><strong>Created:</strong> {{ (player.created_at | localtime) if player.created_at else 'N/A' }}</p>
</div>
<div>
<p><strong>Orientation:</strong> {{ player.orientation }}</p>
@@ -500,7 +758,7 @@
<div>
<p><strong>Last Heartbeat:</strong>
{% if player.last_heartbeat %}
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M:%S') }}
{{ player.last_heartbeat | localtime('%Y-%m-%d %H:%M:%S') }}
{% else %}
Never
{% endif %}

View File

@@ -96,7 +96,7 @@
<div id="player-container">
<div class="loading" id="loading">Loading playlist...</div>
<img id="media-display" alt="Content">
<video id="video-display" style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video>
<video id="video-display" muted autoplay playsinline style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video>
<div class="no-content" id="no-content" style="display: none;">
<p>💭 No content in playlist</p>
<p style="font-size: 16px; margin-top: 10px; opacity: 0.7;">Add content to the playlist to preview</p>
@@ -149,6 +149,7 @@
if (item.type === 'video') {
videoDisplay.src = item.url;
videoDisplay.muted = item.muted !== false; // Muted unless explicitly set to false
videoDisplay.style.display = 'block';
videoDisplay.play();

View File

@@ -64,7 +64,7 @@
</tr>
<tr>
<td style="padding: 10px; font-weight: bold;">Created:</td>
<td style="padding: 10px;">{{ player.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td style="padding: 10px;">{{ player.created_at | localtime }}</td>
</tr>
</table>
</div>
@@ -188,7 +188,7 @@
{% 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.strftime('%Y-%m-%d %H:%M:%S') }}</small>
<small style="color: #6c757d;">{{ feedback.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
</td>
<td style="padding: 10px;">
{% if feedback.status == 'playing' %}

View File

@@ -162,16 +162,15 @@
{{ player.orientation or 'Landscape' }}
</td>
<td>
{% set status = player_statuses.get(player.id, {}) %}
{% if status.get('is_online') %}
{% if player.is_online %}
<span class="status-badge online">Online</span>
{% else %}
<span class="status-badge offline">Offline</span>
{% endif %}
</td>
<td>
{% if player.last_heartbeat %}
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }}
{% if player.last_seen %}
{{ player.last_seen | localtime }}
{% else %}
<span class="text-muted">Never</span>
{% endif %}

View File

@@ -369,6 +369,43 @@
background: #5a1e1e;
color: #ef5350;
}
/* Audio toggle styles */
.audio-toggle {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.audio-checkbox {
display: none;
}
.audio-label {
font-size: 20px;
transition: all 0.3s ease;
}
.audio-checkbox + .audio-label .audio-on {
display: none;
}
.audio-checkbox + .audio-label .audio-off {
display: inline;
}
.audio-checkbox:checked + .audio-label .audio-on {
display: inline;
}
.audio-checkbox:checked + .audio-label .audio-off {
display: none;
}
.audio-label:hover {
transform: scale(1.2);
}
</style>
<div class="playlist-container">
@@ -433,6 +470,7 @@
<th>Filename</th>
<th style="width: 100px;">Type</th>
<th style="width: 120px;">Duration (s)</th>
<th style="width: 80px;">Audio</th>
<th style="width: 100px;">Size</th>
<th style="width: 150px;">Actions</th>
</tr>
@@ -479,6 +517,24 @@
</button>
</div>
</td>
<td>
{% if content.content_type == 'video' %}
<label class="audio-toggle" onclick="event.stopPropagation()">
<input type="checkbox"
class="audio-checkbox"
data-content-id="{{ content.id }}"
{{ 'checked' if not content._playlist_muted else '' }}
onchange="toggleAudio({{ content.id }}, this.checked)"
onclick="event.stopPropagation()">
<span class="audio-label">
<span class="audio-on">🔊</span>
<span class="audio-off">🔇</span>
</span>
</label>
{% else %}
<span style="color: #999;"></span>
{% endif %}
</td>
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
<td>
<form method="POST"
@@ -764,6 +820,38 @@ function updateTotalDuration() {
}
});
}
function toggleAudio(contentId, enabled) {
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
const playerId = {{ player.id }};
const url = `/playlist/${playerId}/update-muted/${contentId}`;
const formData = new FormData();
formData.append('muted', muted ? 'true' : 'false');
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Audio setting updated:', enabled ? 'Enabled' : 'Muted');
} else {
alert('Error updating audio setting: ' + data.message);
// Revert checkbox on error
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
}
})
.catch(error => {
console.error('Error:', error);
alert('Error updating audio setting');
// Revert checkbox on error
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
});
}
</script>
{% endblock %}

154
app/utils/caddy_manager.py Normal file
View File

@@ -0,0 +1,154 @@
"""Caddy configuration generator and manager."""
import os
from typing import Optional
from app.models.https_config import HTTPSConfig
class CaddyConfigGenerator:
"""Generate Caddyfile configuration based on HTTPSConfig."""
@staticmethod
def generate_caddyfile(config: Optional[HTTPSConfig] = None) -> str:
"""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

View File

@@ -0,0 +1,120 @@
"""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()

174
deploy.sh Executable file
View File

@@ -0,0 +1,174 @@
#!/bin/bash
# Automated deployment script for DigiServer on a new PC
# Run this script to completely set up DigiServer with all configurations
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ DigiServer Automated Deployment ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
# Check if docker-compose is available
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}❌ docker-compose not found!${NC}"
echo "Please install docker-compose first"
exit 1
fi
# Check if we're in the project directory
if [ ! -f "docker-compose.yml" ]; then
echo -e "${RED}❌ docker-compose.yml not found!${NC}"
echo "Please run this script from the digiserver-v2 directory"
exit 1
fi
# ============================================================================
# CONFIGURATION VARIABLES
# ============================================================================
HOSTNAME="${HOSTNAME:-digiserver}"
DOMAIN="${DOMAIN:-digiserver.sibiusb.harting.intra}"
IP_ADDRESS="${IP_ADDRESS:-10.76.152.164}"
EMAIL="${EMAIL:-admin@example.com}"
PORT="${PORT:-443}"
echo -e "${BLUE}Configuration:${NC}"
echo " Hostname: $HOSTNAME"
echo " Domain: $DOMAIN"
echo " IP Address: $IP_ADDRESS"
echo " Email: $EMAIL"
echo " Port: $PORT"
echo ""
# ============================================================================
# STEP 1: Start containers
# ============================================================================
echo -e "${YELLOW}📦 [1/6] Starting containers...${NC}"
docker-compose up -d
echo -e "${YELLOW}⏳ Waiting for containers to be healthy...${NC}"
sleep 10
# Verify containers are running
if ! docker-compose ps | grep -q "Up"; then
echo -e "${RED}❌ Containers failed to start!${NC}"
docker-compose logs
exit 1
fi
echo -e "${GREEN}✅ Containers started successfully${NC}"
echo ""
# ============================================================================
# STEP 2: Run database migrations
# ============================================================================
echo -e "${YELLOW}📊 [2/6] Running database migrations...${NC}"
echo -e " • Creating https_config table..."
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
echo -e " • Creating player_user table..."
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
echo -e " • Adding email to https_config..."
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
echo -e " • Migrating player_user global settings..."
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
echo -e "${GREEN}✅ All database migrations completed${NC}"
echo ""
# ============================================================================
# STEP 3: Configure HTTPS
# ============================================================================
echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS...${NC}"
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
"$HOSTNAME" \
"$DOMAIN" \
"$EMAIL" \
"$IP_ADDRESS" \
"$PORT"
echo -e "${GREEN}✅ HTTPS configured successfully${NC}"
echo ""
# ============================================================================
# STEP 4: Verify database setup
# ============================================================================
echo -e "${YELLOW}🔍 [4/6] Verifying database setup...${NC}"
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from sqlalchemy import inspect
app = create_app()
with app.app_context():
inspector = inspect(app.extensions.db.engine)
tables = inspector.get_table_names()
print(' Database tables:')
for table in sorted(tables):
print(f' ✓ {table}')
print(f'')
print(f' ✅ Total tables: {len(tables)}')
" 2>/dev/null || echo " ⚠️ Database verification skipped"
echo ""
# ============================================================================
# STEP 5: Verify Caddy configuration
# ============================================================================
echo -e "${YELLOW}🔧 [5/6] Verifying Caddy configuration...${NC}"
docker-compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1
if [ $? -eq 0 ]; then
echo -e " ${GREEN}✅ Caddy configuration is valid${NC}"
else
echo -e " ${YELLOW}⚠️ Caddy validation skipped${NC}"
fi
echo ""
# ============================================================================
# STEP 6: Display summary
# ============================================================================
echo -e "${YELLOW}📋 [6/6] Displaying configuration summary...${NC}"
echo ""
docker-compose exec -T digiserver-app python /app/https_manager.py status
echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ 🎉 Deployment Complete! ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BLUE}📍 Access Points:${NC}"
echo " 🔒 https://$HOSTNAME"
echo " 🔒 https://$IP_ADDRESS"
echo " 🔒 https://$DOMAIN"
echo ""
echo -e "${BLUE}📝 Default Credentials:${NC}"
echo " Username: admin"
echo " Password: admin123 (⚠️ CHANGE IN PRODUCTION)"
echo ""
echo -e "${BLUE}📚 Documentation:${NC}"
echo " • DEPLOYMENT_COMMANDS.md - Detailed docker exec commands"
echo " • HTTPS_CONFIGURATION.md - HTTPS setup details"
echo " • setup_https.sh - Manual configuration script"
echo ""
echo -e "${YELLOW}Next Steps:${NC}"
echo "1. Access the application at one of the URLs above"
echo "2. Log in with admin credentials"
echo "3. Change the default password immediately"
echo "4. Configure your players and content"
echo ""
echo -e "${BLUE}📞 Support:${NC}"
echo "For troubleshooting, see DEPLOYMENT_COMMANDS.md section 7"
echo ""

View File

@@ -1,14 +1,16 @@
#version: '3.8'
services:
digiserver:
digiserver-app:
build: .
container_name: digiserver-v2
ports:
- "80:5000"
# Don't expose directly; use Caddy reverse proxy instead
expose:
- "5000"
volumes:
- ./instance:/app/instance
- ./app/static/uploads:/app/app/static/uploads
- ./data:/app
- ./data/instance:/app/instance
- ./data/uploads:/app/app/static/uploads
environment:
- FLASK_ENV=production
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
@@ -21,14 +23,38 @@ services:
timeout: 10s
retries: 3
start_period: 40s
networks:
- digiserver-network
# Optional: Redis for caching (uncomment if needed)
# redis:
# image: redis:7-alpine
# container_name: digiserver-redis
# restart: unless-stopped
# volumes:
# - redis-data:/data
# Nginx reverse proxy with HTTPS support
nginx:
image: nginx:alpine
container_name: digiserver-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx-custom-domains.conf:/etc/nginx/conf.d/custom-domains.conf:rw
- ./data/nginx-ssl:/etc/nginx/ssl:ro
- ./data/nginx-logs:/var/log/nginx
- ./data/certbot:/var/www/certbot:ro # For Let's Encrypt ACME challenges
environment:
- DOMAIN=${DOMAIN:-localhost}
- EMAIL=${EMAIL:-admin@localhost}
depends_on:
digiserver-app:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- digiserver-network
# volumes:
# redis-data:
networks:
digiserver-network:
driver: bridge

View File

@@ -8,14 +8,14 @@ mkdir -p /app/instance
mkdir -p /app/app/static/uploads
# Initialize database if it doesn't exist
if [ ! -f /app/instance/digiserver.db ]; then
if [ ! -f /app/instance/dashboard.db ]; then
echo "Initializing database..."
python -c "
from app.app import create_app
from app.extensions import db, bcrypt
from app.models import User
app = create_app()
app = create_app('production')
with app.app_context():
db.create_all()
@@ -49,4 +49,4 @@ exec gunicorn \
--timeout 120 \
--access-logfile - \
--error-logfile - \
"app.app:create_app()"
"app.app:create_app('production')"

25
fix_player_user_schema.py Normal file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Fix player_user table schema by dropping and recreating it."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from app.models.player_user import PlayerUser
def main():
app = create_app('production')
with app.app_context():
# Drop the old table
print("Dropping player_user table...")
db.session.execute(db.text('DROP TABLE IF EXISTS player_user'))
db.session.commit()
# Recreate with new schema
print("Creating player_user table with new schema...")
PlayerUser.__table__.create(db.engine)
print("Done! player_user table recreated successfully.")
if __name__ == '__main__':
main()

30
generate_nginx_certs.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Generate self-signed SSL certificates for Nginx
# Usage: ./generate_nginx_certs.sh [domain] [days]
DOMAIN=${1:-localhost}
DAYS=${2:-365}
CERT_DIR="./data/nginx-ssl"
echo "🔐 Generating self-signed SSL certificate for Nginx"
echo "Domain: $DOMAIN"
echo "Valid for: $DAYS days"
echo "Certificate directory: $CERT_DIR"
# Create directory if it doesnt exist
mkdir -p "$CERT_DIR"
# Generate private key and certificate
openssl req -x509 -nodes -days "$DAYS" \
-newkey rsa:2048 \
-keyout "$CERT_DIR/key.pem" \
-out "$CERT_DIR/cert.pem" \
-subj "/CN=$DOMAIN/O=DigiServer/C=US"
# Set proper permissions
chmod 644 "$CERT_DIR/cert.pem"
chmod 600 "$CERT_DIR/key.pem"
echo "✅ Certificates generated successfully!"
echo "Certificate: $CERT_DIR/cert.pem"
echo "Key: $CERT_DIR/key.pem"

29
init-data.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
# Initialize ./data folder with all necessary files for deployment
set -e
echo "🔧 Initializing data folder..."
mkdir -p data/{app,instance,uploads}
echo "📁 Copying app folder..."
rm -rf data/app
mkdir -p data/app
cp -r app/* data/app/
echo "📋 Copying migrations..."
rm -rf data/migrations
cp -r migrations data/
echo "🔧 Copying utility scripts..."
cp fix_player_user_schema.py data/
echo "🔐 Setting permissions..."
chmod 755 data/{app,instance,uploads}
chmod -R 755 data/app/
find data/app -type f \( -name "*.py" -o -name "*.html" -o -name "*.css" -o -name "*.js" \) -exec chmod 644 {} \;
chmod 777 data/instance data/uploads
echo "✅ Data folder initialized successfully!"
echo "📊 Data folder contents:"
du -sh data/*/

4
install_emoji_fonts.sh Normal file → Executable file
View File

@@ -3,8 +3,8 @@
# Install emoji fonts for Raspberry Pi
echo "Installing emoji font support for Raspberry Pi..."
sudo apt-get update
sudo apt-get install -y fonts-noto-color-emoji fonts-noto-emoji
apt-get update -qq
apt-get install -y fonts-noto-color-emoji fonts-noto-emoji
echo "✅ Emoji fonts installed!"
echo "Please restart your browser to see the changes."

View File

@@ -9,12 +9,6 @@ echo "LibreOffice Installation Script"
echo "======================================"
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "❌ This script must be run as root or with sudo"
exit 1
fi
# Check if already installed
if command -v libreoffice &> /dev/null; then
VERSION=$(libreoffice --version 2>/dev/null || echo "Unknown")

View File

@@ -0,0 +1,33 @@
"""Add email field to https_config table."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app()
with app.app_context():
print("Adding email column to https_config table...")
try:
# Check if column already exists
inspector = db.inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('https_config')]
if 'email' not in columns:
# Add the column
with db.engine.connect() as conn:
conn.execute(text('ALTER TABLE https_config ADD COLUMN email VARCHAR(255)'))
conn.commit()
print("✓ Email column added to https_config table!")
else:
print("✓ Email column already exists in https_config table.")
except Exception as e:
print(f" Note: If table doesn't exist, run add_https_config_table.py first")
print(f"Error details: {str(e)}")
print("\nAlternatively, you can reset the database:")
print(" rm instance/digiserver.db")
print(" python migrations/add_https_config_table.py")

View File

@@ -0,0 +1,14 @@
"""Add https_config table for HTTPS configuration management."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from app.models.https_config import HTTPSConfig
app = create_app()
with app.app_context():
print("Creating https_config table...")
db.create_all()
print("✓ https_config table created successfully!")

View File

@@ -0,0 +1,14 @@
"""Add player_user table for user code mappings."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from app.models.player_user import PlayerUser
app = create_app()
with app.app_context():
print("Creating player_user table...")
db.create_all()
print("✓ player_user table created successfully!")

View File

@@ -0,0 +1,24 @@
"""Migrate player_user table to remove player_id and make user_code unique globally."""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app('production')
with app.app_context():
print("Migrating player_user table...")
# Drop existing table and recreate with new schema
db.session.execute(text('DROP TABLE IF EXISTS player_user'))
db.session.commit()
# Create new table
db.create_all()
print("✓ player_user table migrated successfully!")
print(" - Removed player_id foreign key")
print(" - Made user_code unique globally")
print(" - user_name is now nullable")

21
nginx-custom-domains.conf Normal file
View File

@@ -0,0 +1,21 @@
# Nginx configuration for custom HTTPS domains
# This file will be dynamically generated based on HTTPSConfig database entries
# Include this in your nginx.conf with: include /etc/nginx/conf.d/custom-domains.conf;
# Example entry for custom domain:
# server {
# listen 443 ssl http2;
# listen [::]:443 ssl http2;
# server_name digiserver.example.com;
#
# ssl_certificate /etc/nginx/ssl/custom/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/custom/key.pem;
#
# location / {
# proxy_pass http://digiserver_app;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }

117
nginx.conf Normal file
View File

@@ -0,0 +1,117 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 2048M;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml;
# Upstream to Flask application
upstream digiserver_app {
server digiserver-app:5000;
keepalive 32;
}
# HTTP Server - redirect to HTTPS
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# Allow ACME challenges for Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect HTTP to HTTPS for non-ACME requests
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS Server (with self-signed cert by default)
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
server_name localhost;
# SSL certificate paths (will be volume-mounted)
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# SSL Configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Proxy settings
location / {
proxy_pass http://digiserver_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
# Timeouts for large uploads
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Buffering
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# Static files caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
proxy_pass http://digiserver_app;
proxy_cache_valid 200 60d;
expires 60d;
add_header Cache-Control "public, immutable";
}
}
# Additional server blocks for custom domains can be included here
include /etc/nginx/conf.d/*.conf;
}

View File

@@ -5,13 +5,13 @@ FLASK_ENV=development
# Security
SECRET_KEY=change-this-to-a-random-secret-key
# Domain & SSL (for HTTPS with Caddy)
DOMAIN=your-domain.com
EMAIL=admin@your-domain.com
# Database
DATABASE_URL=sqlite:///instance/dev.db
# Redis (for production)
REDIS_HOST=redis
REDIS_PORT=6379
# Admin User Credentials (used during initial Docker deployment)
# These credentials are set when the database is first created
ADMIN_USERNAME=admin

View File

@@ -0,0 +1,295 @@
# Caddy Dynamic Configuration Management
## Overview
The HTTPS configuration system now automatically generates and manages the Caddy configuration in real-time. When an admin updates settings through the admin panel, the Caddyfile is regenerated and reloaded without requiring a full container restart.
## How It Works
### 1. **Configuration Generation**
When admin saves HTTPS settings:
1. Settings are saved to database (HTTPSConfig table)
2. `CaddyConfigGenerator` creates a new Caddyfile based on settings
3. Generated Caddyfile is written to disk
### 2. **Configuration Reload**
After Caddyfile is written:
1. Caddy reload API is called via `docker-compose exec`
2. Caddy validates and applies new configuration
3. No downtime - live configuration update
### 3. **Fallback Configuration**
If HTTPS is disabled:
1. System uses default hardcoded configuration
2. Supports localhost, internal domain, and IP address
3. Catch-all configuration for any other requests
## Files Involved
### New Files
- **`app/utils/caddy_manager.py`** - CaddyConfigGenerator class with:
- `generate_caddyfile()` - Generates Caddyfile content
- `write_caddyfile()` - Writes to disk
- `reload_caddy()` - Reloads via Docker
### Updated Files
- **`app/blueprints/admin.py`** - HTTPS config route now:
- Generates new Caddyfile
- Writes to disk
- Reloads Caddy automatically
- Reports status to user
## Admin Panel Workflow
### Step 1: User Fills Form
```
Admin Panel → HTTPS Configuration
- Hostname: digiserver
- Domain: digiserver.sibiusb.harting.intra
- Email: admin@example.com
- IP: 10.76.152.164
- Port: 443
```
### Step 2: Admin Saves Configuration
- POST /admin/https-config/update
- Settings validated and saved to database
- Caddyfile generated dynamically
- Caddy reloaded with new configuration
### Step 3: User Sees Confirmation
```
✅ HTTPS configuration saved successfully!
✅ Caddy configuration updated successfully!
Server available at https://digiserver.sibiusb.harting.intra
```
### Step 4: Configuration Live
- New domain/IP immediately active
- No container restart needed
- Caddy applying new routes in real-time
## Generated Caddyfile Structure
**When HTTPS Enabled:**
```caddyfile
{
email admin@example.com
}
(reverse_proxy_config) {
reverse_proxy digiserver-app:5000 { ... }
request_body { max_size 2GB }
header { ... }
log { ... }
}
http://localhost { import reverse_proxy_config }
http://digiserver.sibiusb.harting.intra { import reverse_proxy_config }
http://10.76.152.164 { import reverse_proxy_config }
http://* { import reverse_proxy_config }
```
**When HTTPS Disabled:**
```caddyfile
{
email admin@localhost
}
(reverse_proxy_config) { ... }
http://localhost { import reverse_proxy_config }
http://digiserver.sibiusb.harting.intra { import reverse_proxy_config }
http://10.76.152.164 { import reverse_proxy_config }
http://* { import reverse_proxy_config }
```
## Key Features
### ✅ No Restart Required
- Caddyfile changes applied without restarting containers
- Caddy reload API handles configuration hot-swap
- Zero downtime configuration updates
### ✅ Dynamic Configuration
- Settings in admin panel → Generated Caddyfile
- Database is source of truth
- Easy to modify in admin UI
### ✅ Automatic Fallbacks
- Catch-all `http://*` handles any host
- Always has localhost access
- Always has IP address access
### ✅ User Feedback
- Admin sees status of Caddy reload
- Error messages if Caddy reload fails
- Logging of all changes
### ✅ Safe Updates
- Caddyfile validation before reload
- Graceful error handling
- Falls back to previous config if reload fails
## Error Handling
If Caddy reload fails:
1. Database still has updated settings
2. Old Caddyfile may still be in use
3. User sees warning with status
4. Admin can manually restart: `docker-compose restart caddy`
## Admin Panel Status Messages
### Success (✅)
```
✅ HTTPS configuration saved successfully!
✅ Caddy configuration updated successfully!
Server available at https://domain.local
```
### Partial Success (⚠️)
```
✅ HTTPS configuration saved successfully!
⚠️ Caddyfile updated but reload failed. Please restart containers.
Server available at https://domain.local
```
### Configuration Saved, Update Failed (⚠️)
```
⚠️ Configuration saved but Caddy update failed: [error details]
```
## Testing Configuration
### Check Caddyfile Content
```bash
cat /srv/digiserver-v2/Caddyfile
```
### Manually Reload Caddy
```bash
docker-compose exec caddy caddy reload --config /etc/caddy/Caddyfile
```
### Check Caddy Status
```bash
docker-compose logs caddy --tail=20
```
### Test Access Points
```bash
# Test all configured domains/IPs
curl http://localhost
curl http://digiserver.sibiusb.harting.intra
curl http://10.76.152.164
```
## Configuration Database
Settings stored in `https_config` table:
```
https_enabled: boolean
hostname: string
domain: string
ip_address: string
email: string
port: integer
updated_at: datetime
updated_by: string
```
When admin updates form → Database updated → Caddyfile regenerated → Caddy reloaded
## Workflow Diagram
```
┌─────────────────────┐
│ Admin Panel Form │
│ (HTTPS Config) │
└──────────┬──────────┘
│ Submit
┌─────────────────────┐
│ Validate Input │
└──────────┬──────────┘
│ Valid
┌─────────────────────┐
│ Save to Database │
│ (HTTPSConfig) │
└──────────┬──────────┘
│ Saved
┌─────────────────────┐
│ Generate Caddyfile │
│ (CaddyConfigGen) │
└──────────┬──────────┘
│ Generated
┌─────────────────────┐
│ Write to Disk │
│ (/Caddyfile) │
└──────────┬──────────┘
│ Written
┌─────────────────────┐
│ Reload Caddy │
│ (Docker exec) │
└──────────┬──────────┘
│ Reloaded
┌─────────────────────┐
│ Show Status to │
│ Admin (Success) │
└─────────────────────┘
```
## Implementation Details
### CaddyConfigGenerator Class
**generate_caddyfile(config)**
- Takes HTTPSConfig from database
- Generates complete Caddyfile content
- Uses shared reverse proxy configuration template
- Returns full Caddyfile as string
**write_caddyfile(content, path)**
- Writes generated content to disk
- Path defaults to /srv/digiserver-v2/Caddyfile
- Returns True on success, False on error
**reload_caddy()**
- Runs: `docker-compose exec -T caddy caddy reload`
- Validates config and applies live
- Returns True on success, False on error
## Advantages Over Manual Configuration
| Manual | Dynamic |
|--------|---------|
| Edit Caddyfile manually | Change via admin panel |
| Restart container | No restart needed |
| Risk of syntax errors | Validated generation |
| No audit trail | Logged with username |
| Each change is manual | One-time setup |
## Future Enhancements
Potential improvements:
- Configuration history/backup
- Rollback to previous config
- Health check after reload
- Automatic backup before update
- Configuration templates
- Multi-domain support
## Support
For issues:
1. Check admin panel messages for Caddy reload status
2. Review logs: `docker-compose logs caddy`
3. Check Caddyfile: `cat /srv/digiserver-v2/Caddyfile`
4. Manual reload: `docker-compose exec caddy caddy reload --config /etc/caddy/Caddyfile`
5. Full restart: `docker-compose restart caddy`

View File

@@ -0,0 +1,75 @@
# Data Folder Deployment Guide
## Overview
The `./data` folder is the **persistent data storage** for the DigiServer deployment. It is **NOT committed to the repository** but contains all necessary files copied from the repo during deployment.
## Structure
```
data/
├── app/ # Complete application code (copied from ./app)
├── Caddyfile # Reverse proxy configuration (copied from root)
├── instance/ # Flask instance folder (database, configs)
├── uploads/ # User file uploads
├── caddy-data/ # Caddy SSL certificates and cache
└── caddy-config/ # Caddy configuration data
```
## Deployment Process
### Step 1: Initialize Data Folder
Run this script to copy all necessary files from the repository to `./data`:
```bash
./init-data.sh
```
This will:
- Create the `./data` directory structure
- Copy `./app` folder to `./data/app`
- Copy `Caddyfile` to `./data/Caddyfile`
- Set proper permissions for all files and folders
### Step 2: Start Docker Containers
```bash
docker-compose up -d --build
```
### Step 3: Run Migrations (First Time Only)
```bash
sudo bash deploy.sh
```
## Important Notes
- **./data is NOT in git**: The `./data` folder is listed in `.gitignore` and will not be committed
- **All persistent data here**: Database files, uploads, certificates, and configurations are stored in `./data`
- **Easy backups**: To backup the entire deployment, backup the `./data` folder
- **Easy troubleshooting**: Check the `./data` folder to verify all required files are present
- **Updates**: When you pull new changes, run `./init-data.sh` to update app files in `./data`
## Deployment Checklist
✓ All volumes in docker-compose.yml point to `./data`
`./data` folder contains: app/, Caddyfile, instance/, uploads/, caddy-data/, caddy-config/
✓ Files are copied from repository to `./data` via init-data.sh
✓ Permissions are correctly set for Docker container user
## Verification
Before starting:
```bash
ls -la data/
# Should show: app/, Caddyfile, instance/, uploads/, caddy-data/, caddy-config/
```
After deployment check data folder for:
```bash
data/instance/*.db # Database files
data/uploads/ # User uploads
data/caddy-data/*.pem # SSL certificates
```

View File

@@ -0,0 +1,272 @@
# DigiServer Deployment Commands
This document contains all necessary `docker exec` commands to deploy and configure DigiServer on a new PC with the same settings as the production system.
## Prerequisites
```bash
# Ensure you're in the project directory
cd /path/to/digiserver-v2
# Start the containers
docker-compose up -d
```
## 1. Database Initialization and Migrations
### Run all database migrations in sequence:
```bash
# Create https_config table
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
# Create player_user table
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
# Add email to https_config table
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
# Migrate player_user global settings
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
```
**Note:** The `-T` flag prevents Docker from allocating a pseudo-terminal, which is useful for automated deployments.
## 2. HTTPS Configuration via CLI
### Check HTTPS Configuration Status:
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py status
```
### Enable HTTPS with Production Settings:
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
digiserver \
digiserver.sibiusb.harting.intra \
admin@example.com \
10.76.152.164 \
443
```
### Show Detailed Configuration:
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py show
```
## 3. Admin User Setup
### Create/Reset Admin User (if needed):
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from app.models.user import User
from app.extensions import db
app = create_app()
with app.app_context():
# Check if admin exists
admin = User.query.filter_by(username='admin').first()
if admin:
print('✅ Admin user already exists')
else:
# Create new admin user
admin = User(username='admin', email='admin@example.com')
admin.set_password('admin123') # Change this password!
admin.is_admin = True
db.session.add(admin)
db.session.commit()
print('✅ Admin user created with username: admin')
"
```
## 4. Database Verification
### Check Database Tables:
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from app.extensions import db
from sqlalchemy import inspect
app = create_app()
with app.app_context():
inspector = inspect(db.engine)
tables = inspector.get_table_names()
print('📊 Database Tables:')
for table in sorted(tables):
print(f' ✓ {table}')
print(f'\\n✅ Total tables: {len(tables)}')
"
```
### Check HTTPS Configuration in Database:
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from app.models.https_config import HTTPSConfig
app = create_app()
with app.app_context():
config = HTTPSConfig.get_config()
if config:
print('✅ HTTPS Configuration Found:')
print(f' Status: {\"ENABLED\" if config.https_enabled else \"DISABLED\"}')
print(f' Hostname: {config.hostname}')
print(f' Domain: {config.domain}')
print(f' IP Address: {config.ip_address}')
print(f' Port: {config.port}')
else:
print('⚠️ No HTTPS configuration found')
"
```
## 5. Health Checks
### Test Caddy Configuration:
```bash
docker-compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile
```
### Test Flask Application Health:
```bash
docker-compose exec -T digiserver-app python -c "
import urllib.request
try:
response = urllib.request.urlopen('http://localhost:5000/health', timeout=5)
print('✅ Application is responding')
print(f' Status: {response.status}')
except Exception as e:
print(f'❌ Application health check failed: {e}')
"
```
### Check Docker Container Logs:
```bash
# Flask app logs
docker-compose logs digiserver-app | tail -50
# Caddy logs
docker-compose logs caddy | tail -50
```
## 6. Complete Deployment Script
Create a file called `deploy.sh` to run all steps automatically:
```bash
#!/bin/bash
set -e
echo "🚀 DigiServer Deployment Script"
echo "=================================="
echo ""
# Change to project directory
cd /path/to/digiserver-v2
# Step 1: Start containers
echo "📦 Starting containers..."
docker-compose up -d
sleep 5
# Step 2: Run migrations
echo "📊 Running database migrations..."
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
# Step 3: Configure HTTPS
echo "🔒 Configuring HTTPS..."
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
digiserver \
digiserver.sibiusb.harting.intra \
admin@example.com \
10.76.152.164 \
443
# Step 4: Verify setup
echo "✅ Verifying setup..."
docker-compose exec -T digiserver-app python /app/https_manager.py status
echo ""
echo "🎉 Deployment Complete!"
echo "=================================="
echo "Access your application at:"
echo " - https://digiserver"
echo " - https://10.76.152.164"
echo " - https://digiserver.sibiusb.harting.intra"
echo ""
echo "Login with:"
echo " Username: admin"
echo " Password: (check your password settings)"
```
Make it executable:
```bash
chmod +x deploy.sh
```
Run it:
```bash
./deploy.sh
```
## 7. Troubleshooting
### Restart Services:
```bash
# Restart all containers
docker-compose restart
# Restart just the app
docker-compose restart digiserver-app
# Restart just Caddy
docker-compose restart caddy
```
### View Caddy Configuration:
```bash
docker-compose exec -T caddy cat /etc/caddy/Caddyfile
```
### Test HTTPS Endpoints:
```bash
# Test from host machine (if accessible)
curl -k https://digiserver.sibiusb.harting.intra
# Test from within containers
docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/ | head -20
```
### Clear Caddy Cache (if certificate issues occur):
```bash
docker volume rm digiserver-v2_caddy-data
docker volume rm digiserver-v2_caddy-config
docker-compose restart caddy
```
## Important Notes
- Always use `-T` flag with `docker-compose exec` in automated scripts to prevent TTY issues
- Change default passwords (`admin123`) in production environments
- Adjust email address in HTTPS configuration as needed
- For different network setups, modify the IP address and domain in the enable HTTPS command
- Keep database backups before running migrations
- Test all three access points after deployment

View File

@@ -0,0 +1,278 @@
# 📚 DigiServer Deployment Documentation Index
Complete documentation for deploying and maintaining DigiServer. Choose your path below:
---
## 🚀 I Want to Deploy Now!
### Quick Start (2 minutes)
```bash
cd /path/to/digiserver-v2
./deploy.sh
```
→ See [DEPLOYMENT_README.md](DEPLOYMENT_README.md)
### Or Step-by-Step Setup
```bash
./setup_https.sh
```
---
## 📖 Documentation Files
### 1. **[DEPLOYMENT_README.md](DEPLOYMENT_README.md)** ⭐ START HERE
- **Size**: 9.4 KB
- **Purpose**: Complete deployment guide for beginners
- **Contains**:
- Quick start instructions
- Prerequisites checklist
- 3 deployment methods (auto, semi-auto, manual)
- Verification procedures
- First access setup
- Troubleshooting guide
- **Read time**: 15-20 minutes
### 2. **[DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md)** ⭐ REFERENCE GUIDE
- **Size**: 7.6 KB
- **Purpose**: Quick reference for all docker exec commands
- **Contains**:
- Database migrations
- HTTPS configuration
- User management
- Database inspection
- Health checks
- Maintenance commands
- Troubleshooting commands
- **Use when**: You need a specific command
- **Read time**: 5-10 minutes (or search for what you need)
### 3. **[DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md)**
- **Size**: 6.8 KB
- **Purpose**: Detailed deployment command explanations
- **Contains**:
- Individual command explanations
- Complete deployment script template
- Health check procedures
- Verification steps
- Advanced troubleshooting
- **Read time**: 20-30 minutes
---
## 🔧 Executable Scripts
### 1. **[deploy.sh](deploy.sh)** - Fully Automated
- **Size**: 6.7 KB
- **Purpose**: One-command deployment
- **Does**:
1. Starts Docker containers
2. Runs all migrations
3. Configures HTTPS
4. Verifies setup
5. Shows access URLs
- **Usage**:
```bash
./deploy.sh
```
- **With custom settings**:
```bash
HOSTNAME=server1 DOMAIN=server1.internal ./deploy.sh
```
### 2. **[setup_https.sh](setup_https.sh)** - Semi-Automated
- **Size**: 5.9 KB
- **Purpose**: Setup script that works in or outside Docker
- **Does**:
- Detects environment (Docker container or host)
- Runs migrations
- Configures HTTPS
- Shows status
- **Usage**:
```bash
./setup_https.sh
```
---
## 🎯 Quick Navigation by Task
### "I need to deploy on a new PC"
1. Read: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#prerequisites)
2. Run: `./deploy.sh`
3. Access: https://digiserver.sibiusb.harting.intra
### "I need a specific docker exec command"
→ Search [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md)
### "I want to understand what's being deployed"
→ Read [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md#prerequisites)
### "Something went wrong, help!"
→ See [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-troubleshooting) or [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-troubleshooting)
### "I need to configure custom settings"
→ Read [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-environment-variables)
### "I want to manage HTTPS"
→ See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-https-configuration-management)
---
## 📋 Deployment Checklist
- [ ] Docker and Docker Compose installed
- [ ] Project files copied to new PC
- [ ] Run `./deploy.sh` or `./setup_https.sh`
- [ ] Verify with `docker-compose ps`
- [ ] Access https://digiserver.sibiusb.harting.intra
- [ ] Log in with admin/admin123
- [ ] Change default password
- [ ] Configure players and content
---
## 🔑 Configuration Options
### Default Settings
```
Hostname: digiserver
Domain: digiserver.sibiusb.harting.intra
IP Address: 10.76.152.164
Port: 443
Email: admin@example.com
Username: admin
Password: admin123
```
### Customize During Deployment
```bash
HOSTNAME=myserver \
DOMAIN=myserver.internal \
IP_ADDRESS=192.168.1.100 \
EMAIL=admin@myserver.com \
./deploy.sh
```
---
## 🆘 Common Tasks
| Task | Command |
|------|---------|
| **Start containers** | `docker-compose up -d` |
| **Stop containers** | `docker-compose stop` |
| **View logs** | `docker-compose logs -f` |
| **Check HTTPS status** | `docker-compose exec -T digiserver-app python /app/https_manager.py status` |
| **Reset password** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#reset-admin-password) |
| **View all tables** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#list-all-tables) |
| **Create admin user** | See [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#create-admin-user) |
---
## 📊 File Structure
```
digiserver-v2/
├── DEPLOYMENT_README.md ..................... Main deployment guide
├── DOCKER_EXEC_COMMANDS.md ................. Quick reference (BEST FOR COMMANDS)
├── DEPLOYMENT_COMMANDS.md .................. Detailed explanations
├── deploy.sh ............................. Fully automated deployment
├── setup_https.sh ......................... Semi-automated setup
├── docker-compose.yml ..................... Docker services config
├── Caddyfile .............................. Reverse proxy config
├── requirements.txt ....................... Python dependencies
├── app/
│ ├── app.py ............................ Flask application
│ ├── models/ ........................... Database models
│ │ ├── https_config.py
│ │ ├── user.py
│ │ └── ...
│ └── ...
├── migrations/
│ ├── add_https_config_table.py
│ ├── add_player_user_table.py
│ ├── add_email_to_https_config.py
│ └── migrate_player_user_global.py
└── old_code_documentation/
├── HTTPS_CONFIGURATION.md
└── ...
```
---
## 🚀 Deployment Methods Comparison
| Method | Time | Effort | Best For |
|--------|------|--------|----------|
| `./deploy.sh` | 2-3 min | Click & wait | First-time setup, automation |
| `./setup_https.sh` | 3-5 min | Manual review | Learning, step debugging |
| Manual commands | 10-15 min | Full control | Advanced users, scripting |
---
## ✨ What Gets Deployed
✅ Flask web application with admin dashboard
✅ HTTPS with self-signed certificates
✅ Caddy reverse proxy for routing
✅ SQLite database with all tables
✅ User management system
✅ HTTPS configuration management
✅ Player and content management
✅ Group and playlist management
✅ Admin audit trail
---
## 🎓 Learning Path
1. **Total Beginner?**
- Start: [DEPLOYMENT_README.md](DEPLOYMENT_README.md)
- Run: `./deploy.sh`
- Learn: Browse [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md) for available commands
2. **Want to Understand Everything?**
- Read: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-deployment-methods) (all 3 methods)
- Study: [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md)
- Reference: [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md)
3. **Need to Troubleshoot?**
- Check: [DEPLOYMENT_README.md](DEPLOYMENT_README.md#-troubleshooting)
- Or: [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md#-troubleshooting)
---
## 💡 Pro Tips
1. **Use `-T` flag** in docker-compose exec for scripts (prevents TTY issues)
2. **Keep backups** before major changes
3. **Check logs often**: `docker-compose logs -f`
4. **Use environment variables** for custom deployments
5. **Verify after deployment** using health check commands
---
## 🔗 Related Documentation
- **HTTPS Setup**: `old_code_documentation/HTTPS_CONFIGURATION.md`
- **Admin Features**: Check admin panel after login
- **API Documentation**: See `old_code_documentation/PLAYER_EDIT_MEDIA_API.md`
---
## 📞 Support
- Logs: `docker-compose logs digiserver-app`
- Health Check: See [DOCKER_EXEC_COMMANDS.md#-health-checks](DOCKER_EXEC_COMMANDS.md#-health-checks)
- Troubleshooting: See [DEPLOYMENT_README.md#-troubleshooting](DEPLOYMENT_README.md#-troubleshooting)
---
**Ready? Start with:** `./deploy.sh` 🚀
Or read [DEPLOYMENT_README.md](DEPLOYMENT_README.md) for the full guide.

View File

@@ -0,0 +1,433 @@
# DigiServer Deployment Guide
Complete guide for deploying DigiServer on a new PC with automatic or manual configuration.
## 📋 Table of Contents
1. [Quick Start](#quick-start)
2. [Prerequisites](#prerequisites)
3. [Deployment Methods](#deployment-methods)
4. [Verification](#verification)
5. [Documentation Files](#documentation-files)
6. [Troubleshooting](#troubleshooting)
---
## 🚀 Quick Start
The fastest way to deploy DigiServer on a new PC:
```bash
# 1. Clone or copy the project to your new PC
cd /path/to/digiserver-v2
# 2. Run the automated deployment script
./deploy.sh
```
That's it! The script will:
- ✅ Start all Docker containers
- ✅ Run all database migrations
- ✅ Configure HTTPS with self-signed certificates
- ✅ Verify the setup
- ✅ Display access URLs
---
## 📋 Prerequisites
Before deploying, ensure you have:
### 1. Docker & Docker Compose
```bash
# Check Docker installation
docker --version
# Check Docker Compose installation
docker-compose --version
```
If not installed, follow the official guides:
- [Docker Installation](https://docs.docker.com/install/)
- [Docker Compose Installation](https://docs.docker.com/compose/install/)
### 2. Project Files
```bash
# You should have these files in the project directory:
ls -la
# Caddyfile - Reverse proxy configuration
# docker-compose.yml - Docker services definition
# setup_https.sh - Manual setup script
# deploy.sh - Automated deployment script
# requirements.txt - Python dependencies
```
### 3. Sufficient Disk Space
- ~2GB for Docker images and volumes
- Additional space for your content/uploads
### 4. Network Access
- Ports 80, 443 available (or configure in docker-compose.yml)
- Port 2019 for Caddy admin API (internal only)
---
## 🎯 Deployment Methods
### Method 1: Fully Automated (Recommended)
```bash
cd /path/to/digiserver-v2
./deploy.sh
```
**What it does:**
1. Starts Docker containers
2. Runs all migrations
3. Configures HTTPS
4. Verifies setup
5. Shows access URLs
**Configuration variables** (can be customized):
```bash
# Use environment variables to customize
HOSTNAME=digiserver \
DOMAIN=digiserver.sibiusb.harting.intra \
IP_ADDRESS=10.76.152.164 \
EMAIL=admin@example.com \
PORT=443 \
./deploy.sh
```
---
### Method 2: Semi-Automated Setup
```bash
cd /path/to/digiserver-v2
./setup_https.sh
```
**What it does:**
1. Starts containers (if needed)
2. Runs all migrations
3. Configures HTTPS with production settings
4. Shows status
---
### Method 3: Manual Step-by-Step
#### Step 1: Start Containers
```bash
cd /path/to/digiserver-v2
docker-compose up -d
```
Wait for containers to be ready (check with `docker-compose ps`).
#### Step 2: Run Migrations
```bash
# Migration 1: HTTPS Config
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
# Migration 2: Player User
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
# Migration 3: Email
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
# Migration 4: Player User Global
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
```
#### Step 3: Configure HTTPS
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
digiserver \
digiserver.sibiusb.harting.intra \
admin@example.com \
10.76.152.164 \
443
```
#### Step 4: Verify Status
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py status
```
---
## ✅ Verification
### Check Container Status
```bash
docker-compose ps
```
Expected output:
```
NAME SERVICE STATUS PORTS
digiserver-v2 digiserver-app Up (healthy) 5000/tcp
digiserver-caddy caddy Up 80, 443, 2019/tcp
```
### Test HTTPS Access
```bash
# From the same network (if DNS configured)
curl -k https://digiserver.sibiusb.harting.intra
# Or from container
docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/ | head -10
```
### Expected Response
Should show HTML login page with "DigiServer" in the title.
### Check Database
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from sqlalchemy import inspect
app = create_app()
with app.app_context():
inspector = inspect(app.extensions.db.engine)
tables = inspector.get_table_names()
print('Database tables:', len(tables))
for t in sorted(tables):
print(f' ✓ {t}')
"
```
---
## 📚 Documentation Files
### 1. `DOCKER_EXEC_COMMANDS.md` ⭐ **START HERE**
Quick reference for all docker exec commands
- Database operations
- User management
- HTTPS configuration
- Health checks
- Maintenance tasks
### 2. `DEPLOYMENT_COMMANDS.md`
Comprehensive deployment guide
- Prerequisites
- Each deployment step explained
- Complete deployment script template
- Troubleshooting section
### 3. `deploy.sh`
Automated deployment script (executable)
- Runs all steps automatically
- Shows progress with colors
- Configurable via environment variables
### 4. `setup_https.sh`
Semi-automated setup script (executable)
- Detects if running in Docker or on host
- Manual configuration option
- Detailed output
### 5. `Caddyfile`
Reverse proxy configuration
- HTTPS certificate management
- Domain routing
- Security headers
### 6. `docker-compose.yml`
Docker services definition
- Flask application
- Caddy reverse proxy
- Volumes and networks
---
## 🔐 First Access
After deployment:
1. **Access the application**
- https://digiserver.sibiusb.harting.intra
- https://10.76.152.164
- https://digiserver
2. **Log in with default credentials**
```
Username: admin
Password: admin123
```
3. **⚠️ IMPORTANT: Change the password immediately**
- Click on admin user settings
- Change default password to a strong password
4. **Configure your system**
- Set up players
- Upload content
- Create groups
- Configure playlists
---
## 🆘 Troubleshooting
### Containers Won't Start
```bash
# Check logs
docker-compose logs
# Try rebuilding
docker-compose down
docker-compose up -d --build
```
### Migration Fails
```bash
# Check database connection
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
app = create_app()
print('Database OK')
"
# Check if tables already exist
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from sqlalchemy import inspect
app = create_app()
with app.app_context():
inspector = inspect(app.extensions.db.engine)
print('Existing tables:', inspector.get_table_names())
"
```
### HTTPS Certificate Issues
```bash
# Clear Caddy certificate cache
docker volume rm digiserver-v2_caddy-data
docker volume rm digiserver-v2_caddy-config
# Restart Caddy
docker-compose restart caddy
```
### Port 80/443 Already in Use
```bash
# Find what's using the port
lsof -i :80 # For port 80
lsof -i :443 # For port 443
# Stop the conflicting service or change ports in docker-compose.yml
```
### Can't Access via IP Address
```bash
# Verify Caddy is listening
docker-compose exec -T caddy netstat -tlnp 2>/dev/null | grep -E ':(80|443)'
# Test from container
docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/
```
### Database Corruption
```bash
# Backup current database
docker-compose exec -T digiserver-app cp /app/instance/digiserver.db /app/instance/digiserver.db.backup
# Reset database (CAUTION: This deletes all data)
docker-compose exec -T digiserver-app rm /app/instance/digiserver.db
# Restart and re-run migrations
docker-compose restart digiserver-app
./setup_https.sh
```
---
## 📞 More Help
See the detailed documentation files:
- **Quick Commands**: `DOCKER_EXEC_COMMANDS.md`
- **Full Guide**: `DEPLOYMENT_COMMANDS.md`
- **HTTPS Details**: `old_code_documentation/HTTPS_CONFIGURATION.md`
---
## 🔄 Deployment on Different PC
To deploy on a different PC:
1. **Copy project files** to the new PC (or clone from git)
2. **Ensure Docker and Docker Compose are installed**
3. **Run deployment script**:
```bash
cd /path/to/digiserver-v2
./deploy.sh
```
4. **Access the application** on the new PC at the configured URLs
All settings will be automatically configured! 🎉
---
## 📋 Environment Variables
You can customize deployment using environment variables:
```bash
# Customize hostname
HOSTNAME=myserver ./deploy.sh
# Customize domain
DOMAIN=myserver.example.com ./deploy.sh
# Customize IP address
IP_ADDRESS=192.168.1.100 ./deploy.sh
# Customize email
EMAIL=admin@myserver.com ./deploy.sh
# Customize port
PORT=8443 ./deploy.sh
# All together
HOSTNAME=server1 \
DOMAIN=server1.internal \
IP_ADDRESS=192.168.1.100 \
EMAIL=admin@server1.com \
PORT=443 \
./deploy.sh
```
---
## ✨ Features
✅ Automated HTTPS with self-signed certificates
✅ Multi-access (hostname, domain, IP address)
✅ Automatic reverse proxy with Caddy
✅ Docker containerized (easy deployment)
✅ Complete database schema with migrations
✅ Admin dashboard for configuration
✅ User management
✅ Player management
✅ Content/Playlist management
✅ Group management
---
## 📝 Notes
- Default SSL certificates are **self-signed** (internal use)
- For production with Let's Encrypt, edit the Caddyfile
- Keep database backups before major changes
- Default credentials are in the code; change them in production
- All logs available via `docker-compose logs`
---
**Ready to deploy? Run:** `./deploy.sh` 🚀

View File

@@ -0,0 +1,353 @@
# DigiServer Docker Exec Commands - Quick Reference
Quick reference guide for common `docker exec` commands used in DigiServer deployment and maintenance.
## 🚀 Quick Start
### Complete Automated Deployment
```bash
./deploy.sh
```
### Manual Step-by-Step Setup
```bash
./setup_https.sh
```
---
## 📊 Database Migrations
Run migrations in this order:
```bash
# 1. HTTPS Configuration table
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
# 2. Player User table
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
# 3. Email column for HTTPS config
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
# 4. Player User global migration
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
```
---
## 🔒 HTTPS Configuration Management
### Check HTTPS Status
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py status
```
### Show Detailed Configuration
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py show
```
### Enable HTTPS (Production Settings)
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
digiserver \
digiserver.sibiusb.harting.intra \
admin@example.com \
10.76.152.164 \
443
```
### Disable HTTPS
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py disable
```
---
## 👤 User Management
### Create Admin User
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from app.models.user import User
from app.extensions import db
app = create_app()
with app.app_context():
admin = User.query.filter_by(username='admin').first()
if not admin:
admin = User(username='admin', email='admin@example.com')
admin.set_password('admin123')
admin.is_admin = True
db.session.add(admin)
db.session.commit()
print('✅ Admin user created')
else:
print('✅ Admin user already exists')
"
```
### Reset Admin Password
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from app.models.user import User
from app.extensions import db
app = create_app()
with app.app_context():
admin = User.query.filter_by(username='admin').first()
if admin:
admin.set_password('newpassword123')
db.session.commit()
print('✅ Admin password reset successfully')
else:
print('❌ Admin user not found')
"
```
---
## 🔍 Database Inspection
### List All Tables
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from sqlalchemy import inspect
app = create_app()
with app.app_context():
inspector = inspect(app.extensions.db.engine)
tables = inspector.get_table_names()
for table in sorted(tables):
print(f' ✓ {table}')
print(f'Total: {len(tables)} tables')
"
```
### Check HTTPS Configuration Record
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from app.models.https_config import HTTPSConfig
app = create_app()
with app.app_context():
config = HTTPSConfig.get_config()
if config:
print('HTTPS Configuration:')
print(f' Status: {\"ENABLED\" if config.https_enabled else \"DISABLED\"}')
print(f' Hostname: {config.hostname}')
print(f' Domain: {config.domain}')
print(f' IP: {config.ip_address}')
print(f' Port: {config.port}')
print(f' Updated: {config.updated_at}')
print(f' Updated by: {config.updated_by}')
else:
print('No configuration found')
"
```
### Count Users
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
from app.models.user import User
app = create_app()
with app.app_context():
count = User.query.count()
print(f'Total users: {count}')
admins = User.query.filter_by(is_admin=True).count()
print(f'Admin users: {admins}')
"
```
---
## 🧪 Health Checks
### Check Flask Application
```bash
docker-compose exec -T digiserver-app python -c "
import urllib.request
try:
response = urllib.request.urlopen('http://localhost:5000/', timeout=5)
print(f'✅ Application responding (HTTP {response.status})')
except Exception as e:
print(f'❌ Application error: {e}')
"
```
### Validate Caddy Configuration
```bash
docker-compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile
```
### Test HTTPS from Container
```bash
docker-compose exec -T caddy wget --no-check-certificate -qO- https://localhost/ | head -10
```
---
## 🛠️ Maintenance Commands
### View Caddy Configuration
```bash
docker-compose exec -T caddy cat /etc/caddy/Caddyfile
```
### Reload Caddy Configuration
```bash
docker-compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
```
### View Application Logs (Last 50 lines)
```bash
docker-compose logs --tail=50 digiserver-app
```
### View Caddy Logs (Last 50 lines)
```bash
docker-compose logs --tail=50 caddy
```
### Clear All Logs
```bash
docker-compose logs --clear
```
---
## 🔄 Container Management
### Restart All Containers
```bash
docker-compose restart
```
### Restart Specific Container
```bash
# Restart application
docker-compose restart digiserver-app
# Restart Caddy
docker-compose restart caddy
```
### Stop All Containers
```bash
docker-compose stop
```
### Start All Containers
```bash
docker-compose start
```
### Remove Everything (Clean slate)
```bash
docker-compose down
```
### Remove Everything Including Volumes (Full cleanup)
```bash
docker-compose down -v
```
---
## 📦 Backup and Recovery
### Backup Database
```bash
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
import shutil
from datetime import datetime
app = create_app()
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_name = f'digiserver_{timestamp}.db'
with app.app_context():
# Get database path
db_path = app.instance_path + '/digiserver.db'
shutil.copy(db_path, f'/app/backups/{backup_name}')
print(f'✅ Backup created: {backup_name}')
"
```
### List Database Backups
```bash
docker-compose exec -T digiserver-app ls -lah /app/backups/
```
---
## 🚨 Troubleshooting
### Common Issues
**Containers won't start:**
```bash
# Check logs
docker-compose logs
# Try rebuild
docker-compose up -d --build
```
**Migration fails:**
```bash
# Check database connection
docker-compose exec -T digiserver-app python -c "
from app.app import create_app
app = create_app()
print('✅ Database connection OK')
"
```
**Certificate issues:**
```bash
# Clear Caddy cache
docker volume rm digiserver-v2_caddy-data
docker volume rm digiserver-v2_caddy-config
# Restart Caddy
docker-compose restart caddy
```
**Port conflicts:**
```bash
# Find what's using port 443
lsof -i :443
# Find what's using port 80
lsof -i :80
```
---
## 📝 Tips and Notes
- **`-T` flag**: Prevents Docker from allocating a pseudo-terminal (use in scripts)
- **No `-T` flag**: Allocates a terminal (use for interactive commands)
- **Container name**: `digiserver-app` (Flask application)
- **Container name**: `digiserver-caddy` (Reverse proxy)
- **Network**: `digiserver-v2_digiserver-network`
- **Database**: SQLite at `/app/instance/digiserver.db`
---
## 🔗 Related Documentation
- [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md) - Complete deployment guide
- [setup_https.sh](setup_https.sh) - Semi-automated setup script
- [deploy.sh](deploy.sh) - Fully automated deployment script
- [HTTPS_CONFIGURATION.md](old_code_documentation/HTTPS_CONFIGURATION.md) - HTTPS details

View File

@@ -0,0 +1,192 @@
# HTTPS Configuration Management System
## Overview
The DigiServer v2 now includes a built-in HTTPS configuration management system accessible through the Admin Panel. This allows administrators to enable and manage HTTPS/SSL settings directly from the web interface without needing to manually edit configuration files.
## Features
- **Enable/Disable HTTPS**: Toggle HTTPS on and off from the admin panel
- **Domain Management**: Set the full domain name (e.g., `digiserver.sibiusb.harting.intra`)
- **Hostname Configuration**: Configure server hostname (e.g., `digiserver`)
- **IP Address Management**: Set the IP address for direct access (e.g., `10.76.152.164`)
- **Port Configuration**: Customize HTTPS port (default: 443)
- **Status Tracking**: View current HTTPS status and configuration details
- **Real-time Preview**: See access points as you configure settings
## Workflow
### Step 1: Initial Setup (HTTP Only)
1. Start the application normally: `docker-compose up -d`
2. The app runs on HTTP port 80
3. Access via: `http://<server-ip>`
### Step 2: Enable HTTPS via Admin Panel
1. Log in to the admin panel as an administrator
2. Navigate to: **Admin Panel → 🔒 HTTPS Configuration**
3. Toggle the "Enable HTTPS" switch
4. Fill in the required fields:
- **Hostname**: Short name for your server (e.g., `digiserver`)
- **Full Domain Name**: Complete domain (e.g., `digiserver.sibiusb.harting.intra`)
- **IP Address**: Server IP address (e.g., `10.76.152.164`)
- **HTTPS Port**: Port number (default: 443)
### Step 3: Verify Configuration
1. The status section shows your HTTPS configuration
2. Access points are displayed:
- HTTPS: `https://digiserver.sibiusb.harting.intra`
- HTTP fallback: `http://10.76.152.164`
## Configuration Details
### Database Model (HTTPSConfig)
The configuration is stored in the `https_config` table with the following fields:
```python
- id: Primary key
- https_enabled: Boolean flag for HTTPS status
- hostname: Server hostname
- domain: Full domain name
- ip_address: IPv4 or IPv6 address
- port: HTTPS port (default: 443)
- created_at: Creation timestamp
- updated_at: Last modification timestamp
- updated_by: Username of admin who made the change
```
### Admin Routes
- **GET /admin/https-config**: View HTTPS configuration page
- **POST /admin/https-config/update**: Update HTTPS settings
- **GET /admin/https-config/status**: Get current status as JSON
## Integration with Docker & Caddy
The HTTPS configuration works in conjunction with:
1. **Caddy Reverse Proxy**: Automatically handles SSL/TLS
2. **Let's Encrypt**: Provides free SSL certificates
3. **docker-compose.yml**: Uses the configured domain for Caddy
### Current Setup
**docker-compose.yml** uses `digiserver.sibiusb.harting.intra` as the primary domain.
**Caddyfile** configurations:
- HTTPS: `digiserver.sibiusb.harting.intra` (auto-managed SSL)
- HTTP Fallback: `10.76.152.164` (direct IP access)
## Prerequisites
Before enabling HTTPS, ensure:
1. **DNS Resolution**: Domain must resolve to the server's IP
```bash
# Test DNS resolution
nslookup digiserver.sibiusb.harting.intra
```
2. **Ports Accessible**:
- Port 80 (HTTP): For Let's Encrypt challenges
- Port 443 (HTTPS): For secure traffic
- Port 443/UDP: For HTTP/3 support
3. **Firewall Rules**: Ensure inbound traffic is allowed on ports 80 and 443
4. **Hosts File** (if DNS not available):
```
10.76.152.164 digiserver.sibiusb.harting.intra
```
## Database Migration
To set up the HTTPS configuration table, run:
```bash
# From inside the Docker container
python /app/migrations/add_https_config_table.py
# Or from the host machine
docker-compose exec digiserver-app python /app/migrations/add_https_config_table.py
```
## Access Points After Configuration
### HTTPS (Recommended)
- URL: `https://digiserver.sibiusb.harting.intra`
- Protocol: HTTPS with SSL/TLS
- Automatic redirects from HTTP
- Let's Encrypt certificate (auto-renewed)
### HTTP Fallback
- URL: `http://10.76.152.164`
- Protocol: Plain HTTP (no encryption)
- Used when domain is not accessible
- Automatically redirects to HTTPS
## Security Features
✅ Automatic SSL certificate management (Let's Encrypt)
✅ Automatic certificate renewal
✅ Security headers (HSTS, X-Frame-Options, etc.)
✅ HTTP/2 and HTTP/3 support
✅ Admin-only access to configuration
## Logging
All HTTPS configuration changes are logged in the server logs:
```
✓ HTTPS enabled by admin: domain=digiserver.sibiusb.harting.intra, hostname=digiserver, ip=10.76.152.164
✓ HTTPS disabled by admin
```
Check admin panel → Logs for detailed audit trail.
## Troubleshooting
### HTTPS Not Working
1. Verify DNS resolution: `nslookup digiserver.sibiusb.harting.intra`
2. Check Caddy logs: `docker-compose logs caddy`
3. Ensure ports 80 and 443 are open
4. Check firewall rules
### Certificate Issues
1. Check Caddy container logs
2. Verify domain is accessible from internet
3. Ensure Let's Encrypt can validate domain
4. Check email configuration for certificate notifications
### Configuration Not Applied
1. Verify database migration ran: `python migrations/add_https_config_table.py`
2. Restart containers: `docker-compose restart`
3. Check admin panel for error messages
4. Review server logs
## Example Configuration
For a typical setup:
```
Hostname: digiserver
Domain: digiserver.sibiusb.harting.intra
IP Address: 10.76.152.164
Port: 443
HTTPS Status: Enabled ✅
```
Access via:
- `https://digiserver.sibiusb.harting.intra` ← Primary
- `http://10.76.152.164` ← Fallback
## Future Enhancements
Potential improvements for future versions:
- Certificate upload/management interface
- Domain validation checker
- Automatic DNS verification
- Custom SSL certificate support
- Certificate expiration notifications
- A/B testing for domain migration

View File

@@ -0,0 +1,202 @@
# HTTPS Email Configuration - Update Guide
## What's New
The HTTPS configuration system now includes an **Email Address** field that is essential for:
- SSL certificate management (Let's Encrypt)
- Certificate expiration notifications
- Certificate renewal reminders
## Changes Made
### 1. **Database Model** (`app/models/https_config.py`)
- Added `email` field to HTTPSConfig model
- Updated `create_or_update()` method to accept email parameter
- Updated `to_dict()` method to include email in output
### 2. **Admin Routes** (`app/blueprints/admin.py`)
- Added email form field handling
- Added email validation (checks for '@' symbol)
- Updated configuration save to store email
- Updated logging to include email in configuration changes
### 3. **Admin Template** (`app/templates/admin/https_config.html`)
- Added email input field in configuration form
- Added email display in status section
- Added help text explaining email purpose
- Email marked as required when HTTPS is enabled
### 4. **CLI Utility** (`https_manager.py`)
- Updated enable command to accept email parameter
- Updated help text to show email requirement
- Example: `python https_manager.py enable digiserver domain.local admin@example.com 10.76.152.164`
### 5. **Database Migration** (`migrations/add_email_to_https_config.py`)
- New migration script to add email column to existing database
## Update Instructions
### Step 1: Run Database Migration
```bash
# Add email column to existing https_config table
python /app/migrations/add_email_to_https_config.py
```
### Step 2: Restart Application
```bash
docker-compose restart
```
### Step 3: Configure Email via Admin Panel
1. Navigate to: **Admin Panel → 🔒 HTTPS Configuration**
2. Fill in the new **Email Address** field
3. Example: `admin@example.com`
4. Click **Save HTTPS Configuration**
## Configuration Form - New Field
```html
<!-- Email Field -->
<label for="email">Email Address *</label>
<input type="email" id="email" name="email"
value="admin@example.com"
placeholder="e.g., admin@example.com"
required>
<p>Email address for SSL certificate notifications and Let's Encrypt communications</p>
```
## CLI Usage - New Syntax
**Old (still works for HTTP):**
```bash
python https_manager.py enable digiserver domain.local 10.76.152.164 443
```
**New (with email - recommended):**
```bash
python https_manager.py enable digiserver domain.local admin@example.com 10.76.152.164 443
```
## Status Display - Updated
The status card now shows:
```
✅ HTTPS ENABLED
Domain: digiserver.sibiusb.harting.intra
Hostname: digiserver
Email: admin@example.com ← NEW
IP Address: 10.76.152.164
Port: 443
Access URL: https://digiserver.sibiusb.harting.intra
Last Updated: 2026-01-14 15:30:45 by admin
```
## Validation
The system now validates:
- ✅ Email format (must contain '@')
- ✅ Email is required when HTTPS is enabled
- ✅ Email is stored in database
- ✅ Email is logged when configuration changes
## Benefits
📧 **Proper SSL Certificate Management**
- Let's Encrypt sends notifications to configured email
- Certificate expiration warnings before renewal
📋 **Better Configuration**
- Email is persisted in database
- No need to set environment variables
- Fully managed through admin panel
🔐 **Professional Setup**
- Real email address for certificate notifications
- Easier to manage multiple servers
- Complete audit trail with email address
## Backwards Compatibility
If you have an existing HTTPS configuration without an email:
1. The email field will be NULL
2. You'll see an error when trying to use HTTPS without email
3. Simply add the email through the admin panel and save
4. Configuration will be complete
## Database Schema Update
```sql
ALTER TABLE https_config ADD COLUMN email VARCHAR(255);
```
New schema:
```
https_config table:
├── id (PK)
├── https_enabled (BOOLEAN)
├── hostname (VARCHAR)
├── domain (VARCHAR)
├── ip_address (VARCHAR)
├── email (VARCHAR) ← NEW
├── port (INTEGER)
├── created_at (DATETIME)
├── updated_at (DATETIME)
└── updated_by (VARCHAR)
```
## Example Configuration
**Complete HTTPS Setup:**
```
Hostname: digiserver
Domain: digiserver.sibiusb.harting.intra
Email: admin@example.com
IP: 10.76.152.164
Port: 443
Status: ✅ ENABLED
```
## Troubleshooting
### Email Field Not Showing?
1. Clear browser cache (Ctrl+Shift+Del)
2. Reload the page
3. Check that containers restarted: `docker-compose restart`
### Migration Error?
If migration fails:
```bash
# Option 1: Add column manually
docker-compose exec digiserver-app python -c "
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app()
with app.app_context():
db.engine.execute(text('ALTER TABLE https_config ADD COLUMN email VARCHAR(255)'))
"
# Option 2: Reset database (if testing)
rm instance/digiserver.db
python /app/migrations/add_https_config_table.py
```
### "Email Required" Error When HTTPS Enabled?
- Admin panel: Fill in the Email Address field before saving
- CLI: Include email in command: `python https_manager.py enable ... email@example.com ...`
## Next Steps
1. Run the database migration
2. Restart the application
3. Navigate to HTTPS Configuration
4. Enter a valid email address (e.g., `admin@example.com`)
5. Enable HTTPS
6. System will use this email for Let's Encrypt notifications
## Support
For issues or questions:
- Check `HTTPS_CONFIGURATION.md` for detailed documentation
- See `HTTPS_QUICK_REFERENCE.md` for quick examples
- Review server logs in admin panel for configuration changes

View File

@@ -0,0 +1,316 @@
# HTTPS Management System - Implementation Summary
## ✅ What Has Been Implemented
A complete HTTPS configuration management system has been added to DigiServer v2, allowing administrators to manage HTTPS settings through the web interface.
### Files Created
#### 1. **Database Model** (`app/models/https_config.py`)
- New `HTTPSConfig` model for storing HTTPS configuration
- Fields: hostname, domain, ip_address, port, enabled status, audit trail
- Methods: `get_config()`, `create_or_update()`, `to_dict()`
#### 2. **Admin Routes** (updated `app/blueprints/admin.py`)
- `GET /admin/https-config` - Display configuration page
- `POST /admin/https-config/update` - Update settings
- `GET /admin/https-config/status` - Get status as JSON
- Full validation and error handling
- Admin-only access with permission checks
#### 3. **Admin Template** (`app/templates/admin/https_config.html`)
- Beautiful, user-friendly configuration interface
- Status display showing current HTTPS settings
- Form with toggle switch for enable/disable
- Input fields for: hostname, domain, IP address, port
- Real-time preview of access points
- Comprehensive help text and information sections
- Responsive design for mobile compatibility
#### 4. **Database Migration** (`migrations/add_https_config_table.py`)
- Creates `https_config` table with all necessary fields
- Indexes on important columns
- Timestamps for audit trail
#### 5. **Admin Dashboard Link** (updated `app/templates/admin/admin.html`)
- Added new card in admin dashboard linking to HTTPS configuration
- Purple gradient card with lock icon (🔒)
- Easy access from main admin panel
#### 6. **CLI Utility** (`https_manager.py`)
- Command-line interface for managing HTTPS configuration
- Commands: `status`, `enable`, `disable`, `show`
- Useful for automation and scripting
#### 7. **Setup Script** (`setup_https.sh`)
- Automated setup script for database migration
- Step-by-step instructions for configuration
#### 8. **Documentation** (`HTTPS_CONFIGURATION.md`)
- Comprehensive guide covering:
- Feature overview
- Step-by-step workflow
- Configuration details
- Prerequisites
- Integration details
- Troubleshooting
- Examples
### Files Updated
#### 1. **Models Package** (`app/models/__init__.py`)
- Added import for `HTTPSConfig`
- Exported in `__all__` list
#### 2. **Admin Blueprint** (`app/blueprints/admin.py`)
- Imported `HTTPSConfig` model
- Added HTTPS management routes
#### 3. **Admin Dashboard** (`app/templates/admin/admin.html`)
- Added link to HTTPS configuration
#### 4. **Caddyfile**
- Already preconfigured with domain: `digiserver.sibiusb.harting.intra`
- IP fallback: `10.76.152.164`
- Ready to use with the new configuration system
---
## 🚀 Quick Start Guide
### Step 1: Database Setup
```bash
# Run the migration to create the https_config table
python /app/migrations/add_https_config_table.py
# Or automatically with the setup script
bash setup_https.sh
```
### Step 2: Start the Application (HTTP Only)
```bash
docker-compose up -d
```
### Step 3: Configure HTTPS via Admin Panel
1. Log in as admin
2. Go to: **Admin Panel → 🔒 HTTPS Configuration**
3. Toggle "Enable HTTPS"
4. Fill in:
- Hostname: `digiserver`
- Domain: `digiserver.sibiusb.harting.intra`
- IP Address: `10.76.152.164`
- Port: `443` (default)
5. Click "Save HTTPS Configuration"
### Step 4: Verify Access
- HTTPS: `https://digiserver.sibiusb.harting.intra`
- HTTP Fallback: `http://10.76.152.164`
---
## 📋 Workflow Explanation
### Initial State (HTTP Only)
```
┌─────────────────┐
│ App Running on │
│ Port 80 (HTTP) │
└────────┬────────┘
└─ Accessible at: http://10.76.152.164
```
### After Configuration (HTTP + HTTPS)
```
┌──────────────────────────────────────┐
│ Admin Configures HTTPS Settings: │
│ • Hostname: digiserver │
│ • Domain: digiserver...intra │
│ • IP: 10.76.152.164 │
│ • Port: 443 │
└──────────────┬───────────────────────┘
┌───────┴────────┐
│ │
┌────▼────┐ ┌─────▼──────┐
│ HTTPS │ │ HTTP │
│ Port443 │ │ Port 80 │
└────┬────┘ └─────┬──────┘
│ │
└──────────────┘
Both available
```
---
## 🔐 Security Features
**Admin-Only Access**
- Only administrators can access HTTPS configuration
- All changes logged with admin username and timestamp
**Input Validation**
- Domain format validation
- IP address format validation (IPv4/IPv6)
- Port range validation (1-65535)
**SSL/TLS Management**
- Automatic Let's Encrypt integration (via Caddy)
- Automatic certificate renewal
- Security headers (HSTS, X-Frame-Options, etc.)
**Audit Trail**
- All configuration changes logged
- Admin dashboard logs show who changed what and when
- Server logs track HTTPS enable/disable events
---
## 🛠️ CLI Management
Configure HTTPS from command line:
```bash
# Show current status
python https_manager.py status
# Enable HTTPS
python https_manager.py enable digiserver digiserver.sibiusb.harting.intra 10.76.152.164 443
# Disable HTTPS
python https_manager.py disable
# Show detailed configuration
python https_manager.py show
```
---
## 📊 Database Schema
**https_config table:**
```
┌──────────────────┬────────────────────┬──────────────┐
│ Column │ Type │ Description │
├──────────────────┼────────────────────┼──────────────┤
│ id │ Integer (PK) │ Primary key │
│ https_enabled │ Boolean │ Enable flag │
│ hostname │ String(255) │ Server name │
│ domain │ String(255) │ Domain name │
│ ip_address │ String(45) │ IP address │
│ port │ Integer │ HTTPS port │
│ created_at │ DateTime │ Created time │
│ updated_at │ DateTime │ Updated time │
│ updated_by │ String(255) │ Admin user │
└──────────────────┴────────────────────┴──────────────┘
```
---
## 🧪 Testing
### Test HTTPS Configuration UI
1. Log in as admin
2. Go to Admin Panel → HTTPS Configuration
3. Test Enable/Disable toggle
4. Test form validation with invalid inputs
5. Verify real-time preview updates
### Test Access Points
```bash
# Test HTTPS
curl -k https://digiserver.sibiusb.harting.intra
# Test HTTP Fallback
curl http://10.76.152.164
# Test status endpoint
curl http://<admin>/admin/https-config/status
```
---
## 📝 Configuration Examples
### Default Configuration
```python
hostname = "digiserver"
domain = "digiserver.sibiusb.harting.intra"
ip_address = "10.76.152.164"
port = 443
https_enabled = True
```
### Configuration for Different Network
```python
hostname = "myserver"
domain = "myserver.company.local"
ip_address = "192.168.1.100"
port = 8443
https_enabled = True
```
---
## 🔄 Integration with Existing System
The HTTPS configuration system integrates seamlessly with:
1. **Caddy Reverse Proxy** - Uses configured domain for SSL termination
2. **Let's Encrypt** - Automatic certificate provisioning and renewal
3. **Flask Application** - No code changes needed, works with existing auth
4. **Database** - Stores configuration persistently
5. **Logging System** - All changes logged and auditable
---
## 🎯 Key Benefits
**No Manual Configuration** - All settings through web UI
**Easy to Use** - Intuitive interface with real-time preview
**Audit Trail** - Track all HTTPS configuration changes
**Flexible** - Support for multiple access points (HTTPS + HTTP)
**Secure** - Admin-only access with validation
**Automated** - Automatic SSL certificate management
**CLI Support** - Programmatic configuration via command line
---
## 📚 Next Steps
1.**Run Database Migration**
```bash
python /app/migrations/add_https_config_table.py
```
2. ✅ **Start Application**
```bash
docker-compose up -d
```
3. ✅ **Configure via Admin Panel**
- Navigate to Admin → HTTPS Configuration
- Enable HTTPS with your settings
4. ✅ **Verify Configuration**
- Check status displays correctly
- Test access points work
- Review logs for changes
---
## 📞 Support & Troubleshooting
See `HTTPS_CONFIGURATION.md` for:
- Detailed troubleshooting guide
- DNS configuration instructions
- Firewall requirements
- Let's Encrypt certificate issues
- Error messages and solutions
---
## 🎉 Implementation Complete!
The HTTPS configuration management system is ready to use. All components are in place and documented. Simply run the database migration and start using the feature through the admin panel!

View File

@@ -0,0 +1,259 @@
# HTTPS Configuration - Quick Reference Guide
## 🎯 Quick Access
**Admin Panel Location:** Main Dashboard → 🔒 **HTTPS Configuration** (Purple card)
---
## ⚡ Quick Setup (5 Minutes)
### 1. Initial State
Your app is running on HTTP. Access: `http://10.76.152.164`
### 2. Navigate to HTTPS Config
- Admin Panel → 🔒 HTTPS Configuration
### 3. Configure (Fill In)
| Field | Value | Example |
|-------|-------|---------|
| Hostname | Server short name | `digiserver` |
| Domain | Full domain name | `digiserver.sibiusb.harting.intra` |
| IP Address | Server IP | `10.76.152.164` |
| Port | HTTPS port (default 443) | `443` |
### 4. Enable HTTPS
- Toggle: **Enable HTTPS**
- Click: **💾 Save HTTPS Configuration**
### 5. Verify
- ✅ Configuration shows as "ENABLED"
- ✅ Access via: `https://digiserver.sibiusb.harting.intra`
- ✅ Check status card for current settings
---
## 🔍 Status Display
### Enabled State ✅
```
✅ HTTPS ENABLED
Domain: digiserver.sibiusb.harting.intra
Hostname: digiserver
IP Address: 10.76.152.164
Port: 443
Access URL: https://digiserver.sibiusb.harting.intra
Last Updated: 2024-01-14 15:30:45 by admin
```
### Disabled State ⚠️
```
⚠️ HTTPS DISABLED
The application is currently running on HTTP only (port 80)
Enable HTTPS below to secure your application.
```
---
## 🔐 Access Points
### After HTTPS is Enabled
| Access Type | URL | Use Case |
|------------|-----|----------|
| **Primary (HTTPS)** | `https://digiserver.sibiusb.harting.intra` | Daily use, secure |
| **Fallback (HTTP)** | `http://10.76.152.164` | Troubleshooting, direct IP access |
---
## ✅ Prerequisites Checklist
Before enabling HTTPS:
- [ ] DNS resolves domain to IP: `nslookup digiserver.sibiusb.harting.intra`
- [ ] Firewall allows port 80 (HTTP)
- [ ] Firewall allows port 443 (HTTPS)
- [ ] Server IP is `10.76.152.164`
- [ ] Domain is `digiserver.sibiusb.harting.intra`
---
## 🐛 Troubleshooting
### HTTPS Not Working?
1. **Check Status**
- Admin → HTTPS Configuration
- Verify "HTTPS ENABLED" is shown
2. **Test DNS**
```bash
nslookup digiserver.sibiusb.harting.intra
```
Should resolve to: `10.76.152.164`
3. **Test Ports**
```bash
# Should be reachable
telnet 10.76.152.164 443
telnet 10.76.152.164 80
```
4. **Check Logs**
- Admin Panel → Server Logs
- Look for HTTPS enable/disable messages
5. **View Caddy Logs**
```bash
docker-compose logs caddy
```
### Domain Not Resolving?
**Add to hosts file** (temporary):
- Windows: `C:\Windows\System32\drivers\etc\hosts`
- Mac/Linux: `/etc/hosts`
Add line:
```
10.76.152.164 digiserver.sibiusb.harting.intra
```
---
## 📋 Common Tasks
### Enable HTTPS
1. Go to Admin → HTTPS Configuration
2. Toggle "Enable HTTPS"
3. Fill in hostname, domain, IP
4. Click "Save HTTPS Configuration"
### Disable HTTPS
1. Go to Admin → HTTPS Configuration
2. Toggle off "Enable HTTPS"
3. Click "Save HTTPS Configuration"
4. App returns to HTTP only
### Change Domain
1. Go to Admin → HTTPS Configuration
2. Update "Full Domain Name"
3. Click "Save HTTPS Configuration"
### Check Current Settings
1. Go to Admin → HTTPS Configuration
2. View status card at top
3. Shows all current settings
### View Configuration History
1. Admin Panel → Server Logs
2. Search for "HTTPS"
3. See all changes and who made them
---
## 🎯 Configuration Examples
### Default Setup (Already Provided)
```
Hostname: digiserver
Domain: digiserver.sibiusb.harting.intra
IP: 10.76.152.164
Port: 443
```
### Different IP
```
Hostname: digiserver
Domain: digiserver.sibiusb.harting.intra
IP: 10.76.152.165 ← Change this
Port: 443
```
### Different Domain
```
Hostname: myserver
Domain: myserver.company.local ← Change this
IP: 10.76.152.164
Port: 443
```
---
## 🔒 Security Notes
✅ **Admin-Only Feature**
- Only administrators can access this page
- All changes logged with admin username
✅ **Automatic SSL Certificates**
- Let's Encrypt manages certificates
- Auto-renewed before expiration
- No manual certificate management needed
✅ **Access Control**
- HTTP redirects to HTTPS automatically
- Security headers automatically added
- Safe for internal and external access
---
## 📞 Need Help?
1. **Check Documentation**
- See: `HTTPS_CONFIGURATION.md` for detailed guide
- See: `HTTPS_IMPLEMENTATION_SUMMARY.md` for architecture
2. **View Logs**
- Admin Panel → Server Logs
- Filter for HTTPS-related entries
3. **Test Configuration**
```bash
# Via CLI
python https_manager.py status
```
4. **Restart Application**
```bash
docker-compose restart
```
---
## 📊 Quick Status Check
**CLI Command:**
```bash
python https_manager.py status
```
**Output:**
```
==================================================
HTTPS Configuration Status
==================================================
Status: ✅ ENABLED
Hostname: digiserver
Domain: digiserver.sibiusb.harting.intra
IP Address: 10.76.152.164
Port: 443
Updated: 2024-01-14 15:30:45 by admin
Access URL: https://digiserver.sibiusb.harting.intra
Fallback: http://10.76.152.164
==================================================
```
---
## 🎉 You're All Set!
Your HTTPS configuration is ready to use. The system will:
- ✅ Manage SSL certificates automatically
- ✅ Keep them renewed
- ✅ Provide secure access
- ✅ Log all configuration changes
- ✅ Offer fallback HTTP access
**That's it! Your app is now secure!** 🔒

View File

@@ -0,0 +1,75 @@
# DigiServer v2 - HTTPS Setup with Caddy
This setup uses **Caddy** as a reverse proxy with automatic HTTPS via Let's Encrypt.
## Quick Setup
### 1. Configure Domain
Create a `.env` file or edit the existing one:
```bash
cp .env.example .env
```
Edit `.env` and set:
```
DOMAIN=your-domain.com
EMAIL=admin@your-domain.com
```
### 2. Point Your Domain
Make sure your domain's DNS A record points to your server's IP address.
### 3. Start Services
```bash
docker compose up -d
```
That's it! Caddy will **automatically**:
- Obtain SSL certificates from Let's Encrypt
- Renew certificates before expiration
- Redirect HTTP to HTTPS
- Enable HTTP/2 and HTTP/3
## Access Your Site
- **HTTP**: http://your-domain.com (redirects to HTTPS)
- **HTTPS**: https://your-domain.com
## Testing Locally (Without Domain)
If you don't have a domain yet, leave DOMAIN as `localhost`:
```
DOMAIN=localhost
```
Then access: http://localhost (no HTTPS, but app works)
## Certificate Storage
SSL certificates are stored in Docker volumes:
- `caddy-data` - Certificate data
- `caddy-config` - Caddy configuration
## Troubleshooting
### Check Caddy logs:
```bash
docker logs digiserver-caddy
```
### Verify certificates:
```bash
docker exec digiserver-caddy caddy list-certificates
```
### Force certificate renewal:
```bash
docker exec digiserver-caddy caddy reload --config /etc/caddy/Caddyfile
```
## Port Forwarding
Make sure your firewall/router allows:
- Port 80 (HTTP - for Let's Encrypt challenge)
- Port 443 (HTTPS)

View File

@@ -0,0 +1,84 @@
# Quick Start: Nginx Setup for DigiServer v2
## Pre-requisites
- SSL certificates in `./data/nginx-ssl/cert.pem` and `./data/nginx-ssl/key.pem`
- Docker and Docker Compose installed
- Port 80 and 443 available
## Quick Setup (3 steps)
### 1. Generate Self-Signed Certificates
```bash
./generate_nginx_certs.sh localhost 365
```
### 2. Update Nginx Configuration
- Edit `nginx.conf` to set your domain:
```nginx
server_name localhost; # Change to your domain
```
### 3. Start Docker Compose
```bash
docker-compose up -d
```
## Verification
### Check if Nginx is running
```bash
docker ps | grep nginx
```
### Test HTTP → HTTPS redirect
```bash
curl -L http://localhost
```
### Test HTTPS (with self-signed cert)
```bash
curl -k https://localhost
```
### View logs
```bash
docker logs digiserver-nginx
docker exec digiserver-nginx tail -f /var/log/nginx/access.log
```
## Using Production Certificates
### Option A: Let's Encrypt (Free)
1. Install certbot: `apt-get install certbot`
2. Generate cert: `certbot certonly --standalone -d your-domain.com`
3. Copy cert: `cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ./data/nginx-ssl/cert.pem`
4. Copy key: `cp /etc/letsencrypt/live/your-domain.com/privkey.pem ./data/nginx-ssl/key.pem`
5. Fix permissions: `sudo chown 101:101 ./data/nginx-ssl/*`
6. Reload: `docker exec digiserver-nginx nginx -s reload`
### Option B: Commercial Certificate
1. Place your certificate files in `./data/nginx-ssl/cert.pem` and `./data/nginx-ssl/key.pem`
2. Fix permissions: `sudo chown 101:101 ./data/nginx-ssl/*`
3. Reload: `docker exec digiserver-nginx nginx -s reload`
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Port 80/443 in use | `sudo netstat -tlnp \| grep :80` or `:443` |
| Certificate permission denied | `sudo chown 101:101 ./data/nginx-ssl/*` |
| Nginx won't start | `docker logs digiserver-nginx` |
| Connection refused | Check firewall: `sudo ufw allow 80/tcp && sudo ufw allow 443/tcp` |
## File Locations
- Main config: `./nginx.conf`
- SSL certs: `./data/nginx-ssl/`
- Logs: `./data/nginx-logs/`
- Custom domains: `./nginx-custom-domains.conf` (auto-generated)
## Next: Production Setup
1. Update `.env` with your DOMAIN and EMAIL
2. Configure HTTPS settings in admin panel
3. Run: `python nginx_manager.py generate`
4. Test: `docker exec digiserver-nginx nginx -t`
5. Reload: `docker exec digiserver-nginx nginx -s reload`

View File

@@ -0,0 +1,181 @@
# Player Edit Media API
## Overview
This API allows players to upload edited media files back to the server, maintaining version history and automatically updating playlists.
## Endpoint
### POST `/api/player-edit-media`
Upload an edited media file from a player device.
**Authentication Required:** Yes (Bearer token)
**Rate Limit:** 60 requests per 60 seconds
**Content-Type:** `multipart/form-data`
## Request
### Form Data
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `image_file` | File | Yes | The edited image file |
| `metadata` | JSON String | Yes | Metadata about the edit (see below) |
### Metadata JSON Structure
```json
{
"time_of_modification": "2025-12-05T20:30:00Z",
"original_name": "image.jpg",
"new_name": "image_v1.jpg",
"version": 1,
"user": "player_user_name"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `time_of_modification` | ISO 8601 DateTime | Yes | When the edit was made |
| `original_name` | String | Yes | Original filename (must exist in content) |
| `new_name` | String | Yes | New filename with version suffix |
| `version` | Integer | Yes | Version number (1, 2, 3, etc.) |
| `user` | String | No | User who made the edit |
## Response
### Success (200 OK)
```json
{
"success": true,
"message": "Edited media received and processed",
"edit_id": 123,
"version": 1,
"new_playlist_version": 5
}
```
### Error Responses
#### 400 Bad Request
```json
{
"error": "No image file provided"
}
```
#### 404 Not Found
```json
{
"error": "Original content not found: image.jpg"
}
```
#### 500 Internal Server Error
```json
{
"error": "Internal server error"
}
```
## Workflow
1. **Player edits media** - User edits an image/PDF/PPTX on the player device
2. **Player uploads** - Player sends edited file + metadata to this endpoint
3. **Server processes**:
- Saves edited file to `/static/uploads/edited_media/<content_id>/<new_name>`
- Saves metadata JSON to `/static/uploads/edited_media/<content_id>/<new_name>_metadata.json`
- Replaces original file in `/static/uploads/` with edited version
- Creates database record in `player_edit` table
- Increments playlist version to trigger player refresh
- Clears playlist cache
4. **Player refreshes** - Next playlist check shows updated media
## Version History
Each edit is saved with a version number:
- `image.jpg``image_v1.jpg` (first edit)
- `image.jpg``image_v2.jpg` (second edit)
- etc.
All versions are preserved in the `edited_media/<content_id>/` folder.
## Example cURL Request
```bash
# First, authenticate to get token
TOKEN=$(curl -X POST http://server/api/auth/authenticate \
-H "Content-Type: application/json" \
-d '{"hostname": "player-1", "password": "password123"}' \
| jq -r '.token')
# Upload edited media
curl -X POST http://server/api/player-edit-media \
-H "Authorization: Bearer $TOKEN" \
-F "image_file=@edited_image_v1.jpg" \
-F 'metadata={"time_of_modification":"2025-12-05T20:30:00Z","original_name":"image.jpg","new_name":"image_v1.jpg","version":1,"user":"john"}'
```
## Python Example
```python
import requests
import json
# Authenticate
auth_response = requests.post(
'http://server/api/auth/authenticate',
json={'hostname': 'player-1', 'password': 'password123'}
)
token = auth_response.json()['token']
# Prepare metadata
metadata = {
'time_of_modification': '2025-12-05T20:30:00Z',
'original_name': 'image.jpg',
'new_name': 'image_v1.jpg',
'version': 1,
'user': 'john'
}
# Upload edited file
with open('edited_image_v1.jpg', 'rb') as f:
response = requests.post(
'http://server/api/player-edit-media',
headers={'Authorization': f'Bearer {token}'},
files={'image_file': f},
data={'metadata': json.dumps(metadata)}
)
print(response.json())
```
## Database Schema
### player_edit Table
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER | Primary key |
| player_id | INTEGER | Foreign key to player |
| content_id | INTEGER | Foreign key to content |
| original_name | VARCHAR(255) | Original filename |
| new_name | VARCHAR(255) | New filename with version |
| version | INTEGER | Version number |
| user | VARCHAR(255) | User who made the edit |
| time_of_modification | DATETIME | When edit was made |
| metadata_path | VARCHAR(512) | Path to metadata JSON |
| edited_file_path | VARCHAR(512) | Path to edited file |
| created_at | DATETIME | Record creation time |
## UI Display
Edited media history is displayed on the player management page under the "Edited Media on the Player" card, showing:
- Original filename
- Version number
- Editor name
- Modification time
- Link to view edited file

View File

@@ -0,0 +1,56 @@
# ProxyFix Middleware Setup - DigiServer v2
## Overview
ProxyFix middleware is now properly configured in the Flask app to handle reverse proxy headers from Nginx (or Caddy). This ensures correct handling of:
- **X-Real-IP**: Client's real IP address
- **X-Forwarded-For**: List of IPs in the proxy chain
- **X-Forwarded-Proto**: Original protocol (http/https)
- **X-Forwarded-Host**: Original hostname
## Configuration Details
### Flask App (app/app.py)
```python
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
```
**Parameters:**
- `x_for=1`: Trust one proxy for X-Forwarded-For header
- `x_proto=1`: Trust proxy for X-Forwarded-Proto header
- `x_host=1`: Trust proxy for X-Forwarded-Host header
- `x_port=1`: Trust proxy for X-Forwarded-Port header
### Config Settings (app/config.py)
```python
# Reverse proxy trust (for Nginx/Caddy with ProxyFix middleware)
TRUSTED_PROXIES = os.getenv('TRUSTED_PROXIES', '127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16')
PREFERRED_URL_SCHEME = os.getenv('PREFERRED_URL_SCHEME', 'https')
```
## Testing ProxyFix
### 1. Test Real Client IP
```bash
docker exec digiserver-app flask shell
>>> from flask import request
>>> request.remote_addr # Should show client IP
```
### 2. Test URL Scheme
```bash
docker exec digiserver-app flask shell
>>> from flask import url_for
>>> url_for('auth.login', _external=True) # Should use https://
```
## Verification Checklist
- [x] ProxyFix imported in app.py
- [x] app.wsgi_app wrapped with ProxyFix
- [x] TRUSTED_PROXIES configured
- [x] PREFERRED_URL_SCHEME set to 'https'
- [x] SESSION_COOKIE_SECURE=True in ProductionConfig
- [x] Nginx headers configured correctly

View File

@@ -0,0 +1,120 @@
# 🚀 DigiServer Deployment - Quick Reference Card
## Instant Deployment
```bash
cd /path/to/digiserver-v2
./deploy.sh
```
That's it! ✅
---
## 📖 Documentation Files
| File | Purpose | Size |
|------|---------|------|
| [DEPLOYMENT_INDEX.md](DEPLOYMENT_INDEX.md) | Navigation guide | 8.1 KB |
| [DEPLOYMENT_README.md](DEPLOYMENT_README.md) | Complete guide | 9.4 KB |
| [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md) | Command reference | 7.6 KB |
| [DEPLOYMENT_COMMANDS.md](DEPLOYMENT_COMMANDS.md) | Detailed guide | 6.8 KB |
---
## 🔧 Executable Scripts
| Script | Purpose | Time |
|--------|---------|------|
| [deploy.sh](deploy.sh) | Fully automated | 2-3 min |
| [setup_https.sh](setup_https.sh) | Semi-automated | 3-5 min |
---
## 🎯 Common Commands
### Check Status
```bash
docker-compose ps
```
### View Logs
```bash
docker-compose logs -f digiserver-app
```
### Verify HTTPS Configuration
```bash
docker-compose exec -T digiserver-app python /app/https_manager.py status
```
### Access the Application
```
https://digiserver.sibiusb.harting.intra
https://10.76.152.164
https://digiserver
```
### Default Login
```
Username: admin
Password: admin123
```
---
## 🆘 Troubleshooting
| Issue | Solution |
|-------|----------|
| Containers won't start | `docker-compose logs` |
| Migration fails | Check DB connection, see docs |
| HTTPS errors | Clear Caddy cache: `docker volume rm digiserver-v2_caddy-*` |
| Port conflict | `lsof -i :443` or change in docker-compose.yml |
See [DEPLOYMENT_README.md#-troubleshooting](DEPLOYMENT_README.md#-troubleshooting) for full guide.
---
## 🌍 Deploy on Different PC
1. Copy project files
2. Install Docker & Docker Compose
3. Run `./deploy.sh`
Done! 🎉
---
## 🔑 Customize Deployment
```bash
HOSTNAME=myserver \
DOMAIN=myserver.internal \
IP_ADDRESS=192.168.1.100 \
EMAIL=admin@example.com \
./deploy.sh
```
---
## 📚 Need More Help?
- **First time?** → [DEPLOYMENT_README.md](DEPLOYMENT_README.md)
- **Need a command?** → [DOCKER_EXEC_COMMANDS.md](DOCKER_EXEC_COMMANDS.md)
- **Lost?** → [DEPLOYMENT_INDEX.md](DEPLOYMENT_INDEX.md)
- **Troubleshooting?** → [DEPLOYMENT_README.md#-troubleshooting](DEPLOYMENT_README.md#-troubleshooting)
---
## ✨ What You Get
✅ Web application with admin dashboard
✅ HTTPS with self-signed certificates
✅ User management system
✅ Player & content management
✅ Fully configured & ready to use
---
**Ready to deploy?** `./deploy.sh` 🚀

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Add muted column to playlist_content table."""
from app.app import create_app
from app.extensions import db
def add_muted_column():
"""Add muted column to playlist_content association table."""
app = create_app()
with app.app_context():
try:
# Check if column already exists
result = db.session.execute(db.text("PRAGMA table_info(playlist_content)")).fetchall()
columns = [row[1] for row in result]
if 'muted' in columns:
print(" Column 'muted' already exists in playlist_content table")
return
# Add muted column with default value True (muted by default)
db.session.execute(db.text("""
ALTER TABLE playlist_content
ADD COLUMN muted BOOLEAN DEFAULT TRUE
"""))
db.session.commit()
print("✅ Successfully added 'muted' column to playlist_content table")
print(" Default: TRUE (videos will be muted by default)")
except Exception as e:
db.session.rollback()
print(f"❌ Error adding column: {e}")
if __name__ == '__main__':
add_muted_column()

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Check and fix player quickconnect code."""
from app import create_app
from app.models import Player
from app.extensions import db
app = create_app()
with app.app_context():
# Find player by hostname
player = Player.query.filter_by(hostname='tv-terasa').first()
if not player:
print("❌ Player 'tv-terasa' NOT FOUND in database!")
print("\nAll registered players:")
all_players = Player.query.all()
for p in all_players:
print(f" - ID={p.id}, Name='{p.name}', Hostname='{p.hostname}'")
else:
print(f"✅ Player found:")
print(f" ID: {player.id}")
print(f" Name: {player.name}")
print(f" Hostname: {player.hostname}")
print(f" Playlist ID: {player.playlist_id}")
print(f" Status: {player.status}")
print(f" QuickConnect Hash: {player.quickconnect_code[:60] if player.quickconnect_code else 'Not set'}...")
# Test the quickconnect code
test_code = "8887779"
print(f"\n🔐 Testing quickconnect code: '{test_code}'")
if player.check_quickconnect_code(test_code):
print(f"✅ Code '{test_code}' is VALID!")
else:
print(f"❌ Code '{test_code}' is INVALID - Hash doesn't match!")
# Update it
print(f"\n🔧 Updating quickconnect code to: '{test_code}'")
player.set_quickconnect_code(test_code)
db.session.commit()
print("✅ QuickConnect code updated successfully!")
print(f" New hash: {player.quickconnect_code[:60]}...")
# Verify the update
if player.check_quickconnect_code(test_code):
print(f"✅ Verification successful - code '{test_code}' now works!")
else:
print(f"❌ Verification failed - something went wrong!")

View File

@@ -4,7 +4,12 @@
set -e
# Get the root directory of the application
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "🧹 Cleaning DigiServer v2 for deployment..."
echo "📍 App root: $APP_ROOT"
echo ""
# Confirm action
@@ -18,6 +23,9 @@ fi
echo ""
echo "📦 Cleaning development data..."
# Change to app root directory
cd "$APP_ROOT"
# Remove database files
if [ -d "instance" ]; then
echo " 🗄️ Removing database files..."

View File

@@ -0,0 +1,47 @@
"""Migration: Add edit_on_player_enabled column to playlist_content table."""
import sqlite3
import os
DB_PATH = 'instance/dashboard.db'
def migrate():
"""Add edit_on_player_enabled column to playlist_content."""
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return False
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
# Check if column already exists
cursor.execute("PRAGMA table_info(playlist_content)")
columns = [col[1] for col in cursor.fetchall()]
if 'edit_on_player_enabled' in columns:
print("Column 'edit_on_player_enabled' already exists!")
return True
# Add the new column with default value False
print("Adding 'edit_on_player_enabled' column to playlist_content table...")
cursor.execute("""
ALTER TABLE playlist_content
ADD COLUMN edit_on_player_enabled BOOLEAN DEFAULT 0
""")
conn.commit()
print("✅ Migration completed successfully!")
print("Column 'edit_on_player_enabled' added with default value False (0)")
return True
except Exception as e:
conn.rollback()
print(f"❌ Migration failed: {e}")
return False
finally:
conn.close()
if __name__ == '__main__':
migrate()

View File

@@ -1,254 +0,0 @@
"""
Player Authentication Module for Kiwy-Signage
Handles authentication with DigiServer v2 and secure config storage
"""
import configparser
import os
import requests
from typing import Optional, Dict, Tuple
import json
class PlayerAuth:
"""Handle player authentication and configuration management."""
def __init__(self, config_path: str = 'player_config.ini'):
"""Initialize player authentication.
Args:
config_path: Path to configuration file
"""
self.config_path = config_path
self.config = configparser.ConfigParser()
self.load_config()
def load_config(self) -> None:
"""Load configuration from file."""
if os.path.exists(self.config_path):
self.config.read(self.config_path)
else:
# Create default config
self._create_default_config()
def _create_default_config(self) -> None:
"""Create default configuration file."""
self.config['server'] = {
'server_url': 'http://localhost:5000'
}
self.config['player'] = {
'hostname': '',
'auth_code': '',
'player_id': '',
'group_id': ''
}
self.config['display'] = {
'orientation': 'Landscape',
'resolution': '1920x1080'
}
self.config['security'] = {
'verify_ssl': 'true',
'timeout': '30'
}
self.config['cache'] = {
'cache_dir': './cache',
'max_cache_size': '1024'
}
self.config['logging'] = {
'enabled': 'true',
'log_level': 'INFO',
'log_file': './player.log'
}
self.save_config()
def save_config(self) -> None:
"""Save configuration to file."""
with open(self.config_path, 'w') as f:
self.config.write(f)
def get_server_url(self) -> str:
"""Get server URL from config."""
return self.config.get('server', 'server_url', fallback='http://localhost:5000')
def get_hostname(self) -> str:
"""Get player hostname from config."""
return self.config.get('player', 'hostname', fallback='')
def get_auth_code(self) -> str:
"""Get saved auth code from config."""
return self.config.get('player', 'auth_code', fallback='')
def is_authenticated(self) -> bool:
"""Check if player has valid authentication."""
return bool(self.get_hostname() and self.get_auth_code())
def authenticate(self, hostname: str, password: str = None,
quickconnect_code: str = None) -> Tuple[bool, Optional[str]]:
"""Authenticate with server and save credentials.
Args:
hostname: Player hostname/identifier
password: Player password (optional if using quickconnect)
quickconnect_code: Quick connect code (optional if using password)
Returns:
Tuple of (success: bool, error_message: Optional[str])
"""
if not password and not quickconnect_code:
return False, "Password or quick connect code required"
server_url = self.get_server_url()
try:
# Make authentication request
response = requests.post(
f"{server_url}/api/auth/player",
json={
'hostname': hostname,
'password': password,
'quickconnect_code': quickconnect_code
},
timeout=int(self.config.get('security', 'timeout', fallback='30')),
verify=self.config.getboolean('security', 'verify_ssl', fallback=True)
)
if response.status_code == 200:
data = response.json()
# Save authentication data
self.config['player']['hostname'] = hostname
self.config['player']['auth_code'] = data.get('auth_code', '')
self.config['player']['player_id'] = str(data.get('player_id', ''))
self.config['player']['group_id'] = str(data.get('group_id', ''))
self.config['display']['orientation'] = data.get('orientation', 'Landscape')
self.save_config()
return True, None
else:
error_data = response.json()
return False, error_data.get('error', 'Authentication failed')
except requests.exceptions.ConnectionError:
return False, "Cannot connect to server"
except requests.exceptions.Timeout:
return False, "Connection timeout"
except Exception as e:
return False, f"Error: {str(e)}"
def verify_auth(self) -> Tuple[bool, Optional[Dict]]:
"""Verify current auth code with server.
Returns:
Tuple of (valid: bool, player_info: Optional[Dict])
"""
auth_code = self.get_auth_code()
if not auth_code:
return False, None
server_url = self.get_server_url()
try:
response = requests.post(
f"{server_url}/api/auth/verify",
json={'auth_code': auth_code},
timeout=int(self.config.get('security', 'timeout', fallback='30')),
verify=self.config.getboolean('security', 'verify_ssl', fallback=True)
)
if response.status_code == 200:
data = response.json()
return data.get('valid', False), data
return False, None
except Exception:
return False, None
def get_playlist(self) -> Optional[Dict]:
"""Get playlist for this player from server.
Returns:
Playlist data or None if failed
"""
auth_code = self.get_auth_code()
player_id = self.config.get('player', 'player_id', fallback='')
if not auth_code or not player_id:
return None
server_url = self.get_server_url()
try:
response = requests.get(
f"{server_url}/api/playlists/{player_id}",
headers={'Authorization': f'Bearer {auth_code}'},
timeout=int(self.config.get('security', 'timeout', fallback='30')),
verify=self.config.getboolean('security', 'verify_ssl', fallback=True)
)
if response.status_code == 200:
return response.json()
return None
except Exception:
return None
def send_heartbeat(self, status: str = 'online') -> bool:
"""Send heartbeat to server.
Args:
status: Player status
Returns:
True if successful, False otherwise
"""
auth_code = self.get_auth_code()
player_id = self.config.get('player', 'player_id', fallback='')
if not auth_code or not player_id:
return False
server_url = self.get_server_url()
try:
response = requests.post(
f"{server_url}/api/players/{player_id}/heartbeat",
headers={'Authorization': f'Bearer {auth_code}'},
json={'status': status},
timeout=int(self.config.get('security', 'timeout', fallback='30')),
verify=self.config.getboolean('security', 'verify_ssl', fallback=True)
)
return response.status_code == 200
except Exception:
return False
def clear_auth(self) -> None:
"""Clear saved authentication data."""
self.config['player']['auth_code'] = ''
self.config['player']['player_id'] = ''
self.config['player']['group_id'] = ''
self.save_config()
# Example usage
if __name__ == '__main__':
auth = PlayerAuth()
# Check if already authenticated
if auth.is_authenticated():
print(f"Already authenticated as: {auth.get_hostname()}")
# Verify authentication
valid, info = auth.verify_auth()
if valid:
print(f"Authentication valid: {info}")
else:
print("Authentication expired or invalid")
else:
print("Not authenticated. Please run authentication:")
print("auth.authenticate(hostname='player-001', password='your_password')")

View File

@@ -1,51 +0,0 @@
# Player Configuration File
# This file is automatically generated and updated by the signage player
# DO NOT EDIT MANUALLY unless you know what you're doing
[server]
# DigiServer URL (without trailing slash)
server_url = http://localhost:5000
[player]
# Player hostname/identifier (must be unique)
hostname =
# Player authentication code (obtained after first authentication)
auth_code =
# Player ID (assigned by server)
player_id =
# Group ID (assigned by server)
group_id =
[display]
# Display orientation: Landscape or Portrait
orientation = Landscape
# Screen resolution (width x height)
resolution = 1920x1080
[security]
# Enable SSL certificate verification
verify_ssl = true
# Connection timeout in seconds
timeout = 30
[cache]
# Local cache directory for downloaded content
cache_dir = ./cache
# Maximum cache size in MB
max_cache_size = 1024
[logging]
# Enable logging
enabled = true
# Log level: DEBUG, INFO, WARNING, ERROR
log_level = INFO
# Log file path
log_file = ./player.log

View File

@@ -16,9 +16,6 @@ Flask-Caching==2.1.0
SQLAlchemy==2.0.37
alembic==1.14.1
# Redis (for caching in production)
redis==5.0.1
# Date parsing
python-dateutil==2.9.0