Compare commits

...

19 Commits

Author SHA1 Message Date
613f53ba9e Release 1.7.1 2026-01-28 13:33:18 +01:00
5221f9f670 Release 1.7 2026-01-27 16:16:23 +01:00
0c2720618a Release 1.6.2 2026-01-26 15:34:52 +01:00
c5aa8a5156 Release 1.6.1 2026-01-25 18:35:28 +01:00
9fd3f03b87 Release 1.6 2026-01-25 18:00:12 +01:00
860679d119 Release 1.5 2026-01-25 17:14:18 +01:00
78f0f379fc Make image crop target size configurable 2026-01-25 16:54:01 +01:00
56760e380d Release v1.3 2026-01-25 15:57:38 +01:00
47aca9d64d Update playlist detail UI (priority/schedule/add-item) 2026-01-25 13:50:22 +01:00
f4b7fb62f5 Initial import 2026-01-25 13:26:45 +01:00
a5fe0f73a0 Update app 2026-01-25 12:03:08 +01:00
4df004c18a Scale display player by switching from SSE to polling 2026-01-24 19:59:00 +01:00
a9a1a6cdbe Add display deletion endpoint and admin UI tweaks 2026-01-24 19:28:25 +01:00
4d4ab086c9 Update app templates and routes 2026-01-24 10:09:33 +01:00
3684d98456 prodv1 2026-01-23 22:23:31 +01:00
f01de7a8e6 mail fix 2026-01-23 22:00:12 +01:00
97e17854b9 Update settings/admin UI and misc fixes 2026-01-23 21:21:56 +01:00
7f0092ff10 v1 2026-01-23 20:48:30 +01:00
ea3d0164f2 Add company dashboard improvements and upload/auth features 2026-01-23 20:21:11 +01:00
33 changed files with 6126 additions and 380 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.git/
.gitignore
.venv/
venv/
instance/
app/static/uploads/
.env
.flaskenv
*.sqlite
scripts/
README.md

View File

@@ -1,9 +1,9 @@
FLASK_APP=app:create_app FLASK_APP=app:create_app
FLASK_DEBUG=1 FLASK_DEBUG=1
SMTP_HOST=smtp.strato.de ##SMTP_HOST=smtp.strato.de
SMTP_PORT=465 ##SMTP_PORT=465
SMTP_USERNAME=beheer@alphen.cloud ##SMTP_USERNAME=beheer@alphen.cloud
SMTP_PASSWORD=Fr@nkrijk2024! ##SMTP_PASSWORD=***
SMTP_FROM=beheer@alphen.cloud ##SMTP_FROM=beheer@alphen.cloud
SMTP_STARTTLS=1 ##SMTP_STARTTLS=1
SMTP_DEBUG=1 ##SMTP_DEBUG=1

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# System deps (kept minimal). Pillow may need some libs; for most cases this is fine on slim.
# If you hit Pillow build/runtime issues, consider adding: libjpeg62-turbo, zlib1g, etc.
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir gunicorn
COPY . .
# Ensure entrypoint is executable
RUN chmod +x docker/entrypoint.sh
# Create runtime dirs (also mountable as volumes)
RUN mkdir -p instance app/static/uploads
EXPOSE 8000
# Default config (override at runtime)
ENV FLASK_ENV=production \
GUNICORN_WORKERS=2 \
GUNICORN_BIND=0.0.0.0:8000
# Run via WSGI entrypoint
CMD ["sh", "-c", "./docker/entrypoint.sh"]

207
README.md
View File

@@ -33,15 +33,201 @@ flask run --debug
Open http://127.0.0.1:5000 Open http://127.0.0.1:5000
## Production (WSGI)
This repo includes a `wsgi.py` entrypoint for production WSGI servers.
### Important (Windows)
If you try to run Gunicorn directly on Windows you will see an error like:
```
ModuleNotFoundError: No module named 'fcntl'
```
Thats expected: **Gunicorn is Unix-only**. On Windows, run the app via:
- **Docker** (recommended) so Gunicorn runs inside a Linux container, or
- **WSL2/Linux** (Gunicorn works), or
- use a Windows-native WSGI server (e.g. Waitress) instead of Gunicorn.
Examples:
```bash
# gunicorn (Linux)
gunicorn -w 4 -b 0.0.0.0:8000 wsgi:app
# uWSGI
uwsgi --http :8000 --wsgi-file wsgi.py --callable app
```
Note: unlike `flask run`, WSGI servers typically don't auto-load `.env` / `.flaskenv`.
`wsgi.py` attempts to load `.env` (best-effort), but for real production you should set
environment variables via your process manager / secrets.
## Docker
### Docker Compose (recommended)
This repo includes a `docker-compose.yml` for a one-command startup.
On first run, the container will ensure the SQLite schema exists.
If you provide `ADMIN_PASS`, it will also create/update the initial admin user.
```powershell
docker compose up --build
```
Create an admin on startup (recommended):
```powershell
$env:ADMIN_EMAIL="you@example.com"
$env:ADMIN_PASS="YourStrongPassword"
docker compose up --build
```
Or put these in a `.env` file used by Compose.
Run in the background:
```powershell
docker compose up -d --build
```
Stop:
```powershell
docker compose down
```
Data persistence:
- SQLite DB is mounted to `./instance` on your host
- uploads are mounted to `./app/static/uploads` on your host
Build:
```bash
docker build -t signage:latest .
```
Run (with persistent SQLite DB + uploads):
```bash
docker run --rm -p 8000:8000 \
-e SECRET_KEY="change-me" \
-v %cd%/instance:/app/instance \
-v %cd%/app/static/uploads:/app/app/static/uploads \
signage:latest
```
PowerShell variant (sometimes volume path quoting is easier):
```powershell
docker run --rm -p 8000:8000 `
-e SECRET_KEY="change-me" `
-v "${PWD}/instance:/app/instance" `
-v "${PWD}/app/static/uploads:/app/app/static/uploads" `
signage:latest
```
Then open: http://127.0.0.1:8000
Notes:
- The container starts with Gunicorn using `wsgi:app`.
- You can override Gunicorn settings via env vars:
- `GUNICORN_WORKERS` (default: 2)
- `GUNICORN_BIND` (default: `0.0.0.0:8000`)
## Release helper (git + docker publish)
This repo includes a small helper to:
1) ask for a **commit message** and **version**
2) commit + push to the `openslide` git remote
3) build + push Docker images:
- `git.alphen.cloud/bramval/openslide:<version>`
- `git.alphen.cloud/bramval/openslide:latest`
Run (interactive):
```bash
python scripts/release.py
```
Run (non-interactive):
```bash
python scripts/release.py --version 1.2.3 --message "Release 1.2.3"
```
Dry-run (prints commands only):
```bash
python scripts/release.py --version 1.2.3 --message "Release 1.2.3" --dry-run
```
## Notes ## Notes
- SQLite DB is stored at `instance/signage.sqlite`. - SQLite DB is stored at `instance/signage.sqlite`.
- Uploaded files go to `app/static/uploads/`. - Uploaded files go to `app/static/uploads/`.
## Display player
Open:
- `http://<host>/display/<token>` for live playback (counts towards the concurrent display limit)
- `http://<host>/display/<token>?preview=1` for preview (does not count towards the concurrent display limit)
### Live updates
The player keeps itself up-to-date automatically:
- It listens to `GET /api/display/<token>/events` (Server-Sent Events) and reloads the playlist immediately when it changes.
- It also does a fallback playlist refresh every 5 minutes for networks/proxies that block SSE.
## Ticker tape (RSS headlines)
Each display can optionally show a **bottom ticker tape** with scrolling news headlines.
Configure RSS + styling as a company user via:
- **My Company → Ticker tape (RSS)**
Company-level options:
- RSS feed URL (public http/https)
- Text color (picker)
- Background color + opacity
- Font (dropdown)
- Font size
- Speed
Per-display option:
- Enable/disable ticker on that display (Dashboard → Displays → Configure display)
Implementation notes:
- Headlines are fetched server-side via `GET /api/display/<token>/ticker` and cached in-memory.
- The player reads the company ticker settings via `GET /api/display/<token>/playlist`.
- The player auto-refreshes headlines without restart on a **long interval** (default: **12 hours**, override via `?ticker_poll=seconds`).
- Server-side cache TTL defaults to **6 hours** (override via env var `TICKER_CACHE_TTL_SECONDS`).
## SMTP / Forgot password ## SMTP / Forgot password
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables. This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
You can also configure SMTP settings from the UI: **Admin → Settings**.
Environment variables still take precedence over the database settings.
### Public domain for emails
If your app runs behind a reverse proxy (or the internal hostname differs from the public hostname),
set **Admin → Settings → Public domain** to e.g. `signage.example.com` so links in password reset
emails point to the correct address.
Recommended: put these in a local `.env` file in the repo root. Flask (via `python-dotenv`) will auto-load it on startup. `.env` is already gitignored. Recommended: put these in a local `.env` file in the repo root. Flask (via `python-dotenv`) will auto-load it on startup. `.env` is already gitignored.
You can start from `.env.example`: You can start from `.env.example`:
@@ -67,6 +253,11 @@ REM Option B: put the same keys/values in a .env file instead
Security note: do **not** commit SMTP passwords to the repo. Prefer secrets management and rotate leaked credentials. Security note: do **not** commit SMTP passwords to the repo. Prefer secrets management and rotate leaked credentials.
Note on the "From" address: some SMTP providers enforce that the authenticated mailbox
(`SMTP_USERNAME`) is used as the actual sender (envelope-from), even if a different
`SMTP_FROM` is provided. In that case the app sets a `Reply-To` header so replies still
go to `SMTP_FROM`, but the provider may still show the username address as the sender.
### Troubleshooting mail delivery ### Troubleshooting mail delivery
If the reset email is not received: If the reset email is not received:
@@ -84,6 +275,22 @@ If the reset email is not received:

View File

@@ -3,8 +3,8 @@ from flask import Flask, jsonify, request
from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.exceptions import RequestEntityTooLarge
from .extensions import db, login_manager from .extensions import db, login_manager
from .models import User from .models import AppSettings, User
from .cli import init_db_command from .cli import ensure_db_command, init_db_command
def create_app(): def create_app():
@@ -20,6 +20,24 @@ def create_app():
app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False) app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False)
app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads")) app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads"))
# Target output resolution for cropped images.
# This is used by the client-side cropper (to generate an upload) and by the server-side
# image processing (to cap the resulting WEBP size).
#
# Defaults to Full HD landscape (1920x1080). Portrait is derived by swapping.
# Override via env vars, e.g.:
# IMAGE_CROP_TARGET_W=1920
# IMAGE_CROP_TARGET_H=1080
def _env_int(name: str, default: int) -> int:
try:
v = int(os.environ.get(name, "") or default)
except (TypeError, ValueError):
v = default
return max(1, v)
app.config.setdefault("IMAGE_CROP_TARGET_W", _env_int("IMAGE_CROP_TARGET_W", 1920))
app.config.setdefault("IMAGE_CROP_TARGET_H", _env_int("IMAGE_CROP_TARGET_H", 1080))
# NOTE: Videos should be max 250MB. # NOTE: Videos should be max 250MB.
# Flask's MAX_CONTENT_LENGTH applies to the full request payload (multipart includes overhead). # Flask's MAX_CONTENT_LENGTH applies to the full request payload (multipart includes overhead).
# We set this slightly above 250MB to allow for multipart/form fields overhead, while still # We set this slightly above 250MB to allow for multipart/form fields overhead, while still
@@ -56,6 +74,162 @@ def create_app():
if "description" not in display_cols: if "description" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)")) db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)"))
db.session.commit() db.session.commit()
# Displays: optional transition between slides (none|fade|slide)
if "transition" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
db.session.commit()
# Displays: per-display overlay toggle
if "show_overlay" not in display_cols:
db.session.execute(
db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0")
)
db.session.commit()
# Displays: optional ticker tape (RSS headlines)
if "ticker_enabled" not in display_cols:
db.session.execute(
db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0")
)
db.session.commit()
if "ticker_rss_url" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER"))
db.session.commit()
# Companies: optional per-company storage quota
company_cols = [
r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()
]
if "storage_max_bytes" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
db.session.commit()
# Companies: optional overlay file path
if "overlay_file_path" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
db.session.commit()
# Companies: ticker tape settings (RSS + styling)
if "ticker_rss_url" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER"))
db.session.commit()
# AppSettings: create settings table if missing.
# (PRAGMA returns empty if the table doesn't exist.)
settings_cols = [
r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()
]
if not settings_cols:
AppSettings.__table__.create(db.engine, checkfirst=True)
# AppSettings: add public_domain column if missing.
if settings_cols and "public_domain" not in settings_cols:
db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)"))
db.session.commit()
# DisplayPlaylist: create association table for multi-playlist displays.
dp_cols = [
r[1] for r in db.session.execute(db.text("PRAGMA table_info(display_playlist)")).fetchall()
]
if not dp_cols:
# Create association table for multi-playlist displays.
# Keep schema compatible with older DBs that include an autoincrement id and position.
db.session.execute(
db.text(
"""
CREATE TABLE IF NOT EXISTS display_playlist (
id INTEGER PRIMARY KEY,
display_id INTEGER NOT NULL,
playlist_id INTEGER NOT NULL,
position INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
UNIQUE(display_id, playlist_id),
FOREIGN KEY(display_id) REFERENCES display (id),
FOREIGN KEY(playlist_id) REFERENCES playlist (id)
)
"""
)
)
db.session.commit()
else:
# Best-effort column additions for older/newer variants.
if "position" not in dp_cols:
db.session.execute(
db.text("ALTER TABLE display_playlist ADD COLUMN position INTEGER NOT NULL DEFAULT 1")
)
db.session.commit()
if "created_at" not in dp_cols:
# Use CURRENT_TIMESTAMP as a reasonable default for existing rows.
db.session.execute(
db.text(
"ALTER TABLE display_playlist ADD COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"
)
)
db.session.commit()
if "id" not in dp_cols:
# Cannot add PRIMARY KEY via ALTER TABLE; keep nullable for compatibility.
db.session.execute(db.text("ALTER TABLE display_playlist ADD COLUMN id INTEGER"))
db.session.commit()
# Ensure uniqueness index exists (no-op if already present)
db.session.execute(
db.text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_display_playlist_display_playlist ON display_playlist (display_id, playlist_id)"
)
)
db.session.commit()
# Playlists: schedule + priority flags
playlist_cols = [
r[1] for r in db.session.execute(db.text("PRAGMA table_info(playlist)")).fetchall()
]
if "schedule_start" not in playlist_cols:
db.session.execute(db.text("ALTER TABLE playlist ADD COLUMN schedule_start DATETIME"))
db.session.commit()
if "schedule_end" not in playlist_cols:
db.session.execute(db.text("ALTER TABLE playlist ADD COLUMN schedule_end DATETIME"))
db.session.commit()
if "is_priority" not in playlist_cols:
db.session.execute(
db.text("ALTER TABLE playlist ADD COLUMN is_priority BOOLEAN NOT NULL DEFAULT 0")
)
db.session.commit()
except Exception: except Exception:
db.session.rollback() db.session.rollback()
@@ -64,6 +238,7 @@ def create_app():
return db.session.get(User, int(user_id)) return db.session.get(User, int(user_id))
# CLI # CLI
app.cli.add_command(ensure_db_command)
app.cli.add_command(init_db_command) app.cli.add_command(init_db_command)
# Blueprints # Blueprints

27
app/auth_tokens.py Normal file
View File

@@ -0,0 +1,27 @@
"""Shared auth token helpers.
We keep password reset/invite token logic in one place so it can be used by:
- the normal "forgot password" flow
- company "invite user" flow
Tokens are signed with Flask SECRET_KEY and time-limited.
"""
from __future__ import annotations
from itsdangerous import URLSafeTimedSerializer
def _serializer(secret_key: str) -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(secret_key, salt="password-reset")
def make_password_reset_token(*, secret_key: str, user_id: int) -> str:
s = _serializer(secret_key)
return s.dumps({"user_id": int(user_id)})
def load_password_reset_user_id(*, secret_key: str, token: str, max_age_seconds: int) -> int:
s = _serializer(secret_key)
data = s.loads(token, max_age=max_age_seconds)
return int(data.get("user_id"))

View File

@@ -2,7 +2,116 @@ import click
from flask.cli import with_appcontext from flask.cli import with_appcontext
from .extensions import db from .extensions import db
from .models import User from .models import AppSettings, User
def _ensure_schema_and_settings() -> None:
"""Create tables + run lightweight SQLite migrations + ensure settings row exists."""
db.create_all()
# Lightweight migration for older SQLite DBs: ensure columns exist.
# This avoids requiring Alembic for this small project.
try:
cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(user)")).fetchall()]
if "email" not in cols:
db.session.execute(db.text("ALTER TABLE user ADD COLUMN email VARCHAR(255)"))
db.session.commit()
display_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(display)")).fetchall()]
if "description" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)"))
db.session.commit()
if "transition" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
db.session.commit()
if "show_overlay" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit()
# Optional ticker tape (RSS headlines)
if "ticker_enabled" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit()
if "ticker_rss_url" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER"))
db.session.commit()
company_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()]
if "storage_max_bytes" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
db.session.commit()
if "overlay_file_path" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
db.session.commit()
# Companies: ticker tape settings (RSS + styling)
if "ticker_rss_url" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER"))
db.session.commit()
settings_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()]
if settings_cols and "public_domain" not in settings_cols:
db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)"))
db.session.commit()
except Exception:
# Best-effort; if it fails we continue so fresh DBs still work.
db.session.rollback()
# Ensure AppSettings row exists.
if not db.session.get(AppSettings, 1):
db.session.add(AppSettings(id=1))
db.session.commit()
@click.command("ensure-db")
@with_appcontext
def ensure_db_command():
"""Create tables / apply lightweight migrations.
This is useful for container startup where you want schema readiness,
without requiring admin credentials.
"""
_ensure_schema_and_settings()
click.echo("Database ready.")
@click.command("init-db") @click.command("init-db")
@@ -17,23 +126,7 @@ from .models import User
@with_appcontext @with_appcontext
def init_db_command(admin_email: str, admin_pass: str): def init_db_command(admin_email: str, admin_pass: str):
"""Create tables and ensure an admin account exists.""" """Create tables and ensure an admin account exists."""
db.create_all() _ensure_schema_and_settings()
# Lightweight migration for older SQLite DBs: ensure User.email column exists.
# This avoids requiring Alembic for this small project.
try:
cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(user)")).fetchall()]
if "email" not in cols:
db.session.execute(db.text("ALTER TABLE user ADD COLUMN email VARCHAR(255)"))
db.session.commit()
display_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(display)")).fetchall()]
if "description" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)"))
db.session.commit()
except Exception:
# Best-effort; if it fails we continue so fresh DBs still work.
db.session.rollback()
admin_email = (admin_email or "").strip().lower() admin_email = (admin_email or "").strip().lower()
if not admin_email: if not admin_email:

View File

@@ -3,10 +3,27 @@ import smtplib
from email.message import EmailMessage from email.message import EmailMessage
def send_email(*, to_email: str, subject: str, body_text: str): def _truthy(v: str | None) -> bool:
"""Send a plain-text email using SMTP settings from environment variables. if v is None:
return False
return v.strip().lower() in ("1", "true", "yes", "on")
Required env vars:
def send_email(*, to_email: str, subject: str, body_text: str):
"""Send a plain-text email using SMTP settings from:
1) Admin-configured settings stored in the database (AppSettings) (highest priority)
2) Environment variables (fallback)
If you *do* want environment variables to override DB settings (e.g. in production),
set SMTP_OVERRIDE_DB=1.
Required configuration (either from DB or env):
- host
- username
- password
When using env vars, the names are:
- SMTP_HOST - SMTP_HOST
- SMTP_PORT - SMTP_PORT
- SMTP_USERNAME - SMTP_USERNAME
@@ -19,43 +36,109 @@ def send_email(*, to_email: str, subject: str, body_text: str):
- SMTP_DEBUG (default: "0") - set to 1 to print SMTP conversation to console - SMTP_DEBUG (default: "0") - set to 1 to print SMTP conversation to console
""" """
host = os.environ.get("SMTP_HOST") # Load DB settings (best-effort). If the DB has a complete SMTP config, we will use
port = int(os.environ.get("SMTP_PORT", "587")) # it even if environment variables are present.
username = os.environ.get("SMTP_USERNAME") #
password = os.environ.get("SMTP_PASSWORD") # This fixes the common deployment situation where a .env/.flaskenv provides SMTP_*
from_email = os.environ.get("SMTP_FROM") or username # values that unintentionally override admin-configured settings.
starttls = os.environ.get("SMTP_STARTTLS", "1").lower() in ("1", "true", "yes", "on") db_defaults: dict[str, object] = {}
timeout = float(os.environ.get("SMTP_TIMEOUT_SECONDS", "10")) try:
debug = os.environ.get("SMTP_DEBUG", "0").lower() in ("1", "true", "yes", "on") # Local import to avoid import cycles and to keep this module lightweight.
from flask import current_app
from .extensions import db
from .models import AppSettings
# Only try if app context exists (send_email is called inside requests/CLI normally).
# Accessing the LocalProxy will raise if there is no active app context.
current_app._get_current_object()
s = db.session.get(AppSettings, 1)
if s:
db_defaults = {
"host": s.smtp_host,
"port": s.smtp_port,
"username": s.smtp_username,
"password": s.smtp_password,
"from_email": s.smtp_from,
"starttls": s.smtp_starttls,
"timeout": s.smtp_timeout_seconds,
"debug": s.smtp_debug,
}
except Exception:
# Best-effort; if DB isn't ready we fall back to env vars only.
db_defaults = {}
# DB-first selection rules:
# - If *any* SMTP field is configured in DB, treat DB as authoritative (and do NOT
# silently mix in env vars). This avoids confusion where partial DB config still
# results in env vars being used.
# - Env override is only possible with SMTP_OVERRIDE_DB=1.
override_db = _truthy(os.environ.get("SMTP_OVERRIDE_DB"))
db_config_present = bool(
db_defaults.get("host")
or db_defaults.get("username")
or db_defaults.get("password")
or db_defaults.get("from_email")
or (db_defaults.get("port") is not None)
)
if db_config_present and not override_db:
config_source = "db"
host = str(db_defaults.get("host") or "") or None
port = int(db_defaults.get("port") or 587)
username = str(db_defaults.get("username") or "") or None
password = str(db_defaults.get("password") or "") or None
from_email = (str(db_defaults.get("from_email")) if db_defaults.get("from_email") else None) or username
starttls = bool(db_defaults.get("starttls") if db_defaults.get("starttls") is not None else True)
timeout = float(db_defaults.get("timeout") or 10)
debug = bool(db_defaults.get("debug") or False)
else:
config_source = "env"
host = os.environ.get("SMTP_HOST")
port = int(os.environ.get("SMTP_PORT") or 587)
username = os.environ.get("SMTP_USERNAME")
password = os.environ.get("SMTP_PASSWORD")
from_email = os.environ.get("SMTP_FROM") or username
starttls = _truthy(os.environ.get("SMTP_STARTTLS")) if os.environ.get("SMTP_STARTTLS") is not None else True
timeout = float(os.environ.get("SMTP_TIMEOUT_SECONDS") or 10)
debug = _truthy(os.environ.get("SMTP_DEBUG")) if os.environ.get("SMTP_DEBUG") is not None else False
missing = [] missing = []
if not host: if not host:
missing.append("SMTP_HOST") missing.append("host")
if not username: if not username:
missing.append("SMTP_USERNAME") missing.append("username")
if not password: if not password:
missing.append("SMTP_PASSWORD") missing.append("password")
if not from_email: if not from_email:
missing.append("SMTP_FROM") missing.append("from_email")
if missing: if missing:
raise RuntimeError( raise RuntimeError(
"Missing SMTP configuration: " f"Missing SMTP configuration ({config_source}): "
+ ", ".join(missing) + ", ".join(missing)
+ ". Set them as environment variables (or in a local .env file)." + ". Configure it in Admin → Settings (or set SMTP_* environment variables)."
) )
msg = EmailMessage() msg = EmailMessage()
msg["From"] = from_email msg["From"] = from_email
msg["To"] = to_email msg["To"] = to_email
msg["Subject"] = subject msg["Subject"] = subject
# Helps when SMTP providers force the authenticated mailbox as envelope sender,
# but still allow replies to go to the desired address.
if from_email and username and from_email != username:
msg["Reply-To"] = from_email
msg.set_content(body_text) msg.set_content(body_text)
with smtplib.SMTP(host, port, timeout=timeout) as smtp: with smtplib.SMTP(host, port, timeout=timeout) as smtp:
if debug: if debug:
smtp.set_debuglevel(1) smtp.set_debuglevel(1)
print(f"[email_utils] Using SMTP config from: {config_source}")
smtp.ehlo() smtp.ehlo()
if starttls: if starttls:
smtp.starttls() smtp.starttls()
smtp.ehlo() smtp.ehlo()
smtp.login(username, password) smtp.login(username, password)
smtp.send_message(msg)
# Pass explicit envelope-from to avoid falling back to the authenticated user.
# Note: some SMTP providers will still override this for anti-spoofing.
smtp.send_message(msg, from_addr=from_email, to_addrs=[to_email])

View File

@@ -12,6 +12,24 @@ class Company(db.Model):
name = db.Column(db.String(120), unique=True, nullable=False) name = db.Column(db.String(120), unique=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Optional per-company storage quota for uploaded media (bytes).
# If NULL or <=0: unlimited.
storage_max_bytes = db.Column(db.BigInteger, nullable=True)
# Optional per-company 16:9 PNG overlay (stored under /static/uploads/...)
# Example: uploads/<company_id>/overlay_<uuid>.png
overlay_file_path = db.Column(db.String(400), nullable=True)
# Optional per-company ticker tape (RSS headlines) settings.
# Displays can enable/disable the ticker individually.
ticker_rss_url = db.Column(db.String(1000), nullable=True)
ticker_color = db.Column(db.String(32), nullable=True)
ticker_bg_color = db.Column(db.String(32), nullable=True)
ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100
ticker_font_family = db.Column(db.String(120), nullable=True)
ticker_font_size_px = db.Column(db.Integer, nullable=True)
ticker_speed = db.Column(db.Integer, nullable=True) # 1-100
users = db.relationship("User", back_populates="company", cascade="all, delete-orphan") users = db.relationship("User", back_populates="company", cascade="all, delete-orphan")
displays = db.relationship("Display", back_populates="company", cascade="all, delete-orphan") displays = db.relationship("Display", back_populates="company", cascade="all, delete-orphan")
playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan") playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan")
@@ -45,6 +63,18 @@ class Playlist(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False) company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False)
name = db.Column(db.String(120), nullable=False) name = db.Column(db.String(120), nullable=False)
# Optional schedule window in UTC.
# - If both are NULL: playlist is always active.
# - If start is set: playlist is active from start onward.
# - If end is set: playlist is active until end.
schedule_start = db.Column(db.DateTime, nullable=True)
schedule_end = db.Column(db.DateTime, nullable=True)
# If true, this playlist's items take precedence over non-priority playlists
# when multiple playlists are assigned to a display.
is_priority = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
company = db.relationship("Company", back_populates="playlists") company = db.relationship("Company", back_populates="playlists")
@@ -84,11 +114,42 @@ class Display(db.Model):
name = db.Column(db.String(120), nullable=False) name = db.Column(db.String(120), nullable=False)
# Optional short description (e.g. "entrance", "office") # Optional short description (e.g. "entrance", "office")
description = db.Column(db.String(200), nullable=True) description = db.Column(db.String(200), nullable=True)
# Transition animation between slides: none|fade|slide
transition = db.Column(db.String(20), nullable=True)
# Optional ticker tape (RSS headlines) rendered on the display.
# Note: for this small project we avoid a JSON config blob; we store a few explicit columns.
ticker_enabled = db.Column(db.Boolean, default=False, nullable=False)
ticker_rss_url = db.Column(db.String(1000), nullable=True)
ticker_color = db.Column(db.String(32), nullable=True) # CSS color, e.g. "#ffffff"
ticker_bg_color = db.Column(db.String(32), nullable=True) # hex (without alpha); opacity in ticker_bg_opacity
ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100
ticker_font_family = db.Column(db.String(120), nullable=True) # CSS font-family
ticker_font_size_px = db.Column(db.Integer, nullable=True) # px
ticker_speed = db.Column(db.Integer, nullable=True) # 1-100 (UI slider); higher = faster
# If true, show the company's overlay PNG on top of the display content.
show_overlay = db.Column(db.Boolean, default=False, nullable=False)
token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex) token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex)
assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True) assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True)
assigned_playlist = db.relationship("Playlist") assigned_playlist = db.relationship("Playlist")
# Multi-playlist support (active playlists per display).
# If a display has any rows in display_playlist, those are used by the player.
# If not, we fall back to assigned_playlist_id for backwards compatibility.
display_playlists = db.relationship(
"DisplayPlaylist",
back_populates="display",
cascade="all, delete-orphan",
)
playlists = db.relationship(
"Playlist",
secondary="display_playlist",
viewonly=True,
order_by="Playlist.name.asc()",
)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
company = db.relationship("Company", back_populates="displays") company = db.relationship("Company", back_populates="displays")
@@ -111,3 +172,64 @@ class DisplaySession(db.Model):
display = db.relationship("Display") display = db.relationship("Display")
__table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),) __table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),)
class DisplayPlaylist(db.Model):
"""Association table: which playlists are active on a display."""
# NOTE: Some existing databases include an `id` INTEGER PRIMARY KEY column and a
# NOT NULL `position` column on display_playlist. We keep the mapper primary key as
# (display_id, playlist_id) for portability, while allowing an optional `id` column
# to exist in the underlying table.
id = db.Column(db.Integer, nullable=True)
# Composite mapper PK ensures uniqueness per display.
display_id = db.Column(db.Integer, db.ForeignKey("display.id"), primary_key=True)
playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), primary_key=True)
# Ordering of playlists within a display.
position = db.Column(db.Integer, default=1, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
display = db.relationship("Display", back_populates="display_playlists")
playlist = db.relationship("Playlist")
__table_args__ = (
db.UniqueConstraint("display_id", "playlist_id", name="uq_display_playlist_display_playlist"),
)
class AppSettings(db.Model):
"""Singleton-ish app-wide settings.
For this small project we avoid Alembic migrations; this table can be created via
`flask init-db` (db.create_all) and is also created best-effort on app startup.
NOTE: SMTP password is stored in plaintext in the database.
Prefer environment variables / secrets management in production when possible.
"""
id = db.Column(db.Integer, primary_key=True)
smtp_host = db.Column(db.String(255), nullable=True)
smtp_port = db.Column(db.Integer, nullable=True)
smtp_username = db.Column(db.String(255), nullable=True)
smtp_password = db.Column(db.String(255), nullable=True)
smtp_from = db.Column(db.String(255), nullable=True)
smtp_starttls = db.Column(db.Boolean, default=True, nullable=False)
smtp_timeout_seconds = db.Column(db.Float, default=10.0, nullable=False)
smtp_debug = db.Column(db.Boolean, default=False, nullable=False)
# Public domain for generating absolute links in emails.
# Example: "signage.example.com" (no scheme)
public_domain = 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,
)

View File

@@ -6,7 +6,9 @@ from flask import Blueprint, abort, current_app, flash, redirect, render_templat
from flask_login import current_user, login_required, login_user from flask_login import current_user, login_required, login_user
from ..extensions import db from ..extensions import db
from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User from ..uploads import abs_upload_path, ensure_company_upload_dir, get_company_upload_bytes, is_valid_upload_relpath
from ..models import AppSettings, Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem, User
from ..email_utils import send_email
bp = Blueprint("admin", __name__, url_prefix="/admin") bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -16,15 +18,28 @@ def admin_required():
abort(403) abort(403)
def _get_app_settings() -> AppSettings:
"""Get the singleton-ish AppSettings row, creating it if needed."""
s = db.session.get(AppSettings, 1)
if s:
return s
s = AppSettings(id=1)
db.session.add(s)
db.session.commit()
return s
def _try_delete_upload(file_path: str | None, upload_folder: str): def _try_delete_upload(file_path: str | None, upload_folder: str):
"""Best-effort delete of an uploaded media file.""" """Best-effort delete of an uploaded media file."""
if not file_path: if not file_path:
return return
if not file_path.startswith("uploads/"): if not is_valid_upload_relpath(file_path):
return return
filename = file_path.split("/", 1)[1] abs_path = abs_upload_path(upload_folder, file_path)
abs_path = os.path.join(upload_folder, filename) if not abs_path:
return
try: try:
if os.path.isfile(abs_path): if os.path.isfile(abs_path):
os.remove(abs_path) os.remove(abs_path)
@@ -41,6 +56,188 @@ def dashboard():
return render_template("admin/dashboard.html", companies=companies) return render_template("admin/dashboard.html", companies=companies)
@bp.get("/settings")
@login_required
def settings():
admin_required()
settings = _get_app_settings()
admins = User.query.filter_by(is_admin=True).order_by(User.email.asc()).all()
return render_template("admin/settings.html", settings=settings, admins=admins)
@bp.post("/settings/smtp")
@login_required
def update_smtp_settings():
admin_required()
s = _get_app_settings()
smtp_host = (request.form.get("smtp_host") or "").strip() or None
smtp_port_raw = (request.form.get("smtp_port") or "").strip()
smtp_username = (request.form.get("smtp_username") or "").strip() or None
smtp_password = request.form.get("smtp_password")
smtp_from = (request.form.get("smtp_from") or "").strip() or None
smtp_starttls = (request.form.get("smtp_starttls") or "").lower() in ("1", "true", "yes", "on")
smtp_debug = (request.form.get("smtp_debug") or "").lower() in ("1", "true", "yes", "on")
smtp_timeout_raw = (request.form.get("smtp_timeout_seconds") or "").strip()
smtp_port: int | None = None
if smtp_port_raw:
try:
smtp_port = int(smtp_port_raw)
except ValueError:
flash("SMTP port must be a number", "danger")
return redirect(url_for("admin.settings"))
smtp_timeout: float = 10.0
if smtp_timeout_raw:
try:
smtp_timeout = float(smtp_timeout_raw)
except ValueError:
flash("SMTP timeout must be a number (seconds)", "danger")
return redirect(url_for("admin.settings"))
if smtp_port is not None and (smtp_port <= 0 or smtp_port > 65535):
flash("SMTP port must be between 1 and 65535", "danger")
return redirect(url_for("admin.settings"))
if smtp_timeout <= 0:
flash("SMTP timeout must be > 0", "danger")
return redirect(url_for("admin.settings"))
s.smtp_host = smtp_host
s.smtp_port = smtp_port
s.smtp_username = smtp_username
# Only overwrite password if a value was submitted.
# This allows editing other SMTP fields without having to re-enter the password.
if smtp_password is not None and smtp_password.strip() != "":
s.smtp_password = smtp_password
s.smtp_from = smtp_from
s.smtp_starttls = smtp_starttls
s.smtp_timeout_seconds = smtp_timeout
s.smtp_debug = smtp_debug
db.session.commit()
flash("SMTP settings saved.", "success")
return redirect(url_for("admin.settings"))
@bp.post("/settings/domain")
@login_required
def update_public_domain():
admin_required()
s = _get_app_settings()
raw = (request.form.get("public_domain") or "").strip().lower()
if raw:
# Normalize: user asked for domain-only (no scheme). Strip possible scheme anyway.
raw = raw.replace("http://", "").replace("https://", "")
raw = raw.strip().strip("/")
if "/" in raw:
flash("Public domain must not contain a path. Example: signage.example.com", "danger")
return redirect(url_for("admin.settings"))
if " " in raw:
flash("Public domain must not contain spaces", "danger")
return redirect(url_for("admin.settings"))
s.public_domain = raw
else:
s.public_domain = None
db.session.commit()
flash("Public domain saved.", "success")
return redirect(url_for("admin.settings"))
@bp.post("/settings/test-email")
@login_required
def send_test_email():
admin_required()
to_email = (request.form.get("to_email") or "").strip().lower()
if not to_email:
flash("Test email recipient is required", "danger")
return redirect(url_for("admin.settings"))
try:
send_email(
to_email=to_email,
subject="Signage SMTP test",
body_text="This is a test email from Signage (Admin → Settings).",
)
except Exception as e:
# Show a short error to the admin (they requested a test).
flash(f"Failed to send test email: {e}", "danger")
return redirect(url_for("admin.settings"))
flash(f"Test email sent to {to_email}", "success")
return redirect(url_for("admin.settings"))
@bp.post("/settings/admins")
@login_required
def create_admin_user():
admin_required()
email = (request.form.get("email") or "").strip().lower()
password = request.form.get("password") or ""
if not email or not password:
flash("Email and password are required", "danger")
return redirect(url_for("admin.settings"))
if len(password) < 8:
flash("Password must be at least 8 characters", "danger")
return redirect(url_for("admin.settings"))
existing = User.query.filter_by(email=email).first()
if existing:
if existing.is_admin:
flash("That user is already an admin", "warning")
return redirect(url_for("admin.settings"))
# Promote existing user to admin
existing.is_admin = True
existing.set_password(password)
existing.email = email
existing.username = email
existing.company_id = None
db.session.commit()
flash("User promoted to admin.", "success")
return redirect(url_for("admin.settings"))
u = User(is_admin=True)
u.email = email
u.username = email
u.set_password(password)
db.session.add(u)
db.session.commit()
flash("Admin user created.", "success")
return redirect(url_for("admin.settings"))
@bp.post("/settings/admins/<int:user_id>/demote")
@login_required
def demote_admin_user(user_id: int):
admin_required()
if current_user.id == user_id:
flash("You cannot demote yourself", "danger")
return redirect(url_for("admin.settings"))
u = db.session.get(User, user_id)
if not u:
abort(404)
if not u.is_admin:
flash("That user is not an admin", "warning")
return redirect(url_for("admin.settings"))
# Ensure we always keep at least one admin.
admin_count = User.query.filter_by(is_admin=True).count()
if admin_count <= 1:
flash("Cannot demote the last remaining admin", "danger")
return redirect(url_for("admin.settings"))
u.is_admin = False
db.session.commit()
flash("Admin user demoted.", "success")
return redirect(url_for("admin.settings"))
@bp.post("/companies") @bp.post("/companies")
@login_required @login_required
def create_company(): def create_company():
@@ -55,6 +252,13 @@ def create_company():
c = Company(name=name) c = Company(name=name)
db.session.add(c) db.session.add(c)
db.session.commit() db.session.commit()
# Create the per-company upload directory eagerly (best-effort).
try:
ensure_company_upload_dir(current_app.config["UPLOAD_FOLDER"], c.id)
except Exception:
# Upload directory is also created lazily on first upload.
pass
flash("Company created", "success") flash("Company created", "success")
return redirect(url_for("admin.company_detail", company_id=c.id)) return redirect(url_for("admin.company_detail", company_id=c.id))
@@ -66,7 +270,52 @@ def company_detail(company_id: int):
company = db.session.get(Company, company_id) company = db.session.get(Company, company_id)
if not company: if not company:
abort(404) abort(404)
return render_template("admin/company_detail.html", company=company)
upload_root = current_app.config["UPLOAD_FOLDER"]
used_bytes = get_company_upload_bytes(upload_root, company.id)
return render_template(
"admin/company_detail.html",
company=company,
storage={
"used_bytes": used_bytes,
},
)
@bp.post("/companies/<int:company_id>/storage")
@login_required
def update_company_storage(company_id: int):
admin_required()
company = db.session.get(Company, company_id)
if not company:
abort(404)
raw = (request.form.get("storage_max_mb") or "").strip()
if raw == "":
# Treat empty as unlimited
company.storage_max_bytes = None
db.session.commit()
flash("Storage limit cleared (unlimited).", "success")
return redirect(url_for("admin.company_detail", company_id=company_id))
try:
mb = float(raw)
except ValueError:
flash("Invalid storage limit. Please enter a number (MB).", "danger")
return redirect(url_for("admin.company_detail", company_id=company_id))
if mb <= 0:
company.storage_max_bytes = None
db.session.commit()
flash("Storage limit cleared (unlimited).", "success")
return redirect(url_for("admin.company_detail", company_id=company_id))
company.storage_max_bytes = int(mb * 1024 * 1024)
db.session.commit()
flash("Storage limit updated.", "success")
return redirect(url_for("admin.company_detail", company_id=company_id))
@bp.post("/companies/<int:company_id>/users") @bp.post("/companies/<int:company_id>/users")
@@ -126,8 +375,12 @@ def delete_company(company_id: int):
for d in list(company.displays): for d in list(company.displays):
d.assigned_playlist_id = None d.assigned_playlist_id = None
# 2) Delete display sessions referencing displays of this company # 1b) Clear multi-playlist mappings
display_ids = [d.id for d in company.displays] display_ids = [d.id for d in company.displays]
if display_ids:
DisplayPlaylist.query.filter(DisplayPlaylist.display_id.in_(display_ids)).delete(synchronize_session=False)
# 2) Delete display sessions referencing displays of this company
if display_ids: if display_ids:
DisplaySession.query.filter(DisplaySession.display_id.in_(display_ids)).delete(synchronize_session=False) DisplaySession.query.filter(DisplaySession.display_id.in_(display_ids)).delete(synchronize_session=False)
@@ -142,6 +395,10 @@ def delete_company(company_id: int):
if it.item_type in ("image", "video"): if it.item_type in ("image", "video"):
_try_delete_upload(it.file_path, upload_folder) _try_delete_upload(it.file_path, upload_folder)
# 3b) Clean up uploaded overlay (if any)
if company.overlay_file_path:
_try_delete_upload(company.overlay_file_path, upload_folder)
# 4) Delete the company; cascades will delete users/displays/playlists/items. # 4) Delete the company; cascades will delete users/displays/playlists/items.
company_name = company.name company_name = company.name
db.session.delete(company) db.session.delete(company)
@@ -193,6 +450,43 @@ def update_user_email(user_id: int):
return redirect(url_for("admin.company_detail", company_id=u.company_id)) return redirect(url_for("admin.company_detail", company_id=u.company_id))
@bp.post("/users/<int:user_id>/delete")
@login_required
def delete_user(user_id: int):
"""Admin: delete a non-admin user."""
admin_required()
u = db.session.get(User, user_id)
if not u:
abort(404)
# Safety checks
if u.is_admin:
flash("Cannot delete an admin user", "danger")
return redirect(url_for("admin.dashboard"))
if u.id == current_user.id:
flash("You cannot delete yourself", "danger")
return redirect(url_for("admin.dashboard"))
company_id = u.company_id
company_name = u.company.name if u.company else None
email = u.email
db.session.delete(u)
db.session.commit()
flash(
f"User '{email}' deleted" + (f" from '{company_name}'." if company_name else "."),
"success",
)
if company_id:
return redirect(url_for("admin.company_detail", company_id=company_id))
return redirect(url_for("admin.dashboard"))
@bp.post("/displays/<int:display_id>/name") @bp.post("/displays/<int:display_id>/name")
@login_required @login_required
def update_display_name(display_id: int): def update_display_name(display_id: int):
@@ -212,3 +506,35 @@ def update_display_name(display_id: int):
db.session.commit() db.session.commit()
flash("Display name updated", "success") flash("Display name updated", "success")
return redirect(url_for("admin.company_detail", company_id=display.company_id)) return redirect(url_for("admin.company_detail", company_id=display.company_id))
@bp.post("/displays/<int:display_id>/delete")
@login_required
def delete_display(display_id: int):
"""Admin: delete a display."""
admin_required()
display = db.session.get(Display, display_id)
if not display:
abort(404)
company_id = display.company_id
display_name = display.name
# If FK constraints are enabled, delete in a safe order.
# 1) Unassign playlist
display.assigned_playlist_id = None
# 2) Clear multi-playlist mappings
DisplayPlaylist.query.filter_by(display_id=display.id).delete(synchronize_session=False)
# 3) Delete active sessions for this display
DisplaySession.query.filter_by(display_id=display.id).delete(synchronize_session=False)
# 4) Delete display
db.session.delete(display)
db.session.commit()
flash(f"Display '{display_name}' deleted.", "success")
return redirect(url_for("admin.company_detail", company_id=company_id))

View File

@@ -1,16 +1,313 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import hashlib
import json
import os
import time
import re
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from xml.etree import ElementTree as ET
from flask import Blueprint, abort, jsonify, request, url_for from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
from ..extensions import db from ..extensions import db
from ..models import Display, DisplaySession from ..models import Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem
from ..uploads import is_valid_upload_relpath
bp = Blueprint("api", __name__, url_prefix="/api") bp = Blueprint("api", __name__, url_prefix="/api")
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 2 MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
SESSION_TTL_SECONDS = 90 SESSION_TTL_SECONDS = 90
# RSS ticker cache (in-memory; OK for this small app; avoids hammering feeds)
#
# Default is intentionally long because displays can refresh headlines on a long interval
# (e.g., twice per day) and we don't want many displays to re-fetch the same feed.
# Override via env var `TICKER_CACHE_TTL_SECONDS`.
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, "") or default)
except (TypeError, ValueError):
return default
TICKER_CACHE_TTL_SECONDS = max(10, _env_int("TICKER_CACHE_TTL_SECONDS", 6 * 60 * 60))
_TICKER_CACHE: dict[str, dict] = {}
def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
"""Return True if playlist is active based on its optional schedule window."""
if p.schedule_start and now_utc < p.schedule_start:
return False
if p.schedule_end and now_utc > p.schedule_end:
return False
return True
def _enforce_and_touch_display_session(display: Display, sid: str | None):
"""Enforce concurrent display viewer limit and touch last_seen.
Returns:
(ok, response)
- ok=True: caller may proceed
- ok=False: response is a Flask response tuple to return
"""
sid = (sid or "").strip()
if not sid:
return True, None
cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS)
# Cleanup old sessions. Avoid committing if nothing was deleted (saves write locks on SQLite).
deleted = (
DisplaySession.query.filter(
DisplaySession.display_id == display.id,
DisplaySession.last_seen_at < cutoff,
).delete(synchronize_session=False)
)
if deleted:
db.session.commit()
existing = DisplaySession.query.filter_by(display_id=display.id, sid=sid).first()
if existing:
existing.last_seen_at = datetime.utcnow()
db.session.commit()
return True, None
active_count = (
DisplaySession.query.filter(
DisplaySession.display_id == display.id,
DisplaySession.last_seen_at >= cutoff,
).count()
)
if active_count >= MAX_ACTIVE_SESSIONS_PER_DISPLAY:
return (
False,
(
jsonify(
{
"error": "display_limit_reached",
"message": f"This display URL is already open on {MAX_ACTIVE_SESSIONS_PER_DISPLAY} displays.",
}
),
429,
),
)
s = DisplaySession(
display_id=display.id,
sid=sid,
last_seen_at=datetime.utcnow(),
ip=request.headers.get("X-Forwarded-For", request.remote_addr),
user_agent=(request.headers.get("User-Agent") or "")[:300],
)
db.session.add(s)
db.session.commit()
return True, None
def _playlist_signature(display: Display) -> tuple[int | None, str]:
"""Compute a stable hash for what the player should be showing.
We include enough information so that changing the assigned playlist, reordering,
duration changes, and item adds/deletes trigger an update.
"""
# Determine active playlists. If display_playlist has any rows, use those.
# Otherwise fall back to the legacy assigned_playlist_id.
mapped_ids = [
r[0]
for r in db.session.query(DisplayPlaylist.playlist_id)
.filter(DisplayPlaylist.display_id == display.id)
.order_by(DisplayPlaylist.position.asc(), DisplayPlaylist.playlist_id.asc())
.all()
]
use_mapping = bool(mapped_ids)
active_ids = mapped_ids
if not active_ids and display.assigned_playlist_id:
active_ids = [display.assigned_playlist_id]
use_mapping = False
if not active_ids:
raw = "no-playlist"
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
# Apply scheduling + priority rule so a schedule change triggers a player refresh.
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
now_utc = datetime.utcnow()
scheduled = [p for p in playlists if _is_playlist_active_now(p, now_utc)]
if any(p.is_priority for p in scheduled):
scheduled = [p for p in scheduled if p.is_priority]
active_ids = [x for x in active_ids if any(p.id == x for p in scheduled)]
if not active_ids:
raw = "no-active-playlist"
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
# Pull items in a stable order so reordering affects signature.
if use_mapping:
items = (
PlaylistItem.query.join(DisplayPlaylist, DisplayPlaylist.playlist_id == PlaylistItem.playlist_id)
.filter(
DisplayPlaylist.display_id == display.id,
PlaylistItem.playlist_id.in_(active_ids),
)
.order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc())
.all()
)
else:
items = (
PlaylistItem.query.filter(PlaylistItem.playlist_id == active_ids[0])
.order_by(PlaylistItem.position.asc())
.all()
)
payload = {
"playlist_ids": list(active_ids),
"items": [
{
"id": it.id,
"playlist_id": it.playlist_id,
"pos": it.position,
"type": it.item_type,
"title": it.title,
"duration": it.duration_seconds,
"file_path": it.file_path,
"url": it.url,
}
for it in items
],
}
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
# signature returns a single playlist_id previously; now return None when multiple.
# callers only use it for changed-detection.
if len(set(active_ids)) == 1:
return active_ids[0], hashlib.sha1(raw.encode("utf-8")).hexdigest()
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
def _is_http_url_allowed(url: str) -> bool:
"""Basic SSRF hardening: only allow http(s) and disallow obvious local targets."""
try:
u = urlparse(url)
except Exception:
return False
if u.scheme not in {"http", "https"}:
return False
host = (u.hostname or "").strip().lower()
if not host:
return False
# Block localhost and common local domains.
if host in {"localhost", "127.0.0.1", "::1"}:
return False
# Block RFC1918-ish and link-local targets when host is an IP.
# Note: this is best-effort; proper SSRF protection would require DNS resolution too.
if re.match(r"^\d+\.\d+\.\d+\.\d+$", host):
parts = [int(x) for x in host.split(".")]
if parts[0] == 10:
return False
if parts[0] == 127:
return False
if parts[0] == 169 and parts[1] == 254:
return False
if parts[0] == 192 and parts[1] == 168:
return False
if parts[0] == 172 and 16 <= parts[1] <= 31:
return False
return True
def _strip_text(s: str) -> str:
s = (s or "").strip()
s = re.sub(r"\s+", " ", s)
return s
def _fetch_rss_titles(url: str, *, limit: int = 20) -> list[str]:
"""Fetch RSS/Atom titles from a feed URL.
We intentionally avoid adding dependencies (feedparser) for this project.
This implementation is tolerant enough for typical RSS2/Atom feeds.
"""
req = Request(
url,
headers={
"User-Agent": "SignageTicker/1.0 (+https://example.invalid)",
"Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
},
method="GET",
)
with urlopen(req, timeout=8) as resp:
# Basic size cap (avoid reading huge responses into memory)
raw = resp.read(2_000_000) # 2MB
try:
root = ET.fromstring(raw)
except Exception:
return []
titles: list[str] = []
# RSS2: <rss><channel><item><title>
for el in root.findall(".//item/title"):
t = _strip_text("".join(el.itertext()))
if t:
titles.append(t)
# Atom: <feed><entry><title>
if not titles:
for el in root.findall(".//{*}entry/{*}title"):
t = _strip_text("".join(el.itertext()))
if t:
titles.append(t)
# Some feeds may have <channel><title> etc; we only want entry titles.
# Deduplicate while preserving order.
deduped: list[str] = []
seen = set()
for t in titles:
if t in seen:
continue
seen.add(t)
deduped.append(t)
if len(deduped) >= limit:
break
return deduped
def _get_ticker_titles_cached(url: str) -> tuple[list[str], bool]:
"""Return (titles, from_cache)."""
now = time.time()
key = (url or "").strip()
if not key:
return [], True
entry = _TICKER_CACHE.get(key)
if entry and (now - float(entry.get("ts") or 0)) < TICKER_CACHE_TTL_SECONDS:
return (entry.get("titles") or []), True
titles: list[str] = []
try:
if _is_http_url_allowed(key):
titles = _fetch_rss_titles(key)
except Exception:
titles = []
_TICKER_CACHE[key] = {"ts": now, "titles": titles}
return titles, False
@bp.get("/display/<token>/playlist") @bp.get("/display/<token>/playlist")
def display_playlist(token: str): def display_playlist(token: str):
@@ -18,57 +315,104 @@ def display_playlist(token: str):
if not display: if not display:
abort(404) abort(404)
# Enforce: a display URL/token can be opened by max 2 concurrently active sessions. company = Company.query.filter_by(id=display.company_id).first()
# Optional overlay URL (per-company) when enabled on this display.
overlay_src = None
if display.show_overlay:
if company and company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
overlay_src = url_for("static", filename=company.overlay_file_path)
# Enforce: a display URL/token can be opened by max 3 concurrently active sessions.
# Player sends a stable `sid` via querystring. # Player sends a stable `sid` via querystring.
sid = (request.args.get("sid") or "").strip() sid = request.args.get("sid")
if sid: ok, resp = _enforce_and_touch_display_session(display, sid)
cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS) if not ok:
DisplaySession.query.filter( return resp
DisplaySession.display_id == display.id,
DisplaySession.last_seen_at < cutoff,
).delete(synchronize_session=False)
db.session.commit()
existing = DisplaySession.query.filter_by(display_id=display.id, sid=sid).first() # Ticker settings are configured per-company; displays can enable/disable individually.
if existing: ticker_cfg = {
existing.last_seen_at = datetime.utcnow() "enabled": bool(display.ticker_enabled),
db.session.commit() "rss_url": (company.ticker_rss_url if company else None),
"color": (company.ticker_color if company else None),
"bg_color": (company.ticker_bg_color if company else None),
"bg_opacity": (company.ticker_bg_opacity if company else None),
"font_family": (company.ticker_font_family if company else None),
"font_size_px": (company.ticker_font_size_px if company else None),
"speed": (company.ticker_speed if company else None),
}
# Determine active playlists. If display_playlist has any rows, use those.
# Otherwise fall back to the legacy assigned_playlist_id.
mapped_ids = [
r[0]
for r in db.session.query(DisplayPlaylist.playlist_id)
.filter(DisplayPlaylist.display_id == display.id)
.order_by(DisplayPlaylist.position.asc(), DisplayPlaylist.playlist_id.asc())
.all()
]
use_mapping = bool(mapped_ids)
active_ids = mapped_ids
if not active_ids and display.assigned_playlist_id:
active_ids = [display.assigned_playlist_id]
use_mapping = False
if not active_ids:
return jsonify(
{
"display": display.name,
"transition": display.transition or "none",
"overlay_src": overlay_src,
"ticker": ticker_cfg,
"playlists": [],
"items": [],
}
)
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
# Filter playlists by schedule
now_utc = datetime.utcnow()
scheduled = [p for p in playlists if _is_playlist_active_now(p, now_utc)]
# Priority rule:
# If any active (scheduled) playlist is marked priority, only play priority playlists.
any_priority = any(p.is_priority for p in scheduled)
if any_priority:
scheduled = [p for p in scheduled if p.is_priority]
pl_by_id = {p.id: p for p in scheduled}
scheduled_ids = [x for x in active_ids if x in pl_by_id]
ordered_playlists = [pl_by_id[x] for x in scheduled_ids]
# Merge items across active playlists.
if use_mapping:
merged = (
PlaylistItem.query.join(DisplayPlaylist, DisplayPlaylist.playlist_id == PlaylistItem.playlist_id)
.filter(
DisplayPlaylist.display_id == display.id,
PlaylistItem.playlist_id.in_(scheduled_ids),
)
.order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc())
.all()
)
else:
# single-playlist fallback; apply schedule filter too.
if scheduled_ids:
merged = (
PlaylistItem.query.filter(PlaylistItem.playlist_id == scheduled_ids[0])
.order_by(PlaylistItem.position.asc())
.all()
)
else: else:
active_count = ( merged = []
DisplaySession.query.filter(
DisplaySession.display_id == display.id,
DisplaySession.last_seen_at >= cutoff,
).count()
)
if active_count >= MAX_ACTIVE_SESSIONS_PER_DISPLAY:
return (
jsonify(
{
"error": "display_limit_reached",
"message": f"This display URL is already open on {MAX_ACTIVE_SESSIONS_PER_DISPLAY} displays.",
}
),
429,
)
s = DisplaySession(
display_id=display.id,
sid=sid,
last_seen_at=datetime.utcnow(),
ip=request.headers.get("X-Forwarded-For", request.remote_addr),
user_agent=(request.headers.get("User-Agent") or "")[:300],
)
db.session.add(s)
db.session.commit()
playlist = display.assigned_playlist
if not playlist:
return jsonify({"display": display.name, "playlist": None, "items": []})
items = [] items = []
for item in playlist.items: for item in merged:
payload = { payload = {
"id": item.id, "id": item.id,
"playlist_id": item.playlist_id,
"playlist_name": (pl_by_id.get(item.playlist_id).name if pl_by_id.get(item.playlist_id) else None),
"type": item.item_type, "type": item.item_type,
"title": item.title, "title": item.title,
"duration": item.duration_seconds, "duration": item.duration_seconds,
@@ -82,7 +426,123 @@ def display_playlist(token: str):
return jsonify( return jsonify(
{ {
"display": display.name, "display": display.name,
"playlist": {"id": playlist.id, "name": playlist.name}, "transition": display.transition or "none",
"overlay_src": overlay_src,
"ticker": ticker_cfg,
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
"items": items, "items": items,
} }
) )
@bp.get("/display/<token>/ticker")
def display_ticker(token: str):
"""Return ticker headlines for a display.
We keep it separate from /playlist so the player can refresh headlines on its own interval.
"""
display = Display.query.filter_by(token=token).first()
if not display:
abort(404)
company = Company.query.filter_by(id=display.company_id).first()
# Enforce concurrent session limit the same way as /playlist.
sid = request.args.get("sid")
ok, resp = _enforce_and_touch_display_session(display, sid)
if not ok:
return resp
if not display.ticker_enabled:
return jsonify({"enabled": False, "headlines": []})
rss_url = ((company.ticker_rss_url if company else None) or "").strip()
if not rss_url:
return jsonify({"enabled": True, "headlines": []})
titles, from_cache = _get_ticker_titles_cached(rss_url)
return jsonify(
{
"enabled": True,
"rss_url": rss_url,
"headlines": titles,
"cached": bool(from_cache),
}
)
@bp.get("/display/<token>/events")
def display_events(token: str):
"""Server-Sent Events stream to notify the player when its playlist changes."""
display = Display.query.filter_by(token=token).first()
if not display:
abort(404)
sid = request.args.get("sid")
ok, resp = _enforce_and_touch_display_session(display, sid)
if not ok:
return resp
display_id = display.id
sid = (sid or "").strip() or None
@stream_with_context
def _gen():
last_hash = None
last_touch = 0.0
keepalive_counter = 0
while True:
try:
# Refresh from DB each loop so changes become visible.
d = Display.query.filter_by(id=display_id).first()
if not d:
yield "event: closed\ndata: {}\n\n"
return
playlist_id, h = _playlist_signature(d)
if h != last_hash:
last_hash = h
payload = json.dumps({"playlist_id": playlist_id, "hash": h})
yield f"event: changed\ndata: {payload}\n\n"
# Touch session periodically so SSE-only viewers don't time out.
now = time.time()
if sid and (now - last_touch) >= 30:
last_touch = now
existing = DisplaySession.query.filter_by(display_id=display_id, sid=sid).first()
if existing:
existing.last_seen_at = datetime.utcnow()
db.session.commit()
# Keep-alive comment (prevents some proxies from closing idle streams).
keepalive_counter += 1
if keepalive_counter >= 10: # ~20s with the sleep below
keepalive_counter = 0
yield ": keep-alive\n\n"
# Release DB connections between iterations.
db.session.remove()
time.sleep(2)
except GeneratorExit:
return
except Exception:
# Avoid tight error loops.
try:
db.session.remove()
except Exception:
pass
time.sleep(2)
return Response(
_gen(),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)

View File

@@ -7,30 +7,27 @@ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from ..extensions import db from ..extensions import db
from ..email_utils import send_email from ..email_utils import send_email
from ..models import User from ..models import AppSettings, User
from ..auth_tokens import load_password_reset_user_id, make_password_reset_token
bp = Blueprint("auth", __name__, url_prefix="/auth") bp = Blueprint("auth", __name__, url_prefix="/auth")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _reset_serializer_v2() -> URLSafeTimedSerializer: def _make_reset_token(user: User) -> str:
# Use Flask SECRET_KEY; fallback to app config via current_app.
# (defined as separate function to keep import cycle minimal)
from flask import current_app from flask import current_app
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="password-reset") return make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], user_id=user.id)
def _make_reset_token(user: User) -> str:
s = _reset_serializer_v2()
return s.dumps({"user_id": user.id})
def _load_reset_token(token: str, *, max_age_seconds: int) -> int: def _load_reset_token(token: str, *, max_age_seconds: int) -> int:
s = _reset_serializer_v2() from flask import current_app
data = s.loads(token, max_age=max_age_seconds)
user_id = int(data.get("user_id")) return load_password_reset_user_id(
return user_id secret_key=current_app.config["SECRET_KEY"],
token=token,
max_age_seconds=max_age_seconds,
)
@bp.get("/forgot-password") @bp.get("/forgot-password")
@@ -51,7 +48,18 @@ def forgot_password_post():
user = User.query.filter_by(email=email).first() user = User.query.filter_by(email=email).first()
if user: if user:
token = _make_reset_token(user) token = _make_reset_token(user)
reset_url = url_for("auth.reset_password", token=token, _external=True)
# By default Flask uses the request host when building _external URLs.
# For deployments behind proxies or where the public host differs, allow
# admins to configure a public domain used in email links.
settings = db.session.get(AppSettings, 1)
if settings and settings.public_domain:
# Flask's url_for doesn't support overriding the host per-call.
# We generate the relative path and prefix it with the configured domain.
path = url_for("auth.reset_password", token=token, _external=False)
reset_url = f"https://{settings.public_domain}{path}"
else:
reset_url = url_for("auth.reset_password", token=token, _external=True)
body = ( body = (
"Someone requested a password reset for your account.\n\n" "Someone requested a password reset for your account.\n\n"
f"Reset your password using this link (valid for 30 minutes):\n{reset_url}\n\n" f"Reset your password using this link (valid for 30 minutes):\n{reset_url}\n\n"

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, abort, render_template from flask import Blueprint, abort, render_template, url_for
from ..models import Display from ..models import Company, Display
from ..uploads import is_valid_upload_relpath
bp = Blueprint("display", __name__, url_prefix="/display") bp = Blueprint("display", __name__, url_prefix="/display")
@@ -10,4 +11,11 @@ def display_player(token: str):
display = Display.query.filter_by(token=token).first() display = Display.query.filter_by(token=token).first()
if not display: if not display:
abort(404) abort(404)
return render_template("display/player.html", display=display)
overlay_url = None
if display.show_overlay:
company = Company.query.filter_by(id=display.company_id).first()
if company and company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
overlay_url = url_for("static", filename=company.overlay_file_path)
return render_template("display/player.html", display=display, overlay_url=overlay_url)

BIN
app/static/favicon.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

139
app/static/logo.svg Normal file
View File

@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="150.74998mm"
height="46.903393mm"
viewBox="0 0 150.74998 46.903393"
version="1.1"
id="svg1"
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
sodipodi:docname="opencast logo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.78840488"
inkscape:cx="397.00414"
inkscape:cy="561.25984"
inkscape:window-width="1920"
inkscape:window-height="1129"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="g35">
<inkscape:page
x="-3.5699601e-22"
y="0"
width="150.74998"
height="46.903393"
id="page2"
margin="0"
bleed="0" />
</sodipodi:namedview>
<defs
id="defs1">
<rect
x="319.63272"
y="230.84586"
width="465.49686"
height="96.397171"
id="rect4" />
<filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter36"
x="-0.16131238"
y="-0.23838385"
width="1.358472"
height="1.5529181">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood35" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="3.000000"
id="feGaussianBlur35" />
<feOffset
result="offset"
in="blur"
dx="1.600000"
dy="2.300000"
id="feOffset35" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite35" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite36" />
</filter>
</defs>
<g
inkscape:label="Laag 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-19.647457,-50.186441)">
<g
id="g35"
style="filter:url(#filter36)"
inkscape:export-filename="rssfeed\app\static\logo.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
transform="translate(-1.6779659,-0.67118643)">
<ellipse
style="fill:#ffe511;fill-opacity:1;stroke-width:0.212298"
id="path1"
cx="43.582642"
cy="76.934746"
rx="15.057219"
ry="11.056598"
inkscape:export-filename="rssfeed\app\static\logo.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96" />
<ellipse
style="fill:#ffef6e;fill-opacity:1;stroke-width:0.212298"
id="path2"
cx="51.380131"
cy="68.574883"
rx="13.175065"
ry="10.517252" />
<ellipse
style="fill:#fff5a3;fill-opacity:1;stroke-width:0.212298"
id="path3"
cx="57.833221"
cy="78.148277"
rx="15.326097"
ry="10.112741" />
</g>
<text
xml:space="preserve"
transform="matrix(0.26458333,0,0,0.26458333,-4.5436437,1.7684327)"
id="text3"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:74.6667px;font-family:Georgia;-inkscape-font-specification:'Georgia, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect4);display:inline;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.13386;stroke-dasharray:none;stroke-opacity:1"><tspan
x="319.63281"
y="298.21152"
id="tspan2"><tspan
style="stroke:#000000"
id="tspan1">OpenSlide</tspan></tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -56,6 +56,12 @@ body {
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.brand-logo {
width: 160px;
height: 45px;
display: block;
}
.navbar-brand { .navbar-brand {
font-weight: 700; font-weight: 700;
} }
@@ -274,6 +280,13 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
background: #000; background: #000;
} }
/* Mobile: dashboard display previews are heavy (iframes). Hide them on small screens. */
@media (max-width: 768px) {
.display-gallery-card .display-preview {
display: none;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.display-gallery-grid { .display-gallery-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -284,3 +297,26 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
.toast { .toast {
border-radius: var(--radius); border-radius: var(--radius);
} }
/* Small status dot used on company dashboard next to the schedule icon */
.schedule-status-dot {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 50%;
vertical-align: middle;
}
.schedule-status-dot.active {
background: #198754;
}
.schedule-status-dot.inactive {
background: #dc3545;
}
/* Dropzone disabled state (used by bulk upload) */
.dropzone.disabled {
opacity: 0.6;
pointer-events: none;
}

View File

@@ -45,7 +45,17 @@
<td class="monospace small">{{ d.token }}</td> <td class="monospace small">{{ d.token }}</td>
<td class="text-muted">{{ d.assigned_playlist.name if d.assigned_playlist else "(none)" }}</td> <td class="text-muted">{{ d.assigned_playlist.name if d.assigned_playlist else "(none)" }}</td>
<td class="text-end"> <td class="text-end">
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('display.display_player', token=d.token) }}" target="_blank">Open</a> <div class="d-inline-flex gap-2">
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('display.display_player', token=d.token) }}" target="_blank">Open</a>
<form
method="post"
action="{{ url_for('admin.delete_display', display_id=d.id) }}"
data-confirm="Delete display {{ d.name }}? This cannot be undone."
onsubmit="return confirm(this.dataset.confirm);"
>
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</div>
</td> </td>
</tr> </tr>
{% else %} {% else %}
@@ -59,6 +69,38 @@
</div> </div>
</div> </div>
<div class="mt-4">
<div class="card card-elevated">
<div class="card-header">
<h2 class="h5 mb-0">Storage limit</h2>
</div>
<div class="card-body">
<div class="text-muted small mb-2">
Used: <strong>{{ storage.used_bytes }}</strong> bytes
</div>
<form method="post" action="{{ url_for('admin.update_company_storage', company_id=company.id) }}" class="d-flex gap-2 flex-wrap align-items-end">
<div>
<label class="form-label">Max storage (MB)</label>
<input
class="form-control"
type="number"
name="storage_max_mb"
min="0"
step="1"
value="{{ (company.storage_max_bytes / (1024*1024))|int if company.storage_max_bytes else '' }}"
placeholder="(empty = unlimited)"
/>
<div class="text-muted small">Set to 0 or empty to disable the limit.</div>
</div>
<div>
<button class="btn btn-brand" type="submit">Save</button>
</div>
</form>
</div>
</div>
</div>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="card card-elevated"> <div class="card card-elevated">
@@ -93,9 +135,20 @@
<div> <div>
<strong>{{ u.email or "(no email)" }}</strong> <strong>{{ u.email or "(no email)" }}</strong>
</div> </div>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}"> <div class="d-flex gap-2">
<button class="btn btn-brand btn-sm" type="submit">Impersonate</button> <form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
</form> <button class="btn btn-brand btn-sm" type="submit">Impersonate</button>
</form>
<form
method="post"
action="{{ url_for('admin.delete_user', user_id=u.id) }}"
data-confirm="Delete user {{ u.email or '(no email)' }}? This cannot be undone."
onsubmit="return confirm(this.dataset.confirm);"
>
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</div>
</div> </div>
{% else %} {% else %}
<div class="list-group-item text-muted">No users.</div> <div class="list-group-item text-muted">No users.</div>

View File

@@ -2,6 +2,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h1 class="page-title">Admin</h1> <h1 class="page-title">Admin</h1>
<a class="btn btn-outline-ink" href="{{ url_for('admin.settings') }}">Settings</a>
</div> </div>
<div class="row mt-4"> <div class="row mt-4">

View File

@@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center">
<h1 class="page-title">Admin settings</h1>
<a class="btn btn-outline-ink" href="{{ url_for('admin.dashboard') }}">Back</a>
</div>
<div class="row mt-4 g-3">
<div class="col-lg-6">
<div class="card card-elevated">
<div class="card-header">
<h2 class="h5 mb-0">SMTP settings</h2>
</div>
<div class="card-body">
<form method="post" action="{{ url_for('admin.update_smtp_settings') }}" class="vstack gap-3">
<div>
<label class="form-label">Host</label>
<input class="form-control" name="smtp_host" value="{{ settings.smtp_host or '' }}" placeholder="smtp.example.com" />
</div>
<div class="row g-2">
<div class="col-md-4">
<label class="form-label">Port</label>
<input class="form-control" name="smtp_port" value="{{ settings.smtp_port or '' }}" placeholder="587" />
</div>
<div class="col-md-8">
<label class="form-label">From address (optional)</label>
<input class="form-control" name="smtp_from" value="{{ settings.smtp_from or '' }}" placeholder="no-reply@example.com" />
</div>
</div>
<div>
<label class="form-label">Username</label>
<input class="form-control" name="smtp_username" value="{{ settings.smtp_username or '' }}" placeholder="user@example.com" />
</div>
<div>
<label class="form-label">Password</label>
<input
class="form-control"
name="smtp_password"
type="password"
value=""
placeholder="Leave empty to keep current password"
autocomplete="new-password"
/>
<div class="form-text">
For security, the current password is never shown. Leave empty to keep it unchanged.
</div>
</div>
<div class="row g-2">
<div class="col-md-6">
<label class="form-label">Timeout (seconds)</label>
<input
class="form-control"
name="smtp_timeout_seconds"
value="{{ settings.smtp_timeout_seconds }}"
placeholder="10"
/>
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="smtp_starttls"
value="1"
id="smtp_starttls"
{% if settings.smtp_starttls %}checked{% endif %}
/>
<label class="form-check-label" for="smtp_starttls">Use STARTTLS</label>
</div>
</div>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="smtp_debug"
value="1"
id="smtp_debug"
{% if settings.smtp_debug %}checked{% endif %}
/>
<label class="form-check-label" for="smtp_debug">Enable SMTP debug logging</label>
<div class="form-text">Prints SMTP conversation to the Flask console (useful for troubleshooting).</div>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-brand" type="submit">Save SMTP settings</button>
</div>
</form>
</div>
</div>
<div class="card card-elevated mt-3">
<div class="card-header">
<h2 class="h5 mb-0">Send test email</h2>
</div>
<div class="card-body">
<form method="post" action="{{ url_for('admin.send_test_email') }}" class="vstack gap-2">
<div>
<label class="form-label">Recipient email</label>
<input class="form-control" name="to_email" placeholder="you@example.com" required />
<div class="form-text">Sends a short test email using the current SMTP configuration.</div>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-outline-ink" type="submit">Send test email</button>
</div>
</form>
</div>
</div>
<div class="card card-elevated mt-3">
<div class="card-header">
<h2 class="h5 mb-0">Public domain</h2>
</div>
<div class="card-body">
<form method="post" action="{{ url_for('admin.update_public_domain') }}" class="vstack gap-2">
<div>
<label class="form-label">Domain used in emails</label>
<input
class="form-control"
name="public_domain"
value="{{ settings.public_domain or '' }}"
placeholder="signage.example.com"
/>
<div class="form-text">
Used to generate absolute links (like password reset). Do not include <code>http(s)://</code>.
Leave empty to use the current request host.
</div>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-brand" type="submit">Save domain</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card card-elevated">
<div class="card-header">
<h2 class="h5 mb-0">Admin users</h2>
</div>
<div class="card-body">
<form method="post" action="{{ url_for('admin.create_admin_user') }}" class="vstack gap-2 mb-3">
<div class="row g-2">
<div class="col-md-6">
<input class="form-control" name="email" placeholder="Email" required />
</div>
<div class="col-md-6">
<input class="form-control" name="password" type="password" placeholder="Password (min 8 chars)" required />
</div>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-brand" type="submit">Add admin</button>
</div>
</form>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Email</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
{% for u in admins %}
<tr>
<td>
<strong>{{ u.email }}</strong>
{% if u.id == current_user.id %}
<span class="badge text-bg-secondary ms-2">You</span>
{% endif %}
</td>
<td class="text-end">
{% if u.id != current_user.id %}
<form method="post" action="{{ url_for('admin.demote_admin_user', user_id=u.id) }}" class="d-inline">
<button class="btn btn-outline-danger btn-sm" type="submit">Demote</button>
</form>
{% else %}
<span class="text-muted small">(cannot demote yourself)</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="2" class="text-muted">No admin users found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-text mt-2">
Safety: the last remaining admin cannot be demoted.
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -4,6 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title or "Signage" }}</title> <title>{{ title or "Signage" }}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/png" />
<link rel="apple-touch-icon" href="{{ url_for('static', filename='favicon.png') }}" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
</head> </head>
@@ -11,8 +13,14 @@
<nav class="navbar navbar-expand-lg navbar-light fixed-top app-navbar"> <nav class="navbar navbar-expand-lg navbar-light fixed-top app-navbar">
<div class="container"> <div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" href="/"> <a class="navbar-brand d-flex align-items-center gap-2" href="/">
<span class="brand-mark" aria-hidden="true">S</span> <img
<span>Signage</span> class="brand-logo"
src="{{ url_for('static', filename='logo.svg') }}"
alt="Signage"
width="34"
height="34"
/>
</a> </a>
<button <button
@@ -28,10 +36,23 @@
</button> </button>
<div class="collapse navbar-collapse" id="mainNav"> <div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav me-auto">
{% if current_user.is_authenticated %}
{% if current_user.is_admin %}
{# Dashboard link removed: users can click the logo to go to the dashboard. #}
{% else %}
{# Dashboard link removed: users can click the logo to go to the dashboard. #}
{% endif %}
{% endif %}
</ul>
<div class="d-flex align-items-lg-center flex-column flex-lg-row gap-2 ms-lg-auto"> <div class="d-flex align-items-lg-center flex-column flex-lg-row gap-2 ms-lg-auto">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<div class="small text-muted">{{ current_user.email }}</div> <div class="small text-muted">{{ current_user.email }}</div>
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('auth.change_password') }}">Change password</a> <a class="btn btn-outline-ink btn-sm" href="{{ url_for('auth.change_password') }}">Change password</a>
{% if not current_user.is_admin %}
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('company.my_company') }}">My company</a>
{% endif %}
{% if session.get('impersonator_admin_id') %} {% if session.get('impersonator_admin_id') %}
<a class="btn btn-brand btn-sm" href="{{ url_for('auth.stop_impersonation') }}">Stop impersonation</a> <a class="btn btn-brand btn-sm" href="{{ url_for('auth.stop_impersonation') }}">Stop impersonation</a>
{% endif %} {% endif %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h1 class="page-title">Welcome{% if current_user and current_user.email %}, {{ current_user.email }}{% endif %}!</h1> <h1 class="page-title">Dashboard</h1>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">
@@ -24,14 +24,26 @@
<tbody> <tbody>
{% for p in playlists %} {% for p in playlists %}
<tr> <tr>
<td><strong>{{ p.name }}</strong></td> <td>
<strong>{{ p.name }}</strong>
{# Indicators: schedule + priority #}
{% set has_schedule = (p.schedule_start is not none) or (p.schedule_end is not none) %}
{% if has_schedule %}
{% set is_active = (not p.schedule_start or p.schedule_start <= now_utc) and (not p.schedule_end or now_utc <= p.schedule_end) %}
<span class="ms-2" title="Scheduled playlist" style="font-weight:700;">📅</span>
<span
class="ms-1 schedule-status-dot {{ 'active' if is_active else 'inactive' }}"
title="{{ 'Schedule active' if is_active else 'Schedule inactive' }}"
></span>
{% endif %}
{% if p.is_priority %}
<span class="ms-1" title="Priority playlist" style="color:#dc3545; font-weight:700;"></span>
{% endif %}
</td>
<td class="text-end">{{ p.items|length }}</td> <td class="text-end">{{ p.items|length }}</td>
<td class="text-end"> <td class="text-end">
<div class="d-inline-flex gap-2"> <div class="d-inline-flex gap-2">
<a class="btn btn-ink btn-sm" href="{{ url_for('company.playlist_detail', playlist_id=p.id) }}">Open</a> <a class="btn btn-ink btn-sm" href="{{ url_for('company.playlist_detail', playlist_id=p.id) }}">Open</a>
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=p.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</div> </div>
</td> </td>
</tr> </tr>
@@ -57,12 +69,20 @@
<div class="col-12 col-md-6 col-xl-4"> <div class="col-12 col-md-6 col-xl-4">
<div class="card display-gallery-card h-100"> <div class="card display-gallery-card h-100">
<div class="display-preview"> <div class="display-preview">
<iframe <div
title="Preview — {{ d.name }}" class="display-preview-scale"
src="{{ url_for('display.display_player', token=d.token) }}?preview=1" style="width: 1000%; height: 1000%; transform: scale(0.1); transform-origin: top left;"
loading="lazy" >
referrerpolicy="no-referrer" <iframe
></iframe> title="Preview — {{ d.name }}"
data-display-id="{{ d.id }}"
class="js-display-preview"
data-preview-src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
loading="lazy"
referrerpolicy="no-referrer"
style="width: 100%; height: 100%; border: 0;"
></iframe>
</div>
</div> </div>
<div class="card-body d-flex flex-column gap-2"> <div class="card-body d-flex flex-column gap-2">
@@ -74,27 +94,25 @@
</div> </div>
<div class="d-flex flex-column gap-2 mt-auto"> <div class="d-flex flex-column gap-2 mt-auto">
<select {# Multi-playlist selector: button opens modal with playlist checkboxes #}
class="form-select form-select-sm js-playlist-select" <div class="d-flex gap-2 align-items-center">
data-display-id="{{ d.id }}"
aria-label="Playlist selection"
>
<option value="">(none)</option>
{% for p in playlists %}
<option value="{{ p.id }}" {% if d.assigned_playlist_id == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
<div class="d-flex justify-content-end">
<button <button
type="button" type="button"
class="btn btn-ink btn-sm js-edit-desc" class="btn btn-ink btn-sm js-edit-playlists"
data-display-id="{{ d.id }}" data-display-id="{{ d.id }}"
data-display-name="{{ d.name }}" data-display-name="{{ d.name }}"
data-current-desc="{{ d.description or '' }}" data-current-desc="{{ d.description or '' }}"
data-current-transition="{{ d.transition or 'none' }}"
data-current-show-overlay="{{ '1' if d.show_overlay else '0' }}"
data-current-ticker-enabled="{{ '1' if d.ticker_enabled else '0' }}"
data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
> >
Edit description Configure display
</button> </button>
<div class="small text-muted">
<span class="js-active-playlists-summary" data-display-id="{{ d.id }}"></span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -118,31 +136,80 @@
</div> </div>
</div> </div>
<!-- Edit description modal --> <!-- Edit playlists modal -->
<div class="modal fade" id="editDescModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="editPlaylistsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="editDescModalTitle">Edit description</h5> <h5 class="modal-title" id="editPlaylistsModalTitle">Configure display</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<label class="form-label" for="editDescInput">Description</label> <div class="mb-3">
<textarea class="form-control" id="editDescInput" maxlength="200" rows="3" placeholder="Optional description (max 200 chars)"></textarea> <label class="form-label" for="editPlaylistsDescInput">Description</label>
<div class="form-text"><span id="editDescCount">0</span>/200</div> <textarea
class="form-control"
id="editPlaylistsDescInput"
maxlength="200"
rows="3"
placeholder="Optional description (max 200 chars)"
></textarea>
<div class="form-text"><span id="editPlaylistsDescCount">0</span>/200</div>
</div>
<div class="mb-3">
<label class="form-label" for="editPlaylistsTransitionSelect">Slide transition</label>
<select class="form-select" id="editPlaylistsTransitionSelect">
<option value="none">None</option>
<option value="fade">Fade</option>
<option value="slide">Slide</option>
</select>
<div class="form-text">Applied on the display when switching between playlist items.</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="editPlaylistsShowOverlayCheck" />
<label class="form-check-label" for="editPlaylistsShowOverlayCheck">Show company overlay</label>
<div class="form-text">If your company has an overlay uploaded, it will be displayed on top of the content.</div>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="editTickerEnabled" />
<label class="form-check-label" for="editTickerEnabled">
Enable ticker tape on this display
</label>
<div class="form-text">
RSS feed + styling is configured in <a href="{{ url_for('company.my_company') }}">My Company</a>.
</div>
</div>
<hr class="my-3" />
<div class="text-muted small mb-2">Tick the playlists that should be active on this display.</div>
<div id="editPlaylistsList" class="d-flex flex-column gap-2"></div>
<div class="form-text mt-2" id="editPlaylistsHint"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-brand" id="editDescSaveBtn">Save</button> <button type="button" class="btn btn-brand" id="editPlaylistsSaveBtn">Save</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{# Embed playlists list as JSON to avoid templating inside JS (keeps JS linters happy). #}
<script type="application/json" id="allPlaylistsJson">{{ playlists_json|tojson }}</script>
{% endblock %} {% endblock %}
{% block page_scripts %} {% block page_scripts %}
<script> <script>
(function () { (function () {
let ALL_PLAYLISTS = [];
try {
const el = document.getElementById('allPlaylistsJson');
ALL_PLAYLISTS = el ? JSON.parse(el.textContent || '[]') : [];
} catch (e) {
ALL_PLAYLISTS = [];
}
const toastEl = document.getElementById('companyToast'); const toastEl = document.getElementById('companyToast');
const toastBodyEl = document.getElementById('companyToastBody'); const toastBodyEl = document.getElementById('companyToastBody');
const toast = toastEl ? new bootstrap.Toast(toastEl, { delay: 2200 }) : null; const toast = toastEl ? new bootstrap.Toast(toastEl, { delay: 2200 }) : null;
@@ -173,82 +240,265 @@
return data.display; return data.display;
} }
// Playlist auto-save function refreshPreviewIframe(displayId) {
document.querySelectorAll('.js-playlist-select').forEach((sel) => { const iframe = document.querySelector(`iframe[data-display-id="${displayId}"]`);
sel.addEventListener('change', async () => { // Previews are disabled on mobile.
const displayId = sel.dataset.displayId; if (window.matchMedia && window.matchMedia('(max-width: 768px)').matches) return;
const playlistId = sel.value || null; if (!iframe) return;
sel.disabled = true;
try {
await postDisplayUpdate(displayId, { playlist_id: playlistId });
showToast('Playlist saved', 'text-bg-success');
} catch (e) {
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
} finally {
sel.disabled = false;
}
});
});
// Description modal
const modalEl = document.getElementById('editDescModal');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const titleEl = document.getElementById('editDescModalTitle');
const inputEl = document.getElementById('editDescInput');
const countEl = document.getElementById('editDescCount');
const saveBtn = document.getElementById('editDescSaveBtn');
let activeDisplayId = null;
function updateCount() {
if (!inputEl || !countEl) return;
countEl.textContent = String((inputEl.value || '').length);
}
if (inputEl) inputEl.addEventListener('input', updateCount);
document.querySelectorAll('.js-edit-desc').forEach((btn) => {
btn.addEventListener('click', () => {
activeDisplayId = btn.dataset.displayId;
const displayName = btn.dataset.displayName || 'Display';
const currentDesc = btn.dataset.currentDesc || '';
if (titleEl) titleEl.textContent = `Edit description — ${displayName}`;
if (inputEl) inputEl.value = currentDesc;
updateCount();
if (modal) modal.show();
});
});
async function saveDescription() {
if (!activeDisplayId || !inputEl) return;
const desc = (inputEl.value || '').trim();
saveBtn.disabled = true;
try { try {
const updated = await postDisplayUpdate(activeDisplayId, { description: desc }); const baseSrc = iframe.dataset.previewSrc || iframe.src;
// Update visible description if (!baseSrc) return;
const descEl = document.querySelector(`.js-display-desc[data-display-id="${activeDisplayId}"]`); const u = new URL(baseSrc, window.location.origin);
if (descEl) descEl.textContent = updated.description ? updated.description : '—'; // Ensure preview flag is present (and bust cache).
// Update button's stored value u.searchParams.set('preview', '1');
const btn = document.querySelector(`.js-edit-desc[data-display-id="${activeDisplayId}"]`); u.searchParams.set('_ts', String(Date.now()));
if (btn) btn.dataset.currentDesc = updated.description || ''; iframe.src = u.toString();
showToast('Description saved', 'text-bg-success'); } catch (e) {
if (modal) modal.hide(); // Fallback: naive cache buster
const baseSrc = iframe.dataset.previewSrc || iframe.src;
if (!baseSrc) return;
const sep = baseSrc.includes('?') ? '&' : '?';
iframe.src = `${baseSrc}${sep}_ts=${Date.now()}`;
}
}
function parseIds(csv) {
const s = (csv || '').trim();
if (!s) return [];
return s.split(',').map(x => parseInt(x, 10)).filter(n => Number.isFinite(n));
}
function computeActiveIdsFromDataset(btn) {
// If display_playlist table has rows, we use that.
// Otherwise fall back to legacy single playlist assignment.
const active = parseIds(btn.dataset.activePlaylistIds);
if (active.length) return active;
const legacy = parseInt(btn.dataset.legacyPlaylistId || '', 10);
return Number.isFinite(legacy) ? [legacy] : [];
}
function setActiveIdsOnButton(btn, ids) {
btn.dataset.activePlaylistIds = (ids || []).join(',');
}
function playlistNameById(id) {
const p = (ALL_PLAYLISTS || []).find(x => x.id === id);
return p ? p.name : null;
}
function refreshActivePlaylistSummary(displayId, ids) {
const el = document.querySelector(`.js-active-playlists-summary[data-display-id="${displayId}"]`);
if (!el) return;
if (!ids || ids.length === 0) {
el.textContent = '(none)';
return;
}
const names = ids.map(playlistNameById).filter(Boolean);
el.textContent = names.length ? names.join(', ') : `${ids.length} selected`;
}
// Initialize summary labels on page load.
document.querySelectorAll('.js-edit-playlists').forEach((btn) => {
const displayId = btn.dataset.displayId;
const ids = computeActiveIdsFromDataset(btn);
refreshActivePlaylistSummary(displayId, ids);
});
async function postDisplayPlaylists(displayId, playlistIds) {
const res = await fetch(`/company/displays/${displayId}/playlists`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ playlist_ids: playlistIds })
});
const data = await res.json().catch(() => null);
if (!res.ok || !data || !data.ok) {
const msg = (data && data.error) ? data.error : 'Save failed';
throw new Error(msg);
}
return data.display;
}
// Playlists modal
const plModalEl = document.getElementById('editPlaylistsModal');
const plModal = plModalEl ? new bootstrap.Modal(plModalEl) : null;
const plTitleEl = document.getElementById('editPlaylistsModalTitle');
const plListEl = document.getElementById('editPlaylistsList');
const plHintEl = document.getElementById('editPlaylistsHint');
const plSaveBtn = document.getElementById('editPlaylistsSaveBtn');
const plDescInputEl = document.getElementById('editPlaylistsDescInput');
const plDescCountEl = document.getElementById('editPlaylistsDescCount');
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
const plShowOverlayEl = document.getElementById('editPlaylistsShowOverlayCheck');
const tickerEnabledEl = document.getElementById('editTickerEnabled');
let activePlDisplayId = null;
let activePlButton = null;
function updatePlDescCount() {
if (!plDescInputEl || !plDescCountEl) return;
plDescCountEl.textContent = String((plDescInputEl.value || '').length);
}
if (plDescInputEl) plDescInputEl.addEventListener('input', updatePlDescCount);
function renderPlaylistCheckboxes(selectedIds) {
if (!plListEl) return;
plListEl.innerHTML = '';
const selectedSet = new Set(selectedIds || []);
const pls = (ALL_PLAYLISTS || []).slice().sort((a,b) => (a.name || '').localeCompare(b.name || ''));
if (pls.length === 0) {
plListEl.innerHTML = '<div class="text-muted">No playlists available.</div>';
return;
}
pls.forEach((p) => {
const id = `pl_cb_${p.id}`;
const row = document.createElement('div');
row.className = 'form-check';
const input = document.createElement('input');
input.className = 'form-check-input';
input.type = 'checkbox';
input.id = id;
input.value = String(p.id);
input.checked = selectedSet.has(p.id);
const label = document.createElement('label');
label.className = 'form-check-label';
label.setAttribute('for', id);
label.textContent = p.name;
row.appendChild(input);
row.appendChild(label);
plListEl.appendChild(row);
});
}
function getSelectedPlaylistIdsFromModal() {
if (!plListEl) return [];
return Array.from(plListEl.querySelectorAll('input[type="checkbox"]'))
.filter(cb => cb.checked)
.map(cb => parseInt(cb.value, 10))
.filter(n => Number.isFinite(n));
}
document.querySelectorAll('.js-edit-playlists').forEach((btn) => {
btn.addEventListener('click', () => {
activePlDisplayId = btn.dataset.displayId;
activePlButton = btn;
const displayName = btn.dataset.displayName || 'Display';
if (plTitleEl) plTitleEl.textContent = `Configure display — ${displayName}`;
const currentDesc = btn.dataset.currentDesc || '';
if (plDescInputEl) plDescInputEl.value = currentDesc;
updatePlDescCount();
const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase();
if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none';
if (plShowOverlayEl) {
const raw = (btn.dataset.currentShowOverlay || '').toLowerCase();
plShowOverlayEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
if (tickerEnabledEl) {
const raw = (btn.dataset.currentTickerEnabled || '').toLowerCase();
tickerEnabledEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
const selected = computeActiveIdsFromDataset(btn);
renderPlaylistCheckboxes(selected);
if (plHintEl) {
plHintEl.textContent = selected.length ? `${selected.length} currently selected.` : 'No playlists currently selected.';
}
if (plModal) plModal.show();
});
});
async function savePlaylists() {
if (!activePlDisplayId || !activePlButton || !plSaveBtn) return;
const ids = getSelectedPlaylistIdsFromModal();
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
const showOverlay = plShowOverlayEl ? !!plShowOverlayEl.checked : false;
const tickerEnabled = tickerEnabledEl ? !!tickerEnabledEl.checked : false;
plSaveBtn.disabled = true;
try {
const [updatedPlaylists, updatedDesc] = await Promise.all([
postDisplayPlaylists(activePlDisplayId, ids),
postDisplayUpdate(activePlDisplayId, {
description: desc,
transition,
show_overlay: showOverlay,
ticker_enabled: tickerEnabled
})
]);
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
? updatedPlaylists.active_playlist_ids
: ids;
setActiveIdsOnButton(activePlButton, newIds);
refreshActivePlaylistSummary(activePlDisplayId, newIds);
const descEl = document.querySelector(`.js-display-desc[data-display-id="${activePlDisplayId}"]`);
const newDesc = updatedDesc && typeof updatedDesc.description === 'string' ? updatedDesc.description : desc;
if (descEl) descEl.textContent = newDesc ? newDesc : '—';
activePlButton.dataset.currentDesc = newDesc || '';
// Keep button dataset in sync so reopening modal shows correct value.
const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition;
activePlButton.dataset.currentTransition = newTransition || 'none';
const newShowOverlay = updatedDesc && typeof updatedDesc.show_overlay !== 'undefined'
? !!updatedDesc.show_overlay
: showOverlay;
activePlButton.dataset.currentShowOverlay = newShowOverlay ? '1' : '0';
const newTickerEnabled = updatedDesc && typeof updatedDesc.ticker_enabled !== 'undefined'
? !!updatedDesc.ticker_enabled
: tickerEnabled;
activePlButton.dataset.currentTickerEnabled = newTickerEnabled ? '1' : '0';
showToast('Display updated', 'text-bg-success');
refreshPreviewIframe(activePlDisplayId);
if (plModal) plModal.hide();
} catch (e) { } catch (e) {
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger'); showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
} finally { } finally {
saveBtn.disabled = false; plSaveBtn.disabled = false;
} }
} }
if (saveBtn) {
saveBtn.addEventListener('click', saveDescription); if (plSaveBtn) {
plSaveBtn.addEventListener('click', savePlaylists);
} }
if (modalEl) {
modalEl.addEventListener('keydown', (e) => { // Disable dashboard previews on small screens (mobile): don't even set iframe src.
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { function loadDashboardPreviewsIfDesktop() {
e.preventDefault(); const isMobile = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
saveDescription(); if (isMobile) return;
document.querySelectorAll('iframe.js-display-preview[data-preview-src]').forEach((iframe) => {
if (!iframe.src || iframe.src === 'about:blank') {
iframe.src = iframe.dataset.previewSrc;
} }
}); });
} }
loadDashboardPreviewsIfDesktop();
// If user rotates/resizes from mobile -> desktop, load previews then.
if (window.matchMedia) {
const mql = window.matchMedia('(max-width: 768px)');
const onChange = () => {
if (!mql.matches) loadDashboardPreviewsIfDesktop();
};
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', onChange);
} else if (typeof mql.addListener === 'function') {
mql.addListener(onChange);
}
}
})(); })();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,314 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
<div>
<h1 class="page-title">My Company</h1>
<div class="text-muted">{{ company.name }}</div>
</div>
<div>
<a class="btn btn-outline-ink" href="{{ url_for('company.dashboard') }}">Back</a>
</div>
</div>
<div class="row mt-4 g-3">
<div class="col-12 col-lg-6">
<div class="card card-elevated h-100">
<div class="card-header">
<h2 class="h5 mb-0">Company stats</h2>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<div class="text-muted small">Users</div>
<div class="fs-4 fw-bold">{{ stats['users'] }}</div>
</div>
<div class="col-6">
<div class="text-muted small">Displays</div>
<div class="fs-4 fw-bold">{{ stats['displays'] }}</div>
</div>
<div class="col-6">
<div class="text-muted small">Playlists</div>
<div class="fs-4 fw-bold">{{ stats['playlists'] }}</div>
</div>
<div class="col-6">
<div class="text-muted small">Playlist items</div>
<div class="fs-4 fw-bold">{{ stats['items'] }}</div>
</div>
<div class="col-6">
<div class="text-muted small">Active display sessions</div>
<div class="fs-4 fw-bold">{{ stats['active_sessions'] }}</div>
<div class="text-muted small">(last ~90 seconds)</div>
</div>
<div class="col-6">
<div class="text-muted small">Storage used</div>
<div class="fs-4 fw-bold">{{ stats['storage_human'] }}</div>
<div class="text-muted small">({{ stats['storage_bytes'] }} bytes)</div>
{% if stats.get('storage_max_human') %}
<div class="text-muted small mt-1">
Limit: <strong>{{ stats['storage_max_human'] }}</strong>
{% if stats.get('storage_used_percent') is not none %}
— Used: <strong>{{ stats['storage_used_percent'] }}%</strong>
{% endif %}
</div>
{% if stats.get('storage_used_percent') is not none %}
<div class="progress mt-1" style="height: 8px;">
<div
class="progress-bar {% if stats['storage_used_percent'] >= 100 %}bg-danger{% elif stats['storage_used_percent'] >= 90 %}bg-warning{% else %}bg-success{% endif %}"
role="progressbar"
style="width: {{ [stats['storage_used_percent'], 100]|min }}%"
aria-valuenow="{{ stats['storage_used_percent'] }}"
aria-valuemin="0"
aria-valuemax="100"
></div>
</div>
{% endif %}
{% else %}
<div class="text-muted small mt-1">Limit: <strong>Unlimited</strong></div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card card-elevated h-100">
<div class="card-header">
<h2 class="h5 mb-0">Invite user</h2>
</div>
<div class="card-body">
<form method="post" action="{{ url_for('company.invite_user') }}" class="d-flex gap-2 flex-wrap">
<input class="form-control" type="email" name="email" placeholder="Email address" required />
<button class="btn btn-brand" type="submit">Send invite</button>
</form>
<div class="text-muted small mt-2">
The user will receive an email with a password set link (valid for 30 minutes).
</div>
</div>
</div>
</div>
</div>
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Users</h2>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Email</th>
<th class="text-muted">Created</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>
<strong>{{ u.email or "(no email)" }}</strong>
{% if u.id == current_user.id %}
<span class="badge bg-secondary ms-2">you</span>
{% endif %}
</td>
<td class="text-muted">{{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else "—" }}</td>
<td class="text-end">
{% if u.id != current_user.id %}
<form
method="post"
action="{{ url_for('company.delete_company_user', user_id=u.id) }}"
class="d-inline"
data-confirm="Delete user {{ u.email or "(no email)" }}? This cannot be undone."
onsubmit="return confirm(this.dataset.confirm);"
>
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-muted">No users.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Overlay</h2>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Upload a <strong>16:9 PNG</strong> overlay. It will be rendered on top of the display content.
Transparent areas will show the content underneath.
</div>
{% if overlay_url %}
<div class="mb-3">
<div class="text-muted small mb-2">Current overlay:</div>
<div style="max-width: 520px; border: 1px solid rgba(0,0,0,0.15); border-radius: 8px; overflow: hidden;">
<img
src="{{ overlay_url }}"
alt="Company overlay"
style="display:block; width:100%; height:auto; background: repeating-linear-gradient(45deg, #eee 0 12px, #fff 12px 24px);"
/>
</div>
</div>
{% else %}
<div class="text-muted mb-3">No overlay uploaded.</div>
{% endif %}
<form method="post" action="{{ url_for('company.upload_company_overlay') }}" enctype="multipart/form-data" class="d-flex gap-2 flex-wrap align-items-end">
<div>
<label class="form-label">Upload overlay (PNG)</label>
<input class="form-control" type="file" name="overlay" accept="image/png" required />
<div class="form-text">Tip: export at 1920×1080 (or any 16:9 size).</div>
</div>
<div>
<button class="btn btn-brand" type="submit">Save overlay</button>
</div>
</form>
{% if overlay_url %}
<form method="post" action="{{ url_for('company.delete_company_overlay') }}" class="mt-3" onsubmit="return confirm('Remove the overlay?');">
<button class="btn btn-outline-danger" type="submit">Remove overlay</button>
</form>
{% endif %}
</div>
</div>
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Ticker tape (RSS)</h2>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Configure the RSS feed and styling for the ticker tape. Individual displays can enable/disable the ticker from the Dashboard.
</div>
<form method="post" action="{{ url_for('company.update_company_ticker_settings') }}" class="d-flex flex-column gap-3">
<div>
<label class="form-label" for="companyTickerRssUrl">RSS feed URL</label>
<input
class="form-control"
id="companyTickerRssUrl"
name="ticker_rss_url"
type="url"
value="{{ company.ticker_rss_url or '' }}"
placeholder="https://example.com/feed.xml"
/>
<div class="form-text">Leave empty to disable headlines (even if displays have ticker enabled).</div>
</div>
<div class="row g-2">
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerColor">Text color</label>
<input
class="form-control form-control-color"
id="companyTickerColor"
name="ticker_color"
type="color"
value="{{ company.ticker_color or '#ffffff' }}"
title="Choose text color"
/>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerBgColor">Background color</label>
<input
class="form-control form-control-color"
id="companyTickerBgColor"
name="ticker_bg_color"
type="color"
value="{{ company.ticker_bg_color or '#000000' }}"
title="Choose background color"
/>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerBgOpacity">Background opacity</label>
<input
class="form-range"
id="companyTickerBgOpacity"
name="ticker_bg_opacity"
type="range"
min="0"
max="100"
step="1"
value="{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}"
/>
<div class="form-text"><span id="companyTickerBgOpacityLabel">{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}</span>%</div>
</div>
</div>
<div class="row g-2">
<div class="col-12 col-md-6">
<label class="form-label" for="companyTickerFontFamily">Font</label>
<select class="form-select" id="companyTickerFontFamily" name="ticker_font_family">
{% set ff = company.ticker_font_family or 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' %}
<option value="system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" {{ 'selected' if ff == 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' else '' }}>System (default)</option>
<option value="Arial, Helvetica, sans-serif" {{ 'selected' if ff == 'Arial, Helvetica, sans-serif' else '' }}>Arial</option>
<option value="Segoe UI, Arial, sans-serif" {{ 'selected' if ff == 'Segoe UI, Arial, sans-serif' else '' }}>Segoe UI</option>
<option value="Roboto, Arial, sans-serif" {{ 'selected' if ff == 'Roboto, Arial, sans-serif' else '' }}>Roboto</option>
<option value="Georgia, serif" {{ 'selected' if ff == 'Georgia, serif' else '' }}>Georgia</option>
<option value="Times New Roman, Times, serif" {{ 'selected' if ff == 'Times New Roman, Times, serif' else '' }}>Times New Roman</option>
<option value="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace" {{ 'selected' if ff == 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace' else '' }}>Monospace</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label" for="companyTickerFontSize">Font size (px)</label>
<input
class="form-control"
id="companyTickerFontSize"
name="ticker_font_size_px"
type="number"
min="10"
max="200"
step="1"
value="{{ company.ticker_font_size_px if company.ticker_font_size_px is not none else 28 }}"
/>
</div>
<div class="col-12 col-md-3">
<label class="form-label" for="companyTickerSpeed">Speed</label>
<input
class="form-range"
id="companyTickerSpeed"
name="ticker_speed"
type="range"
min="1"
max="100"
step="1"
value="{{ company.ticker_speed if company.ticker_speed is not none else 25 }}"
/>
<div class="form-text">Slower ⟷ Faster</div>
</div>
</div>
<div>
<button class="btn btn-brand" type="submit">Save ticker settings</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script>
(function () {
const opacityEl = document.getElementById('companyTickerBgOpacity');
const opacityLabelEl = document.getElementById('companyTickerBgOpacityLabel');
function syncOpacity() {
if (!opacityEl || !opacityLabelEl) return;
opacityLabelEl.textContent = String(opacityEl.value || '0');
}
if (opacityEl) opacityEl.addEventListener('input', syncOpacity);
syncOpacity();
})();
</script>
{% endblock %}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -7,17 +7,174 @@
<style> <style>
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; } html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; } #stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
/* When ticker is shown, keep content from being visually covered.
(We pad the stage; video/img/iframe inside will keep aspect.) */
body.has-ticker #stage {
bottom: var(--ticker-height, 54px);
}
/* Optional company overlay (transparent PNG) */
#overlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 5;
object-fit: contain;
}
/* Slide transitions (applied by JS via classes) */
#stage .slide {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
#stage .slide.enter {
opacity: 0;
transform: translateX(16px);
}
#stage.transition-none .slide.enter {
opacity: 1;
transform: none;
}
#stage.transition-fade .slide {
transition: opacity 420ms ease;
}
#stage.transition-fade .slide.enter {
opacity: 0;
transform: none;
}
#stage.transition-fade .slide.enter.active {
opacity: 1;
}
#stage.transition-fade .slide.exit {
opacity: 1;
transition: opacity 420ms ease;
}
#stage.transition-fade .slide.exit.active {
opacity: 0;
}
#stage.transition-slide .slide {
transition: transform 420ms ease, opacity 420ms ease;
}
#stage.transition-slide .slide.enter {
opacity: 0;
transform: translateX(48px);
}
#stage.transition-slide .slide.enter.active {
opacity: 1;
transform: translateX(0);
}
#stage.transition-slide .slide.exit {
opacity: 1;
transform: translateX(0);
}
#stage.transition-slide .slide.exit.active {
opacity: 0;
transform: translateX(-48px);
}
#notice {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
color: #fff;
background: rgba(0, 0, 0, 0.86);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
z-index: 10;
text-align: center;
}
#notice .box {
max-width: 720px;
}
#notice .title {
font-size: 28px;
font-weight: 700;
margin: 0 0 10px;
}
#notice .msg {
font-size: 18px;
line-height: 1.4;
margin: 0;
opacity: 0.95;
}
/* Ticker tape */
#ticker {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: var(--ticker-height, 54px);
background: rgba(0, 0, 0, 0.75); /* overridden by JS via style */
display: none;
align-items: center;
overflow: hidden;
z-index: 6; /* above stage, below notice */
pointer-events: none;
}
#ticker .track {
display: inline-flex;
align-items: center;
white-space: nowrap;
will-change: transform;
animation: ticker-scroll linear infinite;
animation-duration: var(--ticker-duration, 60s);
transform: translateX(0);
}
#ticker .item {
padding: 0 26px;
}
#ticker .sep {
opacity: 0.65;
}
@keyframes ticker-scroll {
/* We duplicate the content twice, so shifting -50% effectively loops. */
0% { transform: translateX(0); }
100% { transform: translateX(calc(-1 * var(--ticker-shift, 50%))); }
}
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; } img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
.notice { position: fixed; left: 12px; bottom: 12px; color: #bbb; font: 14px/1.3 sans-serif; } /* removed bottom-left status text */
</style> </style>
</head> </head>
<body> <body>
<div id="notice" role="alert" aria-live="assertive">
<div class="box">
<p class="title" id="noticeTitle">Notice</p>
<p class="msg" id="noticeText"></p>
</div>
</div>
<div id="stage"></div> <div id="stage"></div>
<div class="notice" id="notice"></div> <div id="ticker" aria-hidden="true">
<div class="track" id="tickerTrack"></div>
</div>
{% if overlay_url %}
<img id="overlay" src="{{ overlay_url }}" alt="Overlay" />
{% endif %}
<script> <script>
const token = "{{ display.token }}"; const token = "{{ display.token }}";
const stage = document.getElementById('stage'); const stage = document.getElementById('stage');
const notice = document.getElementById('notice'); let overlayEl = document.getElementById('overlay');
const noticeEl = document.getElementById('notice');
const noticeTitleEl = document.getElementById('noticeTitle');
const noticeTextEl = document.getElementById('noticeText');
function setNotice(text, { title } = {}) {
const t = (text || '').trim();
if (!t) {
noticeEl.style.display = 'none';
noticeTextEl.textContent = '';
return;
}
noticeTitleEl.textContent = title || 'Notice';
noticeTextEl.textContent = t;
noticeEl.style.display = 'flex';
}
const isPreview = new URLSearchParams(window.location.search).get('preview') === '1'; const isPreview = new URLSearchParams(window.location.search).get('preview') === '1';
@@ -39,6 +196,33 @@
let idx = 0; let idx = 0;
let timer = null; let timer = null;
// Ticker DOM
const tickerEl = document.getElementById('ticker');
const tickerTrackEl = document.getElementById('tickerTrack');
let tickerConfig = null;
let tickerInterval = null;
let tickerLastHeadlines = [];
function getTickerPollSeconds() {
// Refresh headlines on a long interval.
// Default: 12 hours (twice per day).
// Override via ?ticker_poll=seconds.
const tp = parseInt(new URLSearchParams(window.location.search).get('ticker_poll') || '', 10);
return Number.isFinite(tp) && tp > 0 ? tp : (12 * 60 * 60);
}
const ANIM_MS = 420;
function getTransitionMode(pl) {
const v = (pl && pl.transition ? String(pl.transition) : 'none').toLowerCase();
return (v === 'fade' || v === 'slide' || v === 'none') ? v : 'none';
}
function applyTransitionClass(mode) {
stage.classList.remove('transition-none', 'transition-fade', 'transition-slide');
stage.classList.add(`transition-${mode}`);
}
async function fetchPlaylist() { async function fetchPlaylist() {
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : ''; const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' }); const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
@@ -49,29 +233,221 @@
return await res.json(); return await res.json();
} }
async function fetchTickerHeadlines() {
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
const res = await fetch(`/api/display/${token}/ticker${qs}`, { cache: 'no-store' });
if (res.status === 429) {
const data = await res.json().catch(() => null);
throw Object.assign(new Error(data?.message || 'Display limit reached'), { code: 'LIMIT', data });
}
return await res.json();
}
function safeCss(val) {
return (val || '').toString().replace(/[\n\r"']/g, ' ').trim();
}
function applyTickerStyle(cfg) {
if (!tickerEl) return;
const color = safeCss(cfg && cfg.color);
const bgColor = safeCss(cfg && cfg.bg_color);
const bgOpacityRaw = parseInt((cfg && cfg.bg_opacity) || '', 10);
const bgOpacity = Number.isFinite(bgOpacityRaw) ? Math.max(0, Math.min(100, bgOpacityRaw)) : 75;
const fontFamily = safeCss(cfg && cfg.font_family);
const sizePx = parseInt((cfg && cfg.font_size_px) || '', 10);
const fontSize = Number.isFinite(sizePx) ? Math.max(10, Math.min(200, sizePx)) : 28;
// Height is slightly larger than font size.
const height = Math.max(36, Math.min(120, fontSize + 26));
tickerEl.style.color = color || '#ffffff';
tickerEl.style.fontFamily = fontFamily || 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
tickerEl.style.fontSize = `${fontSize}px`;
tickerEl.style.setProperty('--ticker-height', `${height}px`);
// Background color + opacity
tickerEl.style.backgroundColor = toRgba(bgColor || '#000000', bgOpacity);
}
function toRgba(hexColor, opacityPercent) {
const s = (hexColor || '').toString().trim().toLowerCase();
const a = Math.max(0, Math.min(100, parseInt(opacityPercent || '0', 10))) / 100;
// Accept #rgb or #rrggbb. Fallback to black.
let r = 0, g = 0, b = 0;
if (s.startsWith('#')) {
const h = s.slice(1);
if (h.length === 3) {
r = parseInt(h[0] + h[0], 16);
g = parseInt(h[1] + h[1], 16);
b = parseInt(h[2] + h[2], 16);
} else if (h.length === 6) {
r = parseInt(h.slice(0,2), 16);
g = parseInt(h.slice(2,4), 16);
b = parseInt(h.slice(4,6), 16);
}
}
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function computeTickerDurationPx(copyWidthPx) {
const w = Math.max(1, parseInt(copyWidthPx || '0', 10) || 0);
// Speed slider (1..100): higher => faster.
const rawSpeed = parseInt((tickerConfig && tickerConfig.speed) || '', 10);
const speed = Number.isFinite(rawSpeed) ? Math.max(1, Math.min(100, rawSpeed)) : 25;
// Map speed to pixels/second. (tuned to be readable on signage)
// speed=25 => ~38 px/s, speed=100 => ~128 px/s
const pxPerSecond = Math.max(8, Math.min(180, 8 + (speed * 1.2)));
const seconds = w / pxPerSecond;
return Math.max(12, Math.min(600, seconds));
}
function buildTickerCopyHtml(list) {
// No trailing separator at the end.
return list.map((t, i) => {
const sep = (i === list.length - 1) ? '' : '<span class="sep">•</span>';
return `<span class="item">${escapeHtml(t)}</span>${sep}`;
}).join('');
}
function setTickerHeadlines(headlines) {
if (!tickerEl || !tickerTrackEl) return;
const list = Array.isArray(headlines) ? headlines.map(x => (x || '').toString().trim()).filter(Boolean) : [];
if (!list.length) {
tickerEl.style.display = 'none';
tickerTrackEl.innerHTML = '';
document.body.classList.remove('has-ticker');
return;
}
tickerLastHeadlines = list.slice();
// Show first so measurements work.
tickerEl.style.display = 'flex';
document.body.classList.add('has-ticker');
// Build one copy.
const oneCopyHtml = buildTickerCopyHtml(list);
tickerTrackEl.innerHTML = oneCopyHtml;
// Ensure we repeat enough so there is never an empty gap, even when the
// total headline width is smaller than the viewport.
requestAnimationFrame(() => {
try {
const viewportW = tickerEl.clientWidth || 1;
const copyW = tickerTrackEl.scrollWidth || 1;
// Want at least 2x viewport width in total track content.
const repeats = Math.max(2, Math.ceil((viewportW * 2) / copyW) + 1);
tickerTrackEl.innerHTML = oneCopyHtml.repeat(repeats);
// Shift by exactly one copy width. In % of total track width that is 100/repeats.
const shiftPercent = 100 / repeats;
tickerEl.style.setProperty('--ticker-shift', `${shiftPercent}%`);
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(copyW)}s`);
} catch (e) {
// fallback: 2 copies
tickerTrackEl.innerHTML = oneCopyHtml + oneCopyHtml;
tickerEl.style.setProperty('--ticker-shift', '50%');
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(2000)}s`);
}
});
}
function escapeHtml(s) {
return (s || '').toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function refreshTickerOnce() {
// Disabled or missing URL: hide and clear immediately.
if (!tickerConfig || !tickerConfig.enabled) {
setTickerHeadlines([]);
return;
}
if (!tickerConfig.rss_url || !String(tickerConfig.rss_url).trim()) {
setTickerHeadlines([]);
return;
}
try {
const data = await fetchTickerHeadlines();
if (!data || !data.enabled) {
setTickerHeadlines([]);
return;
}
setTickerHeadlines(data.headlines || []);
} catch (e) {
// Soft-fail: keep old headlines if any.
}
}
function rerenderTickerFromCache() {
if (!tickerLastHeadlines || !tickerLastHeadlines.length) return;
setTickerHeadlines(tickerLastHeadlines);
}
function startTickerPolling() {
if (tickerInterval) {
clearInterval(tickerInterval);
tickerInterval = null;
}
tickerInterval = setInterval(refreshTickerOnce, getTickerPollSeconds() * 1000);
}
function stopTickerPolling() {
if (tickerInterval) {
clearInterval(tickerInterval);
tickerInterval = null;
}
}
function clearStage() { function clearStage() {
if (timer) { clearTimeout(timer); timer = null; } if (timer) { clearTimeout(timer); timer = null; }
stage.innerHTML = ''; stage.innerHTML = '';
} }
function next() { function setOverlaySrc(src) {
if (!playlist || !playlist.items || playlist.items.length === 0) { const val = (src || '').trim();
notice.textContent = 'No playlist assigned.'; if (!val) {
clearStage(); if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl);
overlayEl = null;
return; return;
} }
const item = playlist.items[idx % playlist.items.length]; if (!overlayEl) {
idx = (idx + 1) % playlist.items.length; overlayEl = document.createElement('img');
overlayEl.id = 'overlay';
overlayEl.alt = 'Overlay';
document.body.appendChild(overlayEl);
}
clearStage(); // Cache-bust in preview mode so changes show up instantly.
notice.textContent = playlist.playlist ? `${playlist.display}${playlist.playlist.name}` : playlist.display; if (isPreview) {
try {
const u = new URL(val, window.location.origin);
u.searchParams.set('_ts', String(Date.now()));
overlayEl.src = u.toString();
return;
} catch(e) {
// fallthrough
}
}
overlayEl.src = val;
}
// Initialize overlay from server-side render.
if (overlayEl && overlayEl.src) setOverlaySrc(overlayEl.src);
function setSlideContent(container, item) {
if (item.type === 'image') { if (item.type === 'image') {
const el = document.createElement('img'); const el = document.createElement('img');
el.src = item.src; el.src = item.src;
stage.appendChild(el); container.appendChild(el);
timer = setTimeout(next, (item.duration || 10) * 1000);
} else if (item.type === 'video') { } else if (item.type === 'video') {
const el = document.createElement('video'); const el = document.createElement('video');
el.src = item.src; el.src = item.src;
@@ -79,12 +455,11 @@
el.muted = true; el.muted = true;
el.playsInline = true; el.playsInline = true;
el.onended = next; el.onended = next;
stage.appendChild(el); container.appendChild(el);
} else if (item.type === 'webpage') { } else if (item.type === 'webpage') {
const el = document.createElement('iframe'); const el = document.createElement('iframe');
el.src = item.url; el.src = item.url;
stage.appendChild(el); container.appendChild(el);
timer = setTimeout(next, (item.duration || 10) * 1000);
} else if (item.type === 'youtube') { } else if (item.type === 'youtube') {
const el = document.createElement('iframe'); const el = document.createElement('iframe');
// item.url is a base embed URL produced server-side (https://www.youtube-nocookie.com/embed/<id>) // item.url is a base embed URL produced server-side (https://www.youtube-nocookie.com/embed/<id>)
@@ -92,8 +467,77 @@
const u = item.url || ''; const u = item.url || '';
const sep = u.includes('?') ? '&' : '?'; const sep = u.includes('?') ? '&' : '?';
el.src = `${u}${sep}autoplay=1&mute=1&controls=0&rel=0&playsinline=1`; el.src = `${u}${sep}autoplay=1&mute=1&controls=0&rel=0&playsinline=1`;
stage.appendChild(el); container.appendChild(el);
}
}
function showItemWithTransition(item) {
const mode = getTransitionMode(playlist);
applyTransitionClass(mode);
// Create new slide container.
const slide = document.createElement('div');
slide.className = 'slide enter';
setSlideContent(slide, item);
// Determine previous slide (if any).
const prev = stage.querySelector('.slide');
// First render: no animation needed.
if (!prev || mode === 'none') {
stage.innerHTML = '';
slide.classList.remove('enter');
stage.appendChild(slide);
return;
}
// Transition: keep both on stage and animate.
stage.appendChild(slide);
// Trigger transition.
// In some browsers the style changes can get coalesced into a single paint (no animation),
// especially on fast/fullscreen pages. We force a layout read before activating.
requestAnimationFrame(() => {
prev.classList.add('exit');
// Force reflow so the browser commits initial (enter) styles.
// eslint-disable-next-line no-unused-expressions
slide.offsetHeight;
slide.classList.add('active');
prev.classList.add('active');
});
// Cleanup after animation.
window.setTimeout(() => {
try {
if (prev && prev.parentNode === stage) stage.removeChild(prev);
slide.classList.remove('enter');
} catch(e) { /* ignore */ }
}, ANIM_MS + 50);
}
function next() {
if (!playlist || !playlist.items || playlist.items.length === 0) {
setNotice('No playlists assigned.');
clearStage();
return;
}
const item = playlist.items[idx % playlist.items.length];
idx = (idx + 1) % playlist.items.length;
// Clear any active timers (but keep DOM for transition).
if (timer) { clearTimeout(timer); timer = null; }
setNotice('');
showItemWithTransition(item);
if (item.type === 'image') {
timer = setTimeout(next, (item.duration || 10) * 1000);
} else if (item.type === 'video') {
// next() is called on video end.
} else if (item.type === 'webpage') {
timer = setTimeout(next, (item.duration || 10) * 1000);
} else if (item.type === 'youtube') {
// YouTube iframes don't reliably emit an "ended" event without the JS API. // YouTube iframes don't reliably emit an "ended" event without the JS API.
// We keep it simple: play for the configured duration (default 30s). // We keep it simple: play for the configured duration (default 30s).
timer = setTimeout(next, (item.duration || 30) * 1000); timer = setTimeout(next, (item.duration || 30) * 1000);
@@ -106,25 +550,100 @@
try { try {
playlist = await fetchPlaylist(); playlist = await fetchPlaylist();
idx = 0; idx = 0;
applyTransitionClass(getTransitionMode(playlist));
setOverlaySrc(playlist && playlist.overlay_src);
tickerConfig = (playlist && playlist.ticker) ? playlist.ticker : null;
applyTickerStyle(tickerConfig);
await refreshTickerOnce();
if (tickerConfig && tickerConfig.enabled) {
startTickerPolling();
} else {
stopTickerPolling();
}
next(); next();
} catch (e) { } catch (e) {
clearStage(); clearStage();
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.'; if (e && e.code === 'LIMIT') {
setNotice(
(e && e.message) ? e.message : 'This display cannot start because the concurrent display limit has been reached.',
{ title: 'Display limit reached' }
);
} else {
setNotice(e && e.message ? e.message : 'Unable to load playlist.', { title: 'Playback error' });
}
// keep retrying; if a slot frees up the display will start automatically. // keep retrying; if a slot frees up the display will start automatically.
} }
// refresh playlist every 60s
// Poll for updates instead of SSE.
// This scales better for 100s of displays because it avoids long-lived HTTP
// connections (which otherwise tie up gunicorn sync workers).
// Default: check every 20s (can be overridden via ?poll=seconds).
const pollParam = parseInt(new URLSearchParams(window.location.search).get('poll') || '', 10);
const pollSeconds = Number.isFinite(pollParam) && pollParam > 0 ? pollParam : 20;
setInterval(async () => { setInterval(async () => {
try { try {
playlist = await fetchPlaylist(); const newPlaylist = await fetchPlaylist();
// Restart if something changed.
const oldStr = JSON.stringify(playlist);
const newStr = JSON.stringify(newPlaylist);
playlist = newPlaylist;
setOverlaySrc(playlist && playlist.overlay_src);
// Apply ticker settings (and refresh if settings changed)
const newTickerCfg = (playlist && playlist.ticker) ? playlist.ticker : null;
const oldTickerStr = JSON.stringify(tickerConfig);
const newTickerStr = JSON.stringify(newTickerCfg);
const oldEnabled = !!(tickerConfig && tickerConfig.enabled);
const newEnabled = !!(newTickerCfg && newTickerCfg.enabled);
const oldRssUrl = (tickerConfig && tickerConfig.rss_url) ? String(tickerConfig.rss_url) : '';
const newRssUrl = (newTickerCfg && newTickerCfg.rss_url) ? String(newTickerCfg.rss_url) : '';
tickerConfig = newTickerCfg;
applyTickerStyle(tickerConfig);
if (oldTickerStr !== newTickerStr) {
// Ensure enable/disable toggles are applied immediately (no reload required).
if (oldEnabled !== newEnabled) {
if (!newEnabled) {
// Hide and stop polling.
tickerLastHeadlines = [];
stopTickerPolling();
setTickerHeadlines([]);
} else {
// Re-enable: fetch now and restart headline polling.
await refreshTickerOnce();
startTickerPolling();
}
} else if (oldRssUrl !== newRssUrl) {
// RSS URL changed: refetch now.
await refreshTickerOnce();
} else {
// Style/speed change only: rerender from cache to apply instantly.
if (newEnabled) rerenderTickerFromCache();
}
}
if (oldStr !== newStr) {
idx = 0;
applyTransitionClass(getTransitionMode(playlist));
next();
}
// If player is blank (e.g. after a temporary error), kick it.
if (!stage.firstChild) { if (!stage.firstChild) {
idx = 0; idx = 0;
next(); next();
} }
} catch(e) { } catch(e) {
clearStage(); clearStage();
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.'; if (e && e.code === 'LIMIT') {
setNotice(
(e && e.message) ? e.message : 'This display cannot start because the concurrent display limit has been reached.',
{ title: 'Display limit reached' }
);
} else {
setNotice(e && e.message ? e.message : 'Unable to load playlist.', { title: 'Playback error' });
}
} }
}, 60000); }, pollSeconds * 1000);
} }
start(); start();

150
app/uploads.py Normal file
View File

@@ -0,0 +1,150 @@
import os
def _safe_company_segment(company_id: int | None) -> str:
"""Return the directory name for a company's upload folder.
We intentionally use the numeric company_id (not company name) to avoid
rename issues and any path traversal concerns.
"""
try:
cid = int(company_id) if company_id is not None else 0
except (TypeError, ValueError):
cid = 0
return str(max(0, cid))
def get_company_upload_dir(upload_root: str, company_id: int | None) -> str:
"""Return absolute directory path for a company's uploads."""
return os.path.join(upload_root, _safe_company_segment(company_id))
def ensure_company_upload_dir(upload_root: str, company_id: int | None) -> str:
"""Ensure the company's upload directory exists; return its absolute path."""
d = get_company_upload_dir(upload_root, company_id)
os.makedirs(d, exist_ok=True)
return d
def get_company_upload_bytes(upload_root: str, company_id: int | None) -> int:
"""Return best-effort total bytes used by a company's upload directory.
This walks the directory tree under uploads/<company_id> and sums file sizes.
Any errors (missing directories, permission issues, broken links) are ignored.
"""
total = 0
root = get_company_upload_dir(upload_root, company_id)
try:
if not os.path.isdir(root):
return 0
except Exception:
return 0
for base, _dirs, files in os.walk(root):
for fn in files:
try:
p = os.path.join(base, fn)
if os.path.isfile(p):
total += os.path.getsize(p)
except Exception:
# Ignore unreadable files
continue
return int(total)
def compute_storage_usage(*, used_bytes: int, max_bytes: int | None):
"""Compute storage usage info.
Args:
used_bytes: current usage
max_bytes: quota; if None or <=0: unlimited
Returns dict:
{
"max_bytes": int|None,
"used_bytes": int,
"is_limited": bool,
"is_exceeded": bool,
"used_ratio": float|None, # 0..1 when limited
"used_percent": int|None, # rounded percent when limited
"remaining_bytes": int|None,
}
"""
used = max(0, int(used_bytes or 0))
mx = None if max_bytes is None else int(max_bytes)
if mx is None or mx <= 0:
return {
"max_bytes": None,
"used_bytes": used,
"is_limited": False,
"is_exceeded": False,
"used_ratio": None,
"used_percent": None,
"remaining_bytes": None,
}
ratio = used / mx if mx > 0 else 1.0
percent = int(round(ratio * 100.0))
return {
"max_bytes": mx,
"used_bytes": used,
"is_limited": True,
"is_exceeded": used >= mx,
# Keep percent un-clamped so the UI can show e.g. 132% when exceeded.
# Clamp only the ratio (for progress bars, etc.).
"used_ratio": max(0.0, min(1.0, ratio)),
"used_percent": max(0, percent),
"remaining_bytes": max(0, mx - used),
}
def is_valid_upload_relpath(file_path: str | None) -> bool:
"""True if file_path looks like a path we manage under /static.
Supports both layouts:
- uploads/<filename>
- uploads/<company_id>/<filename>
"""
if not file_path:
return False
fp = (file_path or "").replace("\\", "/")
if not fp.startswith("uploads/"):
return False
# Prevent weird absolute/relative tricks; we only allow a normal relative path.
if fp.startswith("uploads//") or ":" in fp:
return False
# No parent dir segments.
if "../" in fp or fp.endswith("/..") or fp.startswith("../"):
return False
return True
def abs_upload_path(upload_root: str, file_path: str | None) -> str | None:
"""Resolve an item.file_path (uploads/...) to an absolute file path.
Returns None if file_path is not a managed uploads path.
"""
if not is_valid_upload_relpath(file_path):
return None
rel = (file_path or "").replace("\\", "/")
rel = rel.split("uploads/", 1)[1]
# Split into segments and harden against traversal.
parts = [p for p in rel.split("/") if p]
if not parts:
return None
candidate = os.path.abspath(os.path.join(upload_root, *parts))
root_abs = os.path.abspath(upload_root)
# Ensure resolved path stays inside upload_root.
if os.path.commonpath([candidate, root_abs]) != root_abs:
return None
return candidate

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
services:
web:
build: .
image: signage:latest
ports:
- "8000:8000"
environment:
# Override in a .env file or your shell; this default is only for convenience.
SECRET_KEY: "change-me"
# Optional bootstrap (only runs if ADMIN_PASS is set)
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@admin.admin}
ADMIN_PASS: ${ADMIN_PASS:-}
# Optional overrides (the Dockerfile already defaults these)
GUNICORN_WORKERS: "2"
GUNICORN_BIND: "0.0.0.0:8000"
# Entrypoint (from Dockerfile) runs:
# - `flask ensure-db`
# - optional `flask init-db` when ADMIN_PASS is set
# - gunicorn
volumes:
# Persist SQLite DB and uploads on the host
- data/fossign/instance:/app/instance
- data/fossign/uploads:/app/app/static/uploads

12
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env sh
set -eu
# Ensure DB schema exists
flask --app app ensure-db
# Optional: create/update initial admin account
if [ -n "${ADMIN_PASS:-}" ]; then
flask --app app init-db --admin-email "${ADMIN_EMAIL:-admin@admin.admin}" --admin-pass "${ADMIN_PASS}"
fi
exec gunicorn -w "${GUNICORN_WORKERS:-2}" -b "${GUNICORN_BIND:-0.0.0.0:8000}" wsgi:app

View File

@@ -0,0 +1,54 @@
import os
import sys
# Ensure repo root is on sys.path when running as a script.
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from app import create_app
from app.extensions import db
from app.models import Company, Display
def main():
app = create_app()
with app.app_context():
db.create_all()
# Create a company + display
c = Company(name="TestCo_DisplayLimit")
db.session.add(c)
db.session.commit()
d = Display(company_id=c.id, name="Lobby")
db.session.add(d)
db.session.commit()
token = d.token
client = app.test_client()
def hit(sid: str):
return client.get(f"/api/display/{token}/playlist?sid={sid}")
# First 3 should be accepted (200 with JSON)
for sid in ("s1", "s2", "s3"):
r = hit(sid)
assert r.status_code == 200, (sid, r.status_code, r.data)
# 4th should be rejected with 429 and a clear message
r4 = hit("s4")
assert r4.status_code == 429, (r4.status_code, r4.data)
payload = r4.get_json(silent=True) or {}
assert payload.get("error") == "display_limit_reached", payload
msg = payload.get("message") or ""
assert "open on 3" in msg, msg
print("OK: display session limit allows 3 sessions; 4th is rejected with 429.")
if __name__ == "__main__":
main()

136
scripts/release.py Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Release helper.
What it does (in order):
1) Ask/provide a commit message and version.
2) Commit & push to the `openslide` git remote.
3) Build + push Docker image tags:
- git.alphen.cloud/bramval/openslide:<version>
- git.alphen.cloud/bramval/openslide:latest
Usage examples:
python scripts/release.py --version 1.2.3 --message "Release 1.2.3"
python scripts/release.py # interactive prompts
Notes:
- Assumes you are already authenticated for git + the Docker registry (docker login).
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from dataclasses import dataclass
from typing import Iterable, Sequence
DEFAULT_GIT_REMOTE = "openslide"
DEFAULT_IMAGE = "git.alphen.cloud/bramval/openslide"
@dataclass(frozen=True)
class ReleaseInfo:
version: str
message: str
_DOCKER_TAG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
def _run(cmd: Sequence[str], *, dry_run: bool = False) -> None:
printable = " ".join(cmd)
print(f"> {printable}")
if dry_run:
return
subprocess.run(cmd, check=True)
def _capture(cmd: Sequence[str]) -> str:
return subprocess.check_output(cmd, text=True).strip()
def _require_tool(name: str) -> None:
try:
subprocess.run([name, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
except FileNotFoundError as e:
raise SystemExit(f"Required tool not found in PATH: {name}") from e
def _validate_version(tag: str) -> str:
tag = tag.strip()
if not tag:
raise ValueError("Version may not be empty")
if not _DOCKER_TAG_RE.match(tag):
raise ValueError(
"Invalid docker tag for version. Use only letters, digits, '.', '_' or '-'. "
"(1..128 chars, must start with [A-Za-z0-9])"
)
return tag
def get_release_info(*, version: str | None, message: str | None) -> ReleaseInfo:
"""Collect commit message + version before doing the rest."""
if version is None:
version = input("Version tag (e.g. 1.2.3): ").strip()
version = _validate_version(version)
if message is None:
message = input(f"Commit message [Release {version}]: ").strip() or f"Release {version}"
return ReleaseInfo(version=version, message=message)
def git_commit_and_push(*, remote: str, message: str, dry_run: bool = False) -> None:
# Stage all changes
_run(["git", "add", "-A"], dry_run=dry_run)
# Only commit if there is something to commit
porcelain = _capture(["git", "status", "--porcelain"]) # empty => clean
if porcelain:
_run(["git", "commit", "-m", message], dry_run=dry_run)
else:
print("No working tree changes detected; skipping git commit.")
branch = _capture(["git", "rev-parse", "--abbrev-ref", "HEAD"])
_run(["git", "push", remote, branch], dry_run=dry_run)
def docker_build_and_push(*, image: str, version: str, dry_run: bool = False) -> None:
version_tag = f"{image}:{version}"
latest_tag = f"{image}:latest"
_run(["docker", "build", "-t", version_tag, "-t", latest_tag, "."], dry_run=dry_run)
_run(["docker", "push", version_tag], dry_run=dry_run)
_run(["docker", "push", latest_tag], dry_run=dry_run)
def main(argv: Iterable[str]) -> int:
parser = argparse.ArgumentParser(description="Commit + push + docker publish helper.")
parser.add_argument("--version", "-v", help="Docker version tag (e.g. 1.2.3)")
parser.add_argument("--message", "-m", help="Git commit message")
parser.add_argument("--remote", default=DEFAULT_GIT_REMOTE, help=f"Git remote to push to (default: {DEFAULT_GIT_REMOTE})")
parser.add_argument("--image", default=DEFAULT_IMAGE, help=f"Docker image name (default: {DEFAULT_IMAGE})")
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing")
args = parser.parse_args(list(argv))
_require_tool("git")
_require_tool("docker")
info = get_release_info(version=args.version, message=args.message)
print(f"\nReleasing version: {info.version}")
print(f"Commit message: {info.message}")
print(f"Git remote: {args.remote}")
print(f"Docker image: {args.image}\n")
git_commit_and_push(remote=args.remote, message=info.message, dry_run=args.dry_run)
docker_build_and_push(image=args.image, version=info.version, dry_run=args.dry_run)
print("\nDone.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -20,13 +20,18 @@ def main():
required = { required = {
"/admin/companies/<int:company_id>/delete", "/admin/companies/<int:company_id>/delete",
"/admin/displays/<int:display_id>/delete",
"/admin/displays/<int:display_id>/name", "/admin/displays/<int:display_id>/name",
"/admin/settings",
"/company/displays/<int:display_id>", "/company/displays/<int:display_id>",
"/company/items/<int:item_id>/duration", "/company/items/<int:item_id>/duration",
"/company/playlists/<int:playlist_id>/items/reorder", "/company/playlists/<int:playlist_id>/items/reorder",
"/auth/change-password", "/auth/change-password",
"/auth/forgot-password", "/auth/forgot-password",
"/auth/reset-password/<token>", "/auth/reset-password/<token>",
"/company/my-company",
"/company/my-company/invite",
"/company/my-company/users/<int:user_id>/delete",
} }
missing = sorted(required.difference(rules)) missing = sorted(required.difference(rules))
if missing: if missing:

View File

@@ -0,0 +1,93 @@
import io
import os
import sys
import tempfile
# Ensure repo root is on sys.path when running as a script.
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from app import create_app
from app.extensions import db
from app.models import Company, Playlist, User
def main():
# Use a temporary SQLite DB so this doesn't touch your real instance DB.
fd, path = tempfile.mkstemp(prefix="rssfeed-test-", suffix=".sqlite")
os.close(fd)
try:
os.environ["DATABASE_URL"] = f"sqlite:///{path}"
os.environ["SECRET_KEY"] = "test-secret"
app = create_app()
app.config["TESTING"] = True
with app.app_context():
db.create_all()
c = Company(name="TestCo")
# 1 byte quota so any non-empty upload should be rejected.
c.storage_max_bytes = 1
db.session.add(c)
db.session.commit()
u = User(username="test@example.com", email="test@example.com", is_admin=False, company_id=c.id)
u.set_password("passw0rd123")
db.session.add(u)
db.session.commit()
p = Playlist(company_id=c.id, name="Playlist")
db.session.add(p)
db.session.commit()
client = app.test_client()
# Login
res = client.post(
"/auth/login",
data={"email": "test@example.com", "password": "passw0rd123"},
follow_redirects=False,
)
if res.status_code not in (302, 303):
raise SystemExit(f"Login failed: {res.status_code}")
# Try to upload a small image payload.
data = {
"item_type": "image",
"title": "Small",
"duration_seconds": "10",
"crop_mode": "none",
"response": "json",
# Not a real image; should fail processing OR quota depending on Pillow.
# Use a tiny valid PNG header so Pillow can parse it.
"file": (
io.BytesIO(
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x01\x01\x01\x00\x18\xdd\x8d\x18\x00\x00\x00\x00IEND\xaeB`\x82"
),
"tiny.png",
),
}
url = f"/company/playlists/{p.id}/items"
res = client.post(url, data=data, content_type="multipart/form-data")
if res.status_code != 403:
raise SystemExit(f"Expected 403 for quota exceeded, got: {res.status_code} {res.data!r}")
js = res.get_json(silent=True) or {}
if js.get("ok") is not False:
raise SystemExit(f"Unexpected response: {js}")
if "Storage limit" not in (js.get("error") or ""):
raise SystemExit(f"Unexpected error message: {js}")
print("OK: storage quota prevents uploads when exceeded")
finally:
try:
os.remove(path)
except OSError:
pass
if __name__ == "__main__":
main()

34
wsgi.py Normal file
View File

@@ -0,0 +1,34 @@
"""WSGI entrypoint for production servers (gunicorn/uWSGI/etc.).
This file exposes a module-level WSGI callable named `app` (and `application`)
so common servers can run the project without relying on Flask's dev server.
Examples:
gunicorn -w 4 -b 0.0.0.0:8000 wsgi:app
uwsgi --http :8000 --wsgi-file wsgi.py --callable app
"""
from __future__ import annotations
import os
# `flask run` loads .env/.flaskenv automatically via python-dotenv.
# Production WSGI servers typically *don't*, so we best-effort load `.env` here.
# In real production, prefer setting environment variables via your process manager.
try:
from dotenv import load_dotenv
load_dotenv(os.environ.get("DOTENV_PATH", ".env"), override=False)
except Exception:
# If python-dotenv isn't installed (or any other issue occurs), continue.
pass
from app import create_app
app = create_app()
# Some servers (and hosting platforms) look specifically for `application`.
application = app