first commit

This commit is contained in:
2026-01-23 13:54:58 +01:00
commit 32312fe4f2
29 changed files with 2172 additions and 0 deletions

1
app/routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Package marker for routes.

192
app/routes/admin.py Normal file
View File

@@ -0,0 +1,192 @@
import uuid
import os
from flask import Blueprint, abort, current_app, flash, redirect, render_template, request, session, url_for
from flask_login import current_user, login_required, login_user
from ..extensions import db
from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User
bp = Blueprint("admin", __name__, url_prefix="/admin")
def admin_required():
if not current_user.is_authenticated or not current_user.is_admin:
abort(403)
def _try_delete_upload(file_path: str | None, upload_folder: str):
"""Best-effort delete of an uploaded media file."""
if not file_path:
return
if not file_path.startswith("uploads/"):
return
filename = file_path.split("/", 1)[1]
abs_path = os.path.join(upload_folder, filename)
try:
if os.path.isfile(abs_path):
os.remove(abs_path)
except Exception:
# Ignore cleanup failures
pass
@bp.get("/")
@login_required
def dashboard():
admin_required()
companies = Company.query.order_by(Company.name.asc()).all()
users = User.query.order_by(User.username.asc()).all()
return render_template("admin/dashboard.html", companies=companies, users=users)
@bp.post("/companies")
@login_required
def create_company():
admin_required()
name = request.form.get("name", "").strip()
if not name:
flash("Company name required", "danger")
return redirect(url_for("admin.dashboard"))
if Company.query.filter_by(name=name).first():
flash("Company name already exists", "danger")
return redirect(url_for("admin.dashboard"))
c = Company(name=name)
db.session.add(c)
db.session.commit()
flash("Company created", "success")
return redirect(url_for("admin.company_detail", company_id=c.id))
@bp.get("/companies/<int:company_id>")
@login_required
def company_detail(company_id: int):
admin_required()
company = db.session.get(Company, company_id)
if not company:
abort(404)
return render_template("admin/company_detail.html", company=company)
@bp.post("/companies/<int:company_id>/users")
@login_required
def create_company_user(company_id: int):
admin_required()
company = db.session.get(Company, company_id)
if not company:
abort(404)
username = request.form.get("username", "").strip()
email = (request.form.get("email", "") or "").strip().lower() or None
password = request.form.get("password", "")
if not username or not email or not password:
flash("Username, email and password required", "danger")
return redirect(url_for("admin.company_detail", company_id=company_id))
if User.query.filter_by(username=username).first():
flash("Username already exists", "danger")
return redirect(url_for("admin.company_detail", company_id=company_id))
if User.query.filter_by(email=email).first():
flash("Email already exists", "danger")
return redirect(url_for("admin.company_detail", company_id=company_id))
u = User(username=username, is_admin=False, company=company)
u.email = email
u.set_password(password)
db.session.add(u)
db.session.commit()
flash("User created", "success")
return redirect(url_for("admin.company_detail", company_id=company_id))
@bp.post("/companies/<int:company_id>/displays")
@login_required
def create_display(company_id: int):
admin_required()
company = db.session.get(Company, company_id)
if not company:
abort(404)
name = request.form.get("name", "").strip() or "Display"
token = uuid.uuid4().hex
d = Display(company=company, name=name, token=token)
db.session.add(d)
db.session.commit()
flash("Display created", "success")
return redirect(url_for("admin.company_detail", company_id=company_id))
@bp.post("/companies/<int:company_id>/delete")
@login_required
def delete_company(company_id: int):
admin_required()
company = db.session.get(Company, company_id)
if not company:
abort(404)
# If FK constraints are enabled, we must delete in a safe order.
# 1) Detach displays from playlists (Display.assigned_playlist_id -> Playlist.id)
for d in list(company.displays):
d.assigned_playlist_id = None
# 2) Delete display sessions referencing displays of this company
display_ids = [d.id for d in company.displays]
if display_ids:
DisplaySession.query.filter(DisplaySession.display_id.in_(display_ids)).delete(synchronize_session=False)
# 3) Clean up uploaded media files for all playlist items
upload_folder = current_app.config["UPLOAD_FOLDER"]
items = (
PlaylistItem.query.join(Playlist, PlaylistItem.playlist_id == Playlist.id)
.filter(Playlist.company_id == company.id)
.all()
)
for it in items:
if it.item_type in ("image", "video"):
_try_delete_upload(it.file_path, upload_folder)
# 4) Delete the company; cascades will delete users/displays/playlists/items.
company_name = company.name
db.session.delete(company)
db.session.commit()
flash(f"Company '{company_name}' deleted (including users, displays and playlists).", "success")
return redirect(url_for("admin.dashboard"))
@bp.post("/impersonate/<int:user_id>")
@login_required
def impersonate(user_id: int):
admin_required()
target = db.session.get(User, user_id)
if not target or target.is_admin:
flash("Cannot impersonate this user", "danger")
return redirect(url_for("admin.dashboard"))
# Save admin id in session so we can return without any password.
session["impersonator_admin_id"] = current_user.id
login_user(target)
flash(f"Impersonating {target.username}.", "warning")
return redirect(url_for("company.dashboard"))
@bp.post("/users/<int:user_id>/email")
@login_required
def update_user_email(user_id: int):
admin_required()
u = db.session.get(User, user_id)
if not u:
abort(404)
email = (request.form.get("email", "") or "").strip().lower() or None
if email:
existing = User.query.filter(User.email == email, User.id != u.id).first()
if existing:
flash("Email already exists", "danger")
return redirect(url_for("admin.company_detail", company_id=u.company_id))
u.email = email
db.session.commit()
flash("Email updated", "success")
return redirect(url_for("admin.company_detail", company_id=u.company_id))

88
app/routes/api.py Normal file
View File

@@ -0,0 +1,88 @@
from datetime import datetime, timedelta
from flask import Blueprint, abort, jsonify, request, url_for
from ..extensions import db
from ..models import Display, DisplaySession
bp = Blueprint("api", __name__, url_prefix="/api")
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 2
SESSION_TTL_SECONDS = 90
@bp.get("/display/<token>/playlist")
def display_playlist(token: str):
display = Display.query.filter_by(token=token).first()
if not display:
abort(404)
# Enforce: a display URL/token can be opened by max 2 concurrently active sessions.
# Player sends a stable `sid` via querystring.
sid = (request.args.get("sid") or "").strip()
if sid:
cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS)
DisplaySession.query.filter(
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()
if existing:
existing.last_seen_at = datetime.utcnow()
db.session.commit()
else:
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 (
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 = []
for item in playlist.items:
payload = {
"id": item.id,
"type": item.item_type,
"title": item.title,
"duration": item.duration_seconds,
}
if item.item_type in ("image", "video") and item.file_path:
payload["src"] = url_for("static", filename=item.file_path)
if item.item_type == "webpage":
payload["url"] = item.url
items.append(payload)
return jsonify(
{
"display": display.name,
"playlist": {"id": playlist.id, "name": playlist.name},
"items": items,
}
)

209
app/routes/auth.py Normal file
View File

@@ -0,0 +1,209 @@
from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for
from flask_login import current_user, login_required, login_user, logout_user
import logging
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from ..extensions import db
from ..email_utils import send_email
from ..models import User
bp = Blueprint("auth", __name__, url_prefix="/auth")
logger = logging.getLogger(__name__)
def _reset_serializer_v2() -> URLSafeTimedSerializer:
# 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
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="password-reset")
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:
s = _reset_serializer_v2()
data = s.loads(token, max_age=max_age_seconds)
user_id = int(data.get("user_id"))
return user_id
@bp.get("/forgot-password")
def forgot_password():
if current_user.is_authenticated:
return redirect(url_for("index"))
return render_template("auth_forgot_password.html")
@bp.post("/forgot-password")
def forgot_password_post():
# Always respond with a generic message to avoid user enumeration.
email = (request.form.get("email", "") or "").strip().lower()
if not email:
flash("If that email exists, you will receive a reset link.", "info")
return redirect(url_for("auth.forgot_password"))
user = User.query.filter_by(email=email).first()
if user:
token = _make_reset_token(user)
reset_url = url_for("auth.reset_password", token=token, _external=True)
body = (
"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"
"If you did not request this, you can ignore this email."
)
try:
send_email(to_email=user.email, subject="Password reset", body_text=body)
except Exception:
# Keep message generic to user (avoid leaking SMTP issues), but log for admins.
logger.exception("Failed to send password reset email")
flash("If that email exists, you will receive a reset link.", "info")
return redirect(url_for("auth.login"))
@bp.get("/reset-password/<token>")
def reset_password(token: str):
if current_user.is_authenticated:
return redirect(url_for("index"))
# Validate token up-front so UI can show a friendly error.
try:
_load_reset_token(token, max_age_seconds=30 * 60)
except SignatureExpired:
return render_template("auth_reset_password.html", token=None, token_error="Reset link has expired.")
except BadSignature:
return render_template("auth_reset_password.html", token=None, token_error="Invalid reset link.")
return render_template("auth_reset_password.html", token=token, token_error=None)
@bp.post("/reset-password/<token>")
def reset_password_post(token: str):
if current_user.is_authenticated:
return redirect(url_for("index"))
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
try:
user_id = _load_reset_token(token, max_age_seconds=30 * 60)
except SignatureExpired:
flash("Reset link has expired. Please request a new one.", "danger")
return redirect(url_for("auth.forgot_password"))
except BadSignature:
abort(400)
if not new_password:
flash("New password is required", "danger")
return redirect(url_for("auth.reset_password", token=token))
if len(new_password) < 8:
flash("New password must be at least 8 characters", "danger")
return redirect(url_for("auth.reset_password", token=token))
if new_password != confirm_password:
flash("New password and confirmation do not match", "danger")
return redirect(url_for("auth.reset_password", token=token))
user = db.session.get(User, user_id)
if not user:
# Generic response: treat as invalid.
abort(400)
user.set_password(new_password)
db.session.commit()
flash("Password updated. You can now log in.", "success")
return redirect(url_for("auth.login"))
@bp.get("/change-password")
@login_required
def change_password():
return render_template("auth_change_password.html")
@bp.post("/change-password")
@login_required
def change_password_post():
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
if not current_user.check_password(current_password):
flash("Current password is incorrect", "danger")
return redirect(url_for("auth.change_password"))
if not new_password:
flash("New password is required", "danger")
return redirect(url_for("auth.change_password"))
if len(new_password) < 8:
flash("New password must be at least 8 characters", "danger")
return redirect(url_for("auth.change_password"))
if new_password != confirm_password:
flash("New password and confirmation do not match", "danger")
return redirect(url_for("auth.change_password"))
# Avoid no-op changes (helps catch accidental submits)
if current_user.check_password(new_password):
flash("New password must be different from the current password", "danger")
return redirect(url_for("auth.change_password"))
current_user.set_password(new_password)
db.session.commit()
flash("Password updated", "success")
# Send user back to their home area.
return redirect(url_for("index"))
@bp.get("/login")
def login():
if current_user.is_authenticated:
return redirect(url_for("index"))
return render_template("auth_login.html")
@bp.post("/login")
def login_post():
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
flash("Invalid username/password", "danger")
return redirect(url_for("auth.login"))
# clear impersonation marker, if any
session.pop("impersonator_admin_id", None)
login_user(user)
return redirect(url_for("index"))
@bp.get("/logout")
@login_required
def logout():
logout_user()
session.pop("impersonator_admin_id", None)
return redirect(url_for("auth.login"))
@bp.get("/stop-impersonation")
@login_required
def stop_impersonation():
admin_id = session.get("impersonator_admin_id")
if not admin_id:
return redirect(url_for("index"))
admin = db.session.get(User, int(admin_id))
session.pop("impersonator_admin_id", None)
if admin:
login_user(admin)
return redirect(url_for("admin.dashboard"))

254
app/routes/company.py Normal file
View File

@@ -0,0 +1,254 @@
import os
import uuid
from flask import Blueprint, abort, current_app, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from PIL import Image
from ..extensions import db
from ..models import Display, Playlist, PlaylistItem
def _save_compressed_image(uploaded_file, upload_folder: str) -> str:
"""Save an uploaded image as a compressed WEBP file.
Returns relative file path under /static (e.g. uploads/<uuid>.webp)
"""
unique = f"{uuid.uuid4().hex}.webp"
save_path = os.path.join(upload_folder, unique)
img = Image.open(uploaded_file)
# Normalize mode for webp
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
# Resize down if very large (keeps aspect ratio)
img.thumbnail((1920, 1080))
img.save(save_path, format="WEBP", quality=80, method=6)
return f"uploads/{unique}"
def _try_delete_upload(file_path: str | None, upload_folder: str):
"""Best-effort delete of an uploaded media file."""
if not file_path:
return
if not file_path.startswith("uploads/"):
return
filename = file_path.split("/", 1)[1]
abs_path = os.path.join(upload_folder, filename)
try:
if os.path.isfile(abs_path):
os.remove(abs_path)
except Exception:
# Ignore cleanup failures
pass
bp = Blueprint("company", __name__, url_prefix="/company")
def company_user_required():
if not current_user.is_authenticated:
abort(403)
if current_user.is_admin:
abort(403)
if not current_user.company_id:
abort(403)
@bp.get("/")
@login_required
def dashboard():
company_user_required()
playlists = Playlist.query.filter_by(company_id=current_user.company_id).order_by(Playlist.name.asc()).all()
displays = Display.query.filter_by(company_id=current_user.company_id).order_by(Display.name.asc()).all()
return render_template("company/dashboard.html", playlists=playlists, displays=displays)
@bp.post("/playlists")
@login_required
def create_playlist():
company_user_required()
name = request.form.get("name", "").strip()
if not name:
flash("Playlist name required", "danger")
return redirect(url_for("company.dashboard"))
p = Playlist(company_id=current_user.company_id, name=name)
db.session.add(p)
db.session.commit()
flash("Playlist created", "success")
return redirect(url_for("company.playlist_detail", playlist_id=p.id))
@bp.get("/playlists/<int:playlist_id>")
@login_required
def playlist_detail(playlist_id: int):
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
return render_template("company/playlist_detail.html", playlist=playlist)
@bp.post("/playlists/<int:playlist_id>/delete")
@login_required
def delete_playlist(playlist_id: int):
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
# Unassign from any displays in this company
Display.query.filter_by(company_id=current_user.company_id, assigned_playlist_id=playlist.id).update(
{"assigned_playlist_id": None}
)
# cleanup uploaded files for image/video items
for it in list(playlist.items):
if it.item_type in ("image", "video"):
_try_delete_upload(it.file_path, current_app.config["UPLOAD_FOLDER"])
db.session.delete(playlist)
db.session.commit()
flash("Playlist deleted", "success")
return redirect(url_for("company.dashboard"))
@bp.post("/playlists/<int:playlist_id>/items/reorder")
@login_required
def reorder_playlist_items(playlist_id: int):
"""Persist new ordering for playlist items.
Expects form data: order=<comma-separated item ids>.
"""
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
order = (request.form.get("order") or "").strip()
if not order:
abort(400)
try:
ids = [int(x) for x in order.split(",") if x.strip()]
except ValueError:
abort(400)
# Ensure ids belong to this playlist
existing = PlaylistItem.query.filter(PlaylistItem.playlist_id == playlist_id, PlaylistItem.id.in_(ids)).all()
existing_ids = {i.id for i in existing}
if len(existing_ids) != len(ids):
abort(400)
# Re-number positions starting at 1
id_to_item = {i.id: i for i in existing}
for pos, item_id in enumerate(ids, start=1):
id_to_item[item_id].position = pos
db.session.commit()
return ("", 204)
@bp.post("/playlists/<int:playlist_id>/items")
@login_required
def add_playlist_item(playlist_id: int):
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
item_type = request.form.get("item_type")
title = request.form.get("title", "").strip() or None
duration = int(request.form.get("duration_seconds") or 10)
max_pos = (
db.session.query(db.func.max(PlaylistItem.position)).filter_by(playlist_id=playlist_id).scalar() or 0
)
pos = max_pos + 1
item = PlaylistItem(
playlist=playlist,
item_type=item_type,
title=title,
duration_seconds=max(1, duration),
position=pos,
)
if item_type in ("image", "video"):
f = request.files.get("file")
if not f or not f.filename:
flash("File required", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
filename = secure_filename(f.filename)
ext = os.path.splitext(filename)[1].lower()
if item_type == "image" and ext in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"):
try:
item.file_path = _save_compressed_image(f, current_app.config["UPLOAD_FOLDER"])
except Exception:
flash("Failed to process image upload", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
else:
# Videos (and unknown image types): keep as-is but always rename to a UUID
unique = uuid.uuid4().hex + ext
save_path = os.path.join(current_app.config["UPLOAD_FOLDER"], unique)
f.save(save_path)
item.file_path = f"uploads/{unique}"
elif item_type == "webpage":
url = request.form.get("url", "").strip()
if not url:
flash("URL required", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
item.url = url
else:
flash("Invalid item type", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
db.session.add(item)
db.session.commit()
flash("Item added", "success")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/items/<int:item_id>/delete")
@login_required
def delete_item(item_id: int):
company_user_required()
item = db.session.get(PlaylistItem, item_id)
if not item or item.playlist.company_id != current_user.company_id:
abort(404)
playlist_id = item.playlist_id
if item.item_type in ("image", "video"):
_try_delete_upload(item.file_path, current_app.config["UPLOAD_FOLDER"])
db.session.delete(item)
db.session.commit()
flash("Item deleted", "success")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/displays/<int:display_id>/assign")
@login_required
def assign_playlist(display_id: int):
company_user_required()
display = db.session.get(Display, display_id)
if not display or display.company_id != current_user.company_id:
abort(404)
playlist_id = request.form.get("playlist_id")
if not playlist_id:
display.assigned_playlist_id = None
else:
playlist = db.session.get(Playlist, int(playlist_id))
if not playlist or playlist.company_id != current_user.company_id:
abort(400)
display.assigned_playlist_id = playlist.id
db.session.commit()
flash("Display assignment updated", "success")
return redirect(url_for("company.dashboard"))

13
app/routes/display.py Normal file
View File

@@ -0,0 +1,13 @@
from flask import Blueprint, abort, render_template
from ..models import Display
bp = Blueprint("display", __name__, url_prefix="/display")
@bp.get("/<token>")
def display_player(token: str):
display = Display.query.filter_by(token=token).first()
if not display:
abort(404)
return render_template("display/player.html", display=display)