diff --git a/README.md b/README.md index 1a11047..986e98d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,20 @@ Open http://127.0.0.1:5000 - SQLite DB is stored at `instance/signage.sqlite`. - Uploaded files go to `app/static/uploads/`. +## Display player + +Open: + +- `http:///display/` for live playback (counts towards the concurrent display limit) +- `http:///display/?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//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. + ## SMTP / Forgot password This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables. @@ -88,3 +102,5 @@ If the reset email is not received: + + diff --git a/app/auth_tokens.py b/app/auth_tokens.py new file mode 100644 index 0000000..58c1447 --- /dev/null +++ b/app/auth_tokens.py @@ -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")) diff --git a/app/routes/admin.py b/app/routes/admin.py index bef9a2a..cd499a3 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -6,6 +6,7 @@ from flask import Blueprint, abort, current_app, flash, redirect, render_templat from flask_login import current_user, login_required, login_user from ..extensions import db +from ..uploads import abs_upload_path, ensure_company_upload_dir, is_valid_upload_relpath from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User bp = Blueprint("admin", __name__, url_prefix="/admin") @@ -20,11 +21,12 @@ 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/"): + if not is_valid_upload_relpath(file_path): return - filename = file_path.split("/", 1)[1] - abs_path = os.path.join(upload_folder, filename) + abs_path = abs_upload_path(upload_folder, file_path) + if not abs_path: + return try: if os.path.isfile(abs_path): os.remove(abs_path) @@ -55,6 +57,13 @@ def create_company(): c = Company(name=name) db.session.add(c) 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") return redirect(url_for("admin.company_detail", company_id=c.id)) @@ -193,6 +202,43 @@ def update_user_email(user_id: int): return redirect(url_for("admin.company_detail", company_id=u.company_id)) +@bp.post("/users//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//name") @login_required def update_display_name(display_id: int): diff --git a/app/routes/api.py b/app/routes/api.py index e1fde05..47b61cb 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1,6 +1,9 @@ from datetime import datetime, timedelta +import hashlib +import json +import time -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 ..models import Display, DisplaySession @@ -12,6 +15,95 @@ MAX_ACTIVE_SESSIONS_PER_DISPLAY = 2 SESSION_TTL_SECONDS = 90 +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) + 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() + 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. + """ + + playlist = display.assigned_playlist + if not playlist: + raw = "no-playlist" + return None, hashlib.sha1(raw.encode("utf-8")).hexdigest() + + payload = { + "playlist_id": playlist.id, + "items": [ + { + "id": it.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 playlist.items + ], + } + raw = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return playlist.id, hashlib.sha1(raw.encode("utf-8")).hexdigest() + + @bp.get("/display//playlist") def display_playlist(token: str): display = Display.query.filter_by(token=token).first() @@ -20,46 +112,10 @@ def display_playlist(token: str): # 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() + sid = request.args.get("sid") + ok, resp = _enforce_and_touch_display_session(display, sid) + if not ok: + return resp playlist = display.assigned_playlist if not playlist: @@ -86,3 +142,79 @@ def display_playlist(token: str): "items": items, } ) + + +@bp.get("/display//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", + }, + ) diff --git a/app/routes/auth.py b/app/routes/auth.py index e7e1987..0d68a75 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -8,29 +8,26 @@ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from ..extensions import db from ..email_utils import send_email from ..models import User +from ..auth_tokens import load_password_reset_user_id, make_password_reset_token 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) +def _make_reset_token(user: User) -> str: 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}) + return make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], 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 + from flask import current_app + + return load_password_reset_user_id( + secret_key=current_app.config["SECRET_KEY"], + token=token, + max_age_seconds=max_age_seconds, + ) @bp.get("/forgot-password") diff --git a/app/routes/company.py b/app/routes/company.py index 774871d..284ecc5 100644 --- a/app/routes/company.py +++ b/app/routes/company.py @@ -2,14 +2,19 @@ import os import uuid from urllib.parse import urlparse, parse_qs +from datetime import datetime, timedelta + from flask import Blueprint, abort, current_app, flash, jsonify, 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 PIL import Image, ImageOps from ..extensions import db -from ..models import Display, Playlist, PlaylistItem +from ..uploads import abs_upload_path, ensure_company_upload_dir, get_company_upload_bytes, is_valid_upload_relpath +from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User +from ..email_utils import send_email +from ..auth_tokens import make_password_reset_token ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"} @@ -75,35 +80,88 @@ def _normalize_youtube_embed_url(raw: str) -> str | None: return f"https://www.youtube-nocookie.com/embed/{video_id}" -def _save_compressed_image(uploaded_file, upload_folder: str) -> str: +def _center_crop_to_aspect(img: Image.Image, aspect_w: int, aspect_h: int) -> Image.Image: + """Return a center-cropped copy of img to the desired aspect ratio.""" + + w, h = img.size + if w <= 0 or h <= 0: + return img + + target = aspect_w / aspect_h + current = w / h + + # If image is wider than target: crop width; else crop height. + if current > target: + new_w = max(1, int(h * target)) + left = max(0, (w - new_w) // 2) + return img.crop((left, 0, left + new_w, h)) + else: + new_h = max(1, int(w / target)) + top = max(0, (h - new_h) // 2) + return img.crop((0, top, w, top + new_h)) + + +def _save_compressed_image( + uploaded_file, + upload_root: str, + company_id: int | None, + crop_mode: str | None = None, +) -> str: """Save an uploaded image as a compressed WEBP file. + crop_mode: + - "16:9" : center-crop to landscape + - "9:16" : center-crop to portrait + - "none" : no crop + Returns relative file path under /static (e.g. uploads/.webp) """ unique = f"{uuid.uuid4().hex}.webp" - save_path = os.path.join(upload_folder, unique) + company_dir = ensure_company_upload_dir(upload_root, company_id) + save_path = os.path.join(company_dir, unique) + + cm = (crop_mode or "16:9").strip().lower() + if cm not in {"16:9", "9:16", "none"}: + cm = "16:9" img = Image.open(uploaded_file) + # Respect EXIF orientation (common for phone photos) + img = ImageOps.exif_transpose(img) + # Normalize mode for webp if img.mode not in ("RGB", "RGBA"): img = img.convert("RGB") + # Optional crop + if cm == "16:9": + img = _center_crop_to_aspect(img, 16, 9) + max_box = (1920, 1080) + elif cm == "9:16": + img = _center_crop_to_aspect(img, 9, 16) + max_box = (1080, 1920) + else: + # No crop: allow both portrait and landscape up to 1920px on the longest side. + max_box = (1920, 1920) + # Resize down if very large (keeps aspect ratio) - img.thumbnail((1920, 1080)) + img.thumbnail(max_box) img.save(save_path, format="WEBP", quality=80, method=6) - return f"uploads/{unique}" + company_seg = str(int(company_id)) if company_id is not None else "0" + return f"uploads/{company_seg}/{unique}" -def _try_delete_upload(file_path: str | None, upload_folder: str): +def _try_delete_upload(file_path: str | None, upload_root: str): """Best-effort delete of an uploaded media file.""" if not file_path: return - if not file_path.startswith("uploads/"): + if not is_valid_upload_relpath(file_path): + return + + abs_path = abs_upload_path(upload_root, file_path) + if not abs_path: 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) @@ -123,6 +181,138 @@ def company_user_required(): abort(403) +def _format_bytes(num: int) -> str: + num = max(0, int(num or 0)) + units = ["B", "KB", "MB", "GB", "TB"] + size = float(num) + idx = 0 + while size >= 1024.0 and idx < len(units) - 1: + size /= 1024.0 + idx += 1 + if idx == 0: + return f"{int(size)} {units[idx]}" + return f"{size:.1f} {units[idx]}" + + +@bp.get("/my-company") +@login_required +def my_company(): + company_user_required() + + company = db.session.get(Company, current_user.company_id) + if not company: + abort(404) + + # Stats + display_count = Display.query.filter_by(company_id=company.id).count() + playlist_count = Playlist.query.filter_by(company_id=company.id).count() + user_count = User.query.filter_by(company_id=company.id, is_admin=False).count() + + item_count = ( + PlaylistItem.query.join(Playlist, PlaylistItem.playlist_id == Playlist.id) + .filter(Playlist.company_id == company.id) + .count() + ) + + # Active display sessions (best-effort, based on same TTL as /api) + cutoff = datetime.utcnow() - timedelta(seconds=90) + active_sessions = ( + DisplaySession.query.join(Display, DisplaySession.display_id == Display.id) + .filter(Display.company_id == company.id, DisplaySession.last_seen_at >= cutoff) + .count() + ) + + # Storage usage + upload_root = current_app.config["UPLOAD_FOLDER"] + used_bytes = get_company_upload_bytes(upload_root, company.id) + + users = User.query.filter_by(company_id=company.id, is_admin=False).order_by(User.email.asc()).all() + + return render_template( + "company/my_company.html", + company=company, + users=users, + stats={ + "users": user_count, + "displays": display_count, + "playlists": playlist_count, + "items": item_count, + "active_sessions": active_sessions, + "storage_bytes": used_bytes, + "storage_human": _format_bytes(used_bytes), + }, + ) + + +@bp.post("/my-company/invite") +@login_required +def invite_user(): + company_user_required() + + email = (request.form.get("email", "") or "").strip().lower() + if not email: + flash("Email is required", "danger") + return redirect(url_for("company.my_company")) + + if User.query.filter_by(email=email).first(): + flash("Email already exists", "danger") + return redirect(url_for("company.my_company")) + + company = db.session.get(Company, current_user.company_id) + if not company: + abort(404) + + # Create user without password; they must set it via reset link. + u = User(is_admin=False, company=company) + u.email = email + u.username = email # keep backwards-compatible username column in sync + u.password_hash = None + db.session.add(u) + db.session.commit() + + token = make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], user_id=u.id) + reset_url = url_for("auth.reset_password", token=token, _external=True) + body = ( + f"You have been invited to {company.name} on Signage.\n\n" + "Set your password using this link (valid for 30 minutes):\n" + f"{reset_url}\n" + ) + try: + send_email(to_email=u.email, subject=f"Invite: {company.name} (set your password)", body_text=body) + except Exception: + # Roll back created user if we cannot send invite email, to avoid orphan accounts. + db.session.delete(u) + db.session.commit() + flash( + "Failed to send invite email. Please check SMTP configuration (SMTP_* env vars).", + "danger", + ) + return redirect(url_for("company.my_company")) + + flash(f"Invite sent to {email}", "success") + return redirect(url_for("company.my_company")) + + +@bp.post("/my-company/users//delete") +@login_required +def delete_company_user(user_id: int): + company_user_required() + + if int(user_id) == int(current_user.id): + flash("You cannot delete yourself", "danger") + return redirect(url_for("company.my_company")) + + u = db.session.get(User, user_id) + if not u or u.is_admin or u.company_id != current_user.company_id: + abort(404) + + email = u.email + db.session.delete(u) + db.session.commit() + flash(f"User '{email}' deleted", "success") + return redirect(url_for("company.my_company")) + + @bp.get("/") @login_required def dashboard(): @@ -157,6 +347,34 @@ def playlist_detail(playlist_id: int): return render_template("company/playlist_detail.html", playlist=playlist) +@bp.post("/playlists/") +@login_required +def update_playlist(playlist_id: int): + """Update playlist metadata. + + Currently supports renaming the playlist from the playlist detail (edit) page. + """ + + company_user_required() + playlist = db.session.get(Playlist, playlist_id) + if not playlist or playlist.company_id != current_user.company_id: + abort(404) + + name = (request.form.get("name") or "").strip() + if not name: + flash("Playlist name required", "danger") + return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) + + # Keep within DB column limit (String(120)) + if len(name) > 120: + name = name[:120] + + playlist.name = name + db.session.commit() + flash("Playlist renamed", "success") + return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) + + @bp.post("/playlists//delete") @login_required def delete_playlist(playlist_id: int): @@ -283,6 +501,7 @@ def add_playlist_item(playlist_id: int): ext = os.path.splitext(filename)[1].lower() if item_type == "image": + crop_mode = (request.form.get("crop_mode") or "16:9").strip().lower() if ext not in ALLOWED_IMAGE_EXTENSIONS: if wants_json: return _json_error( @@ -291,7 +510,12 @@ def add_playlist_item(playlist_id: int): flash("Unsupported image type", "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) try: - item.file_path = _save_compressed_image(f, current_app.config["UPLOAD_FOLDER"]) + item.file_path = _save_compressed_image( + f, + current_app.config["UPLOAD_FOLDER"], + current_user.company_id, + crop_mode=crop_mode, + ) except Exception: if wants_json: return _json_error("Failed to process image upload", 500) @@ -330,7 +554,8 @@ def add_playlist_item(playlist_id: int): # 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) + company_dir = ensure_company_upload_dir(current_app.config["UPLOAD_FOLDER"], current_user.company_id) + save_path = os.path.join(company_dir, unique) f.save(save_path) # Safety check: validate using the actual saved file size. @@ -351,7 +576,7 @@ def add_playlist_item(playlist_id: int): flash(msg, "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) - item.file_path = f"uploads/{unique}" + item.file_path = f"uploads/{int(current_user.company_id)}/{unique}" elif item_type == "webpage": url = request.form.get("url", "").strip() diff --git a/app/templates/admin/company_detail.html b/app/templates/admin/company_detail.html index 3b8dd54..80cb8d0 100644 --- a/app/templates/admin/company_detail.html +++ b/app/templates/admin/company_detail.html @@ -93,9 +93,20 @@
{{ u.email or "(no email)" }}
-
- -
+
+
+ +
+ +
+ +
+
{% else %}
No users.
diff --git a/app/templates/base.html b/app/templates/base.html index a62fa9f..da8cacd 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -28,6 +28,23 @@