diff --git a/README.md b/README.md index 333beaa..1a11047 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ python -m venv .venv pip install -r requirements.txt set FLASK_APP=app -flask init-db --admin-user admin --admin-pass admin +flask init-db --admin-email beheer@alphen.cloud --admin-pass admin flask run --debug ``` @@ -85,3 +85,6 @@ If the reset email is not received: + + + diff --git a/app/__init__.py b/app/__init__.py index 5820d64..4fbc87f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,6 @@ import os -from flask import Flask +from flask import Flask, jsonify, request +from werkzeug.exceptions import RequestEntityTooLarge from .extensions import db, login_manager from .models import User @@ -18,7 +19,14 @@ def create_app(): ) app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False) app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads")) - app.config.setdefault("MAX_CONTENT_LENGTH", 500 * 1024 * 1024) # 500MB + + # NOTE: Videos should be max 250MB. + # 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 + # blocking excessively large uploads early. + app.config.setdefault("MAX_CONTENT_LENGTH", 260 * 1024 * 1024) # ~260MB request cap + + # Explicit per-video validation lives in the upload route; this app-wide cap is a safety net. os.makedirs(app.instance_path, exist_ok=True) os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) @@ -40,6 +48,14 @@ def create_app(): # Best-effort unique index (SQLite doesn't support adding unique constraints after the fact). db.session.execute(db.text("CREATE UNIQUE INDEX IF NOT EXISTS ix_user_email ON user (email)")) db.session.commit() + + # Displays: ensure optional description column exists. + 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: db.session.rollback() @@ -75,4 +91,25 @@ def create_app(): return redirect(url_for("admin.dashboard")) return redirect(url_for("company.dashboard")) + @app.errorhandler(RequestEntityTooLarge) + def handle_request_too_large(e): + """Return a user-friendly message when uploads exceed MAX_CONTENT_LENGTH.""" + # Keep behavior consistent with our AJAX endpoints. + wants_json = ( + (request.headers.get("X-Requested-With") == "XMLHttpRequest") + or ("application/json" in (request.headers.get("Accept") or "")) + or request.is_json + or (request.form.get("response") == "json") + ) + + msg = "Upload too large. Videos must be 250MB or smaller." + if wants_json: + return jsonify({"ok": False, "error": msg}), 413 + + # For non-AJAX form posts, redirect back with a flash message. + from flask import flash, redirect + + flash(msg, "danger") + return redirect(request.referrer or url_for("company.dashboard")), 413 + return app diff --git a/app/cli.py b/app/cli.py index 6edb789..d57495b 100644 --- a/app/cli.py +++ b/app/cli.py @@ -6,10 +6,16 @@ from .models import User @click.command("init-db") -@click.option("--admin-user", required=True, help="Username for the initial admin") +@click.option( + "--admin-email", + required=False, + default="beheer@alphen.cloud", + show_default=True, + help="Email for the initial admin", +) @click.option("--admin-pass", required=True, help="Password for the initial admin") @with_appcontext -def init_db_command(admin_user: str, admin_pass: str): +def init_db_command(admin_email: str, admin_pass: str): """Create tables and ensure an admin account exists.""" db.create_all() @@ -20,21 +26,34 @@ def init_db_command(admin_user: str, admin_pass: str): 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() - existing = User.query.filter_by(username=admin_user).first() + admin_email = (admin_email or "").strip().lower() + if not admin_email: + raise click.UsageError("--admin-email is required") + + existing = User.query.filter_by(email=admin_email).first() if existing: if not existing.is_admin: existing.is_admin = True + existing.email = admin_email + existing.username = admin_email existing.set_password(admin_pass) db.session.commit() - click.echo(f"Updated admin user '{admin_user}'.") + click.echo(f"Updated admin user '{admin_email}'.") return - u = User(username=admin_user, is_admin=True) + u = User(is_admin=True) + u.email = admin_email + u.username = admin_email u.set_password(admin_pass) db.session.add(u) db.session.commit() - click.echo(f"Created admin user '{admin_user}'.") + click.echo(f"Created admin user '{admin_email}'.") diff --git a/app/models.py b/app/models.py index 39973ee..a94aaba 100644 --- a/app/models.py +++ b/app/models.py @@ -19,8 +19,11 @@ class Company(db.Model): class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(80), unique=True, nullable=False) - email = db.Column(db.String(255), unique=True, nullable=True) + # Backwards compatibility: older SQLite DBs (and some templates) expect a username column. + # The app no longer uses username for login/display, but we keep it populated (= email) + # to avoid integrity errors without introducing Alembic migrations. + username = db.Column(db.String(255), unique=True, nullable=False) + email = db.Column(db.String(255), unique=True, nullable=False) password_hash = db.Column(db.String(255), nullable=True) is_admin = db.Column(db.Boolean, default=False, nullable=False) @@ -57,7 +60,7 @@ class PlaylistItem(db.Model): id = db.Column(db.Integer, primary_key=True) playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=False) - # image|video|webpage + # image|video|webpage|youtube item_type = db.Column(db.String(20), nullable=False) title = db.Column(db.String(200), nullable=True) @@ -79,6 +82,8 @@ class Display(db.Model): id = db.Column(db.Integer, primary_key=True) company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=False) name = db.Column(db.String(120), nullable=False) + # Optional short description (e.g. "entrance", "office") + description = db.Column(db.String(200), nullable=True) 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) diff --git a/app/routes/admin.py b/app/routes/admin.py index 818a181..bef9a2a 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -38,8 +38,7 @@ def _try_delete_upload(file_path: str | None, upload_folder: str): 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) + return render_template("admin/dashboard.html", companies=companies) @bp.post("/companies") @@ -77,22 +76,19 @@ def create_company_user(company_id: int): 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") + if not email or not password: + flash("Email and password required", "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 = User(is_admin=False, company=company) u.email = email + u.username = email u.set_password(password) db.session.add(u) db.session.commit() @@ -167,7 +163,7 @@ def impersonate(user_id: int): # 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") + flash(f"Impersonating {target.email or '(no email)'}.", "warning") return redirect(url_for("company.dashboard")) @@ -179,14 +175,40 @@ def update_user_email(user_id: int): 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)) + email = (request.form.get("email", "") or "").strip().lower() + if not email: + flash("Email is required", "danger") + return redirect(url_for("admin.company_detail", company_id=u.company_id)) + + 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 + # keep backwards-compatible username column in sync + u.username = email db.session.commit() flash("Email updated", "success") return redirect(url_for("admin.company_detail", company_id=u.company_id)) + + +@bp.post("/displays//name") +@login_required +def update_display_name(display_id: int): + """Admin: rename a display.""" + admin_required() + + display = db.session.get(Display, display_id) + if not display: + abort(404) + + name = (request.form.get("name") or "").strip() + if not name: + flash("Display name is required", "danger") + return redirect(url_for("admin.company_detail", company_id=display.company_id)) + + display.name = name[:120] + db.session.commit() + flash("Display name updated", "success") + return redirect(url_for("admin.company_detail", company_id=display.company_id)) diff --git a/app/routes/api.py b/app/routes/api.py index 91cf6c5..e1fde05 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -75,7 +75,7 @@ def display_playlist(token: str): } 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": + if item.item_type in ("webpage", "youtube"): payload["url"] = item.url items.append(payload) diff --git a/app/routes/auth.py b/app/routes/auth.py index 52402e0..e7e1987 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -173,12 +173,12 @@ def login(): @bp.post("/login") def login_post(): - username = request.form.get("username", "").strip() + email = (request.form.get("email", "") or "").strip().lower() password = request.form.get("password", "") - user = User.query.filter_by(username=username).first() + user = User.query.filter_by(email=email).first() if not user or not user.check_password(password): - flash("Invalid username/password", "danger") + flash("Invalid email/password", "danger") return redirect(url_for("auth.login")) # clear impersonation marker, if any diff --git a/app/routes/company.py b/app/routes/company.py index 0b6d35e..673673e 100644 --- a/app/routes/company.py +++ b/app/routes/company.py @@ -1,7 +1,8 @@ import os import uuid +from urllib.parse import urlparse, parse_qs -from flask import Blueprint, abort, current_app, flash, redirect, render_template, request, url_for +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 @@ -11,6 +12,69 @@ from ..extensions import db from ..models import Display, Playlist, PlaylistItem +ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"} +ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"} + +# Videos should have a maximum upload size of 250MB +MAX_VIDEO_BYTES = 250 * 1024 * 1024 + + +def _normalize_youtube_embed_url(raw: str) -> str | None: + """Normalize a user-provided YouTube URL into a privacy-friendly embed base URL. + + Returns: + https://www.youtube-nocookie.com/embed/ + or None if we cannot parse a valid video id. + """ + + val = (raw or "").strip() + if not val: + return None + + # Be forgiving for inputs like "youtu.be/". + if not val.startswith("http://") and not val.startswith("https://"): + val = "https://" + val + + try: + u = urlparse(val) + except Exception: + return None + + host = (u.netloc or "").lower() + host = host[4:] if host.startswith("www.") else host + + video_id: str | None = None + path = (u.path or "").strip("/") + + if host in {"youtube.com", "m.youtube.com"}: + # /watch?v= + if path == "watch": + v = (parse_qs(u.query).get("v") or [None])[0] + video_id = v + # /embed/ + elif path.startswith("embed/"): + video_id = path.split("/", 1)[1] + # /shorts/ + elif path.startswith("shorts/"): + video_id = path.split("/", 1)[1] + elif host == "youtu.be": + # / + if path: + video_id = path.split("/", 1)[0] + + # Basic validation: YouTube IDs are typically 11 chars (letters/digits/_/-) + if not video_id: + return None + video_id = video_id.strip() + if len(video_id) != 11: + return None + for ch in video_id: + if not (ch.isalnum() or ch in {"_", "-"}): + return None + + return f"https://www.youtube-nocookie.com/embed/{video_id}" + + def _save_compressed_image(uploaded_file, upload_folder: str) -> str: """Save an uploaded image as a compressed WEBP file. @@ -129,7 +193,10 @@ def reorder_playlist_items(playlist_id: int): if not playlist or playlist.company_id != current_user.company_id: abort(404) + # Accept both form and JSON payloads. order = (request.form.get("order") or "").strip() + if not order and request.is_json: + order = ((request.get_json(silent=True) or {}).get("order") or "").strip() if not order: abort(400) @@ -150,6 +217,16 @@ def reorder_playlist_items(playlist_id: int): id_to_item[item_id].position = pos db.session.commit() + + # Client currently doesn't require JSON, but returning JSON if requested + # helps debugging and future enhancements. + wants_json = ( + (request.headers.get("X-Requested-With") == "XMLHttpRequest") + or ("application/json" in (request.headers.get("Accept") or "")) + or request.is_json + ) + if wants_json: + return jsonify({"ok": True}) return ("", 204) @@ -161,9 +238,25 @@ def add_playlist_item(playlist_id: int): if not playlist or playlist.company_id != current_user.company_id: abort(404) - item_type = request.form.get("item_type") + # Support AJAX/modal usage: return JSON when requested. + wants_json = ( + (request.headers.get("X-Requested-With") == "XMLHttpRequest") + or ("application/json" in (request.headers.get("Accept") or "")) + or (request.form.get("response") == "json") + ) + + def _json_error(message: str, status: int = 400): + return jsonify({"ok": False, "error": message}), status + + item_type = (request.form.get("item_type") or "").strip().lower() title = request.form.get("title", "").strip() or None - duration = int(request.form.get("duration_seconds") or 10) + + # Duration is only used for image/webpage. Video/YouTube plays until ended. + raw_duration = request.form.get("duration_seconds") + try: + duration = int(raw_duration) if raw_duration is not None else 10 + except (TypeError, ValueError): + duration = 10 max_pos = ( db.session.query(db.func.max(PlaylistItem.position)).filter_by(playlist_id=playlist_id).scalar() or 0 @@ -181,37 +274,137 @@ def add_playlist_item(playlist_id: int): if item_type in ("image", "video"): f = request.files.get("file") if not f or not f.filename: + if wants_json: + return _json_error("File required") 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"): + if item_type == "image": + if ext not in ALLOWED_IMAGE_EXTENSIONS: + if wants_json: + return _json_error( + "Unsupported image type. Please upload one of: " + ", ".join(sorted(ALLOWED_IMAGE_EXTENSIONS)) + ) + 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"]) except Exception: + if wants_json: + return _json_error("Failed to process image upload", 500) 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 + if ext not in ALLOWED_VIDEO_EXTENSIONS: + if wants_json: + return _json_error( + "Unsupported video type. Please upload one of: " + ", ".join(sorted(ALLOWED_VIDEO_EXTENSIONS)) + ) + flash("Unsupported video type", "danger") + return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) + + # Enforce video size limit (250MB) with a clear error message. + # This is separate from Flask's MAX_CONTENT_LENGTH, which caps the full request. + size = None + try: + size = getattr(f, "content_length", None) + # Werkzeug may report 0 for unknown per-part length. + if (size is None or size <= 0) and hasattr(f, "stream"): + # Measure by seeking in the file-like stream. + pos = f.stream.tell() + f.stream.seek(0, os.SEEK_END) + size = f.stream.tell() + f.stream.seek(pos, os.SEEK_SET) + except Exception: + size = None + + if size is not None and size > MAX_VIDEO_BYTES: + msg = "Video file too large. Maximum allowed size is 250MB." + if wants_json: + return _json_error(msg, 413) + flash(msg, "danger") + return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) + + # 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) + + # Safety check: validate using the actual saved file size. + # (Some clients/framework layers don't reliably report per-part size.) + try: + saved_size = os.path.getsize(save_path) + except OSError: + saved_size = None + + if saved_size is not None and saved_size > MAX_VIDEO_BYTES: + try: + os.remove(save_path) + except OSError: + pass + msg = "Video file too large. Maximum allowed size is 250MB." + if wants_json: + return _json_error(msg, 413) + flash(msg, "danger") + return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) + item.file_path = f"uploads/{unique}" elif item_type == "webpage": url = request.form.get("url", "").strip() if not url: + if wants_json: + return _json_error("URL required") flash("URL required", "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) item.url = url + + elif item_type == "youtube": + raw = request.form.get("url", "").strip() + if not raw: + if wants_json: + return _json_error("YouTube URL required") + flash("YouTube URL required", "danger") + return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) + + embed_url = _normalize_youtube_embed_url(raw) + if not embed_url: + if wants_json: + return _json_error("Invalid YouTube URL") + flash("Invalid YouTube URL", "danger") + return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) + + item.url = embed_url + else: + if wants_json: + return _json_error("Invalid item type") flash("Invalid item type", "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) db.session.add(item) db.session.commit() + + if wants_json: + return jsonify( + { + "ok": True, + "item": { + "id": item.id, + "playlist_id": item.playlist_id, + "position": item.position, + "item_type": item.item_type, + "title": item.title, + "file_path": item.file_path, + "url": item.url, + "duration_seconds": item.duration_seconds, + }, + } + ) + flash("Item added", "success") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) @@ -234,6 +427,52 @@ def delete_item(item_id: int): return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) +@bp.post("/items//duration") +@login_required +def update_item_duration(item_id: int): + """Update duration_seconds for a playlist item. + + Used from the playlist overview (inline edit). + """ + + company_user_required() + + item = db.session.get(PlaylistItem, item_id) + if not item or item.playlist.company_id != current_user.company_id: + abort(404) + + # Duration only applies to images/webpages; videos play until ended. + if item.item_type == "video": + return jsonify({"ok": False, "error": "Duration cannot be set for video items"}), 400 + + wants_json = ( + (request.headers.get("X-Requested-With") == "XMLHttpRequest") + or ("application/json" in (request.headers.get("Accept") or "")) + or request.is_json + ) + + def _json_error(message: str, status: int = 400): + return jsonify({"ok": False, "error": message}), status + + raw = request.form.get("duration_seconds") + if raw is None and request.is_json: + raw = (request.get_json(silent=True) or {}).get("duration_seconds") + + try: + duration = int(raw) + except (TypeError, ValueError): + if wants_json: + return _json_error("Invalid duration") + abort(400) + + item.duration_seconds = max(1, duration) + db.session.commit() + + if wants_json: + return jsonify({"ok": True, "duration_seconds": item.duration_seconds}) + return ("", 204) + + @bp.post("/displays//assign") @login_required def assign_playlist(display_id: int): @@ -252,3 +491,42 @@ def assign_playlist(display_id: int): db.session.commit() flash("Display assignment updated", "success") return redirect(url_for("company.dashboard")) + + +@bp.post("/displays/") +@login_required +def update_display(display_id: int): + """Update display metadata (description + assigned playlist). + + Company users should be able to set a short description per display and assign a playlist. + """ + + company_user_required() + + display = db.session.get(Display, display_id) + if not display or display.company_id != current_user.company_id: + abort(404) + + # Description (short, optional) + desc = (request.form.get("description") or "").strip() or None + if desc is not None: + desc = desc[:200] + display.description = desc + + # Playlist assignment + playlist_id = (request.form.get("playlist_id") or "").strip() + if not playlist_id: + display.assigned_playlist_id = None + else: + try: + playlist_id_int = int(playlist_id) + except ValueError: + abort(400) + playlist = db.session.get(Playlist, playlist_id_int) + if not playlist or playlist.company_id != current_user.company_id: + abort(400) + display.assigned_playlist_id = playlist.id + + db.session.commit() + flash("Display updated", "success") + return redirect(url_for("company.dashboard")) diff --git a/app/templates/admin/company_detail.html b/app/templates/admin/company_detail.html index 9491bed..74e8538 100644 --- a/app/templates/admin/company_detail.html +++ b/app/templates/admin/company_detail.html @@ -27,10 +27,6 @@

Users

-
- - -
@@ -46,7 +42,7 @@ {% for u in company.users %}
- {{ u.username }} + {{ u.email or "(no email)" }}
{{ u.email or "(no email set)" }}
@@ -79,7 +75,17 @@
- {{ d.name }} + + + +
Token: {{ d.token }}
diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index 9c168ef..8137d2d 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -5,7 +5,7 @@
-
+

Companies

@@ -25,30 +25,5 @@ {% endfor %}
- -
-

Users

-
- {% for u in users %} -
-
-
{{ u.username }} {% if u.is_admin %}admin{% endif %}
-
- {% if u.company %}Company: {{ u.company.name }}{% else %}No company{% endif %} -
-
-
- {% if not u.is_admin %} - - - - {% endif %} -
-
- {% else %} -
No users yet.
- {% endfor %} -
-
{% endblock %} diff --git a/app/templates/auth_login.html b/app/templates/auth_login.html index 4f17df3..d631679 100644 --- a/app/templates/auth_login.html +++ b/app/templates/auth_login.html @@ -5,8 +5,8 @@

Login

- - + +
diff --git a/app/templates/base.html b/app/templates/base.html index 9b59d0d..0e7e40c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -24,7 +24,7 @@