From 32312fe4f2ccda9c26aff8ef92c747c54ee867a0 Mon Sep 17 00:00:00 2001 From: bramval Date: Fri, 23 Jan 2026 13:54:58 +0100 Subject: [PATCH] first commit --- .flaskenv | 9 + .gitignore | 7 + README.md | 87 ++++ app/__init__.py | 78 ++++ app/__main__.py | 6 + app/cli.py | 40 ++ app/email_utils.py | 61 +++ app/extensions.py | 5 + app/models.py | 108 +++++ app/routes/__init__.py | 1 + app/routes/admin.py | 192 ++++++++ app/routes/api.py | 88 ++++ app/routes/auth.py | 209 +++++++++ app/routes/company.py | 254 +++++++++++ app/routes/display.py | 13 + app/static/vendor/cropperjs/cropper.min.css | 9 + app/static/vendor/cropperjs/cropper.min.js | 10 + app/templates/admin/company_detail.html | 95 ++++ app/templates/admin/dashboard.html | 54 +++ app/templates/auth_change_password.html | 37 ++ app/templates/auth_forgot_password.html | 20 + app/templates/auth_login.html | 22 + app/templates/auth_reset_password.html | 27 ++ app/templates/base.html | 54 +++ app/templates/company/dashboard.html | 62 +++ app/templates/company/playlist_detail.html | 467 ++++++++++++++++++++ app/templates/display/player.html | 118 +++++ requirements.txt | 6 + scripts/smoke_test.py | 33 ++ 29 files changed, 2172 insertions(+) create mode 100644 .flaskenv create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/__main__.py create mode 100644 app/cli.py create mode 100644 app/email_utils.py create mode 100644 app/extensions.py create mode 100644 app/models.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/admin.py create mode 100644 app/routes/api.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/company.py create mode 100644 app/routes/display.py create mode 100644 app/static/vendor/cropperjs/cropper.min.css create mode 100644 app/static/vendor/cropperjs/cropper.min.js create mode 100644 app/templates/admin/company_detail.html create mode 100644 app/templates/admin/dashboard.html create mode 100644 app/templates/auth_change_password.html create mode 100644 app/templates/auth_forgot_password.html create mode 100644 app/templates/auth_login.html create mode 100644 app/templates/auth_reset_password.html create mode 100644 app/templates/base.html create mode 100644 app/templates/company/dashboard.html create mode 100644 app/templates/company/playlist_detail.html create mode 100644 app/templates/display/player.html create mode 100644 requirements.txt create mode 100644 scripts/smoke_test.py diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 0000000..8c7a5d0 --- /dev/null +++ b/.flaskenv @@ -0,0 +1,9 @@ +FLASK_APP=app:create_app +FLASK_DEBUG=1 +SMTP_HOST=smtp.strato.de +SMTP_PORT=465 +SMTP_USERNAME=beheer@alphen.cloud +SMTP_PASSWORD=Fr@nkrijk2024! +SMTP_FROM=beheer@alphen.cloud +SMTP_STARTTLS=1 +SMTP_DEBUG=1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a8ea60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.env +.venv/ +venv/ +instance/ +app/static/uploads/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..333beaa --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Flask Digital Signage (simple) + +Lightweight digital signage platform using **Flask + SQLite**. + +## Features + +- Central **admin** can manage companies, users, displays. +- Admin can **impersonate** any company user (no password). +- Company users can: + - Create playlists + - Add slides (image/video/webpage) + - Assign playlists to displays +- Displays are public **16:9 player webpages** suitable for kiosk browsers. + +## Quickstart (Windows) + +```bat +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt + +set FLASK_APP=app +flask init-db --admin-user admin --admin-pass admin +flask run --debug +``` + +If Flask can't discover the app automatically, use: + +```bat +set FLASK_APP=app:create_app +flask run --debug +``` + +Open http://127.0.0.1:5000 + +## Notes + +- SQLite DB is stored at `instance/signage.sqlite`. +- Uploaded files go to `app/static/uploads/`. + +## SMTP / Forgot password + +This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables. + +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`: + +```bat +copy .env.example .env +``` + +### Example + +```bat +REM Option A: set env vars in the same terminal where you run `flask run` +set SMTP_HOST=smtp.strato.de +set SMTP_PORT=587 +set SMTP_USERNAME=beheer@alphen.cloud +set SMTP_PASSWORD=*** +set SMTP_FROM=beheer@alphen.cloud +set SMTP_STARTTLS=1 +set SMTP_DEBUG=1 + +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. + +### Troubleshooting mail delivery + +If the reset email is not received: + +1. Set `SMTP_DEBUG=1` and request a reset again. +2. Watch the Flask console output for SMTP responses / errors. +3. Verify: + - `SMTP_USERNAME` and `SMTP_FROM` are allowed by your provider. + - You are using STARTTLS (port 587). + - The recipient mailbox isn’t filtering it (spam/quarantine). + + + + + + + + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..5820d64 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,78 @@ +import os +from flask import Flask + +from .extensions import db, login_manager +from .models import User +from .cli import init_db_command + + +def create_app(): + app = Flask(__name__, instance_relative_config=True) + + # Basic config + # Flask ships with SECRET_KEY=None by default, so setdefault() won't override it. + app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY") or "dev-secret-change-me" + app.config.setdefault( + "SQLALCHEMY_DATABASE_URI", + os.environ.get("DATABASE_URL", "sqlite:///" + os.path.join(app.instance_path, "signage.sqlite")), + ) + 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 + + os.makedirs(app.instance_path, exist_ok=True) + os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) + + # Init extensions + db.init_app(app) + login_manager.init_app(app) + login_manager.login_view = "auth.login" + + # Lightweight migration(s) for SQLite DBs created before new columns existed. + # This avoids requiring Alembic for this small project. + with app.app_context(): + try: + uri = app.config.get("SQLALCHEMY_DATABASE_URI", "") or "" + if uri.startswith("sqlite:"): + 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)")) + # 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() + except Exception: + db.session.rollback() + + @login_manager.user_loader + def load_user(user_id: str): + return db.session.get(User, int(user_id)) + + # CLI + app.cli.add_command(init_db_command) + + # Blueprints + from .routes.auth import bp as auth_bp + from .routes.admin import bp as admin_bp + from .routes.company import bp as company_bp + from .routes.display import bp as display_bp + from .routes.api import bp as api_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(admin_bp) + app.register_blueprint(company_bp) + app.register_blueprint(display_bp) + app.register_blueprint(api_bp) + + # Home + from flask import redirect, url_for + from flask_login import current_user + + @app.get("/") + def index(): + if not current_user.is_authenticated: + return redirect(url_for("auth.login")) + if current_user.is_admin: + return redirect(url_for("admin.dashboard")) + return redirect(url_for("company.dashboard")) + + return app diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..88a6eed --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,6 @@ +from . import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(debug=True) diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..6edb789 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,40 @@ +import click +from flask.cli import with_appcontext + +from .extensions import db +from .models import User + + +@click.command("init-db") +@click.option("--admin-user", required=True, help="Username 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): + """Create tables and ensure an admin account exists.""" + db.create_all() + + # 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() + 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() + if existing: + if not existing.is_admin: + existing.is_admin = True + existing.set_password(admin_pass) + db.session.commit() + click.echo(f"Updated admin user '{admin_user}'.") + return + + u = User(username=admin_user, is_admin=True) + u.set_password(admin_pass) + db.session.add(u) + db.session.commit() + click.echo(f"Created admin user '{admin_user}'.") diff --git a/app/email_utils.py b/app/email_utils.py new file mode 100644 index 0000000..077da98 --- /dev/null +++ b/app/email_utils.py @@ -0,0 +1,61 @@ +import os +import smtplib +from email.message import EmailMessage + + +def send_email(*, to_email: str, subject: str, body_text: str): + """Send a plain-text email using SMTP settings from environment variables. + + Required env vars: + - SMTP_HOST + - SMTP_PORT + - SMTP_USERNAME + - SMTP_PASSWORD + - SMTP_FROM (defaults to SMTP_USERNAME) + + Optional: + - SMTP_STARTTLS (default: "1") + - SMTP_TIMEOUT_SECONDS (default: "10") + - SMTP_DEBUG (default: "0") - set to 1 to print SMTP conversation to console + """ + + host = os.environ.get("SMTP_HOST") + port = int(os.environ.get("SMTP_PORT", "587")) + username = os.environ.get("SMTP_USERNAME") + password = os.environ.get("SMTP_PASSWORD") + from_email = os.environ.get("SMTP_FROM") or username + starttls = os.environ.get("SMTP_STARTTLS", "1").lower() in ("1", "true", "yes", "on") + timeout = float(os.environ.get("SMTP_TIMEOUT_SECONDS", "10")) + debug = os.environ.get("SMTP_DEBUG", "0").lower() in ("1", "true", "yes", "on") + + missing = [] + if not host: + missing.append("SMTP_HOST") + if not username: + missing.append("SMTP_USERNAME") + if not password: + missing.append("SMTP_PASSWORD") + if not from_email: + missing.append("SMTP_FROM") + if missing: + raise RuntimeError( + "Missing SMTP configuration: " + + ", ".join(missing) + + ". Set them as environment variables (or in a local .env file)." + ) + + msg = EmailMessage() + msg["From"] = from_email + msg["To"] = to_email + msg["Subject"] = subject + msg.set_content(body_text) + + with smtplib.SMTP(host, port, timeout=timeout) as smtp: + if debug: + smtp.set_debuglevel(1) + smtp.ehlo() + if starttls: + smtp.starttls() + smtp.ehlo() + smtp.login(username, password) + smtp.send_message(msg) diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..e0663ee --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,5 @@ +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() +login_manager = LoginManager() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..39973ee --- /dev/null +++ b/app/models.py @@ -0,0 +1,108 @@ +import uuid +from datetime import datetime + +from flask_login import UserMixin +from werkzeug.security import check_password_hash, generate_password_hash + +from .extensions import db + + +class Company(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), unique=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + users = db.relationship("User", 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") + + +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) + password_hash = db.Column(db.String(255), nullable=True) + is_admin = db.Column(db.Boolean, default=False, nullable=False) + + company_id = db.Column(db.Integer, db.ForeignKey("company.id"), nullable=True) + company = db.relationship("Company", back_populates="users") + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + def set_password(self, password: str): + self.password_hash = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + if not self.password_hash: + return False + return check_password_hash(self.password_hash, password) + + +class Playlist(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) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + company = db.relationship("Company", back_populates="playlists") + items = db.relationship( + "PlaylistItem", + back_populates="playlist", + cascade="all, delete-orphan", + order_by="PlaylistItem.position.asc()", + ) + + +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 + item_type = db.Column(db.String(20), nullable=False) + title = db.Column(db.String(200), nullable=True) + + # For image/video: stored under /static/uploads + file_path = db.Column(db.String(400), nullable=True) + # For webpage + url = db.Column(db.String(1000), nullable=True) + + # For image/webpage duration (seconds). For video we play until ended. + duration_seconds = db.Column(db.Integer, default=10, nullable=False) + position = db.Column(db.Integer, default=0, nullable=False) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + playlist = db.relationship("Playlist", back_populates="items") + + +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) + 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 = db.relationship("Playlist") + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + company = db.relationship("Company", back_populates="displays") + + +class DisplaySession(db.Model): + """Tracks active viewers of a display token so we can limit concurrent usage.""" + + id = db.Column(db.Integer, primary_key=True) + display_id = db.Column(db.Integer, db.ForeignKey("display.id"), nullable=False, index=True) + sid = db.Column(db.String(64), nullable=False) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + last_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) + + # Optional diagnostics + ip = db.Column(db.String(64), nullable=True) + user_agent = db.Column(db.String(300), nullable=True) + + display = db.relationship("Display") + + __table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e40064c --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +# Package marker for routes. diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..818a181 --- /dev/null +++ b/app/routes/admin.py @@ -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/") +@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//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//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//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/") +@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//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)) diff --git a/app/routes/api.py b/app/routes/api.py new file mode 100644 index 0000000..91cf6c5 --- /dev/null +++ b/app/routes/api.py @@ -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//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, + } + ) diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..52402e0 --- /dev/null +++ b/app/routes/auth.py @@ -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/") +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/") +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")) diff --git a/app/routes/company.py b/app/routes/company.py new file mode 100644 index 0000000..0b6d35e --- /dev/null +++ b/app/routes/company.py @@ -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/.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/") +@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//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//items/reorder") +@login_required +def reorder_playlist_items(playlist_id: int): + """Persist new ordering for playlist items. + + Expects form data: order=. + """ + 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//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//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//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")) diff --git a/app/routes/display.py b/app/routes/display.py new file mode 100644 index 0000000..2900a42 --- /dev/null +++ b/app/routes/display.py @@ -0,0 +1,13 @@ +from flask import Blueprint, abort, render_template + +from ..models import Display + +bp = Blueprint("display", __name__, url_prefix="/display") + + +@bp.get("/") +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) diff --git a/app/static/vendor/cropperjs/cropper.min.css b/app/static/vendor/cropperjs/cropper.min.css new file mode 100644 index 0000000..8e34d75 --- /dev/null +++ b/app/static/vendor/cropperjs/cropper.min.css @@ -0,0 +1,9 @@ +/*! + * Cropper.js v1.6.2 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2024-04-21T07:43:02.731Z + */.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed} \ No newline at end of file diff --git a/app/static/vendor/cropperjs/cropper.min.js b/app/static/vendor/cropperjs/cropper.min.js new file mode 100644 index 0000000..3102cb5 --- /dev/null +++ b/app/static/vendor/cropperjs/cropper.min.js @@ -0,0 +1,10 @@ +/*! + * Cropper.js v1.6.2 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2024-04-21T07:43:05.335Z + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Cropper=e()}(this,function(){"use strict";function C(e,t){var i,a=Object.keys(e);return Object.getOwnPropertySymbols&&(i=Object.getOwnPropertySymbols(e),t&&(i=i.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),a.push.apply(a,i)),a}function S(a){for(var t=1;tt.length)&&(e=t.length);for(var i=0,a=new Array(e);it.width?3===i?o=t.height*e:h=t.width/e:3===i?h=t.width/e:o=t.height*e,{aspectRatio:e,naturalWidth:n,naturalHeight:a,width:o,height:h});this.canvasData=e,this.limited=1===i||2===i,this.limitCanvas(!0,!0),e.width=Math.min(Math.max(e.width,e.minWidth),e.maxWidth),e.height=Math.min(Math.max(e.height,e.minHeight),e.maxHeight),e.left=(t.width-e.width)/2,e.top=(t.height-e.height)/2,e.oldLeft=e.left,e.oldTop=e.top,this.initialCanvasData=g({},e)},limitCanvas:function(t,e){var i=this.options,a=this.containerData,n=this.canvasData,o=this.cropBoxData,h=i.viewMode,r=n.aspectRatio,s=this.cropped&&o;t&&(t=Number(i.minCanvasWidth)||0,i=Number(i.minCanvasHeight)||0,1=a.width&&(n.minLeft=Math.min(0,r),n.maxLeft=Math.max(0,r)),n.height>=a.height)&&(n.minTop=Math.min(0,t),n.maxTop=Math.max(0,t))):(n.minLeft=-n.width,n.minTop=-n.height,n.maxLeft=a.width,n.maxTop=a.height))},renderCanvas:function(t,e){var i,a,n,o,h=this.canvasData,r=this.imageData;e&&(e={width:r.naturalWidth*Math.abs(r.scaleX||1),height:r.naturalHeight*Math.abs(r.scaleY||1),degree:r.rotate||0},r=e.width,o=e.height,e=e.degree,i=90==(e=Math.abs(e)%180)?{width:o,height:r}:(a=e%90*Math.PI/180,i=Math.sin(a),n=r*(a=Math.cos(a))+o*i,r=r*i+o*a,90h.maxWidth||h.widthh.maxHeight||h.heighte.width?a.height=a.width/i:a.width=a.height*i),this.cropBoxData=a,this.limitCropBox(!0,!0),a.width=Math.min(Math.max(a.width,a.minWidth),a.maxWidth),a.height=Math.min(Math.max(a.height,a.minHeight),a.maxHeight),a.width=Math.max(a.minWidth,a.width*t),a.height=Math.max(a.minHeight,a.height*t),a.left=e.left+(e.width-a.width)/2,a.top=e.top+(e.height-a.height)/2,a.oldLeft=a.left,a.oldTop=a.top,this.initialCropBoxData=g({},a)},limitCropBox:function(t,e){var i,a,n=this.options,o=this.containerData,h=this.canvasData,r=this.cropBoxData,s=this.limited,c=n.aspectRatio;t&&(t=Number(n.minCropBoxWidth)||0,n=Number(n.minCropBoxHeight)||0,i=s?Math.min(o.width,h.width,h.width+h.left,o.width-h.left):o.width,a=s?Math.min(o.height,h.height,h.height+h.top,o.height-h.top):o.height,t=Math.min(t,o.width),n=Math.min(n,o.height),c&&(t&&n?ti.maxWidth||i.widthi.maxHeight||i.height=e.width&&i.height>=e.height?q:I),f(this.cropBox,g({width:i.width,height:i.height},x({translateX:i.left,translateY:i.top}))),this.cropped&&this.limited&&this.limitCanvas(!0,!0),this.disabled||this.output()},output:function(){this.preview(),y(this.element,tt,this.getData())}},i={initPreview:function(){var t=this.element,i=this.crossOrigin,e=this.options.preview,a=i?this.crossOriginUrl:this.url,n=t.alt||"The image to preview",o=document.createElement("img");i&&(o.crossOrigin=i),o.src=a,o.alt=n,this.viewBox.appendChild(o),this.viewBoxImage=o,e&&("string"==typeof(o=e)?o=t.ownerDocument.querySelectorAll(e):e.querySelector&&(o=[e]),z(this.previews=o,function(t){var e=document.createElement("img");w(t,m,{width:t.offsetWidth,height:t.offsetHeight,html:t.innerHTML}),i&&(e.crossOrigin=i),e.src=a,e.alt=n,e.style.cssText='display:block;width:100%;height:auto;min-width:0!important;min-height:0!important;max-width:none!important;max-height:none!important;image-orientation:0deg!important;"',t.innerHTML="",t.appendChild(e)}))},resetPreview:function(){z(this.previews,function(e){var i=Bt(e,m),i=(f(e,{width:i.width,height:i.height}),e.innerHTML=i.html,e),e=m;if(o(i[e]))try{delete i[e]}catch(t){i[e]=void 0}else if(i.dataset)try{delete i.dataset[e]}catch(t){i.dataset[e]=void 0}else i.removeAttribute("data-".concat(Dt(e)))})},preview:function(){var h=this.imageData,t=this.canvasData,e=this.cropBoxData,r=e.width,s=e.height,c=h.width,d=h.height,l=e.left-t.left-h.left,p=e.top-t.top-h.top;this.cropped&&!this.disabled&&(f(this.viewBoxImage,g({width:c,height:d},x(g({translateX:-l,translateY:-p},h)))),z(this.previews,function(t){var e=Bt(t,m),i=e.width,e=e.height,a=i,n=e,o=1;r&&(n=s*(o=i/r)),s&&eMath.abs(a-1)?i:a)&&(t.restore&&(o=this.getCanvasData(),h=this.getCropBoxData()),this.render(),t.restore)&&(this.setCanvasData(z(o,function(t,e){o[e]=t*n})),this.setCropBoxData(z(h,function(t,e){h[e]=t*n}))))},dblclick:function(){var t,e;this.disabled||this.options.dragMode===_||this.setDragMode((t=this.dragBox,e=Q,(t.classList?t.classList.contains(e):-1y&&(D.x=y-f);break;case k:p+D.xx&&(D.y=x-v)}}var i,a,o,n=this.options,h=this.canvasData,r=this.containerData,s=this.cropBoxData,c=this.pointers,d=this.action,l=n.aspectRatio,p=s.left,m=s.top,u=s.width,g=s.height,f=p+u,v=m+g,w=0,b=0,y=r.width,x=r.height,M=!0,C=(!l&&t.shiftKey&&(l=u&&g?u/g:1),this.limited&&(w=s.minLeft,b=s.minTop,y=w+Math.min(r.width,h.width,h.left+h.width),x=b+Math.min(r.height,h.height,h.top+h.height)),c[Object.keys(c)[0]]),D={x:C.endX-C.startX,y:C.endY-C.startY};switch(d){case I:p+=D.x,m+=D.y;break;case B:0<=D.x&&(y<=f||l&&(m<=b||x<=v))?M=!1:(e(B),(u+=D.x)<0&&(d=k,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case T:D.y<=0&&(m<=b||l&&(p<=w||y<=f))?M=!1:(e(T),g-=D.y,m+=D.y,g<0&&(d=O,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case k:D.x<=0&&(p<=w||l&&(m<=b||x<=v))?M=!1:(e(k),u-=D.x,p+=D.x,u<0&&(d=B,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case O:0<=D.y&&(x<=v||l&&(p<=w||y<=f))?M=!1:(e(O),(g+=D.y)<0&&(d=T,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case E:if(l){if(D.y<=0&&(m<=b||y<=f)){M=!1;break}e(T),g-=D.y,m+=D.y,u=g*l}else e(T),e(B),!(0<=D.x)||fMath.abs(o)&&(o=i)})}),o),t),M=!1;break;case U:D.x&&D.y?(i=Wt(this.cropper),p=C.startX-i.left,m=C.startY-i.top,u=s.minWidth,g=s.minHeight,0 or element.");this.element=t,this.options=g({},ut,u(e)&&e),this.cropped=!1,this.disabled=!1,this.pointers={},this.ready=!1,this.reloading=!1,this.replaced=!1,this.sized=!1,this.sizing=!1,this.init()}return t=n,i=[{key:"noConflict",value:function(){return window.Cropper=Pt,n}},{key:"setDefaults",value:function(t){g(ut,u(t)&&t)}}],(e=[{key:"init",value:function(){var t,e=this.element,i=e.tagName.toLowerCase();if(!e[c]){if(e[c]=this,"img"===i){if(this.isImg=!0,t=e.getAttribute("src")||"",!(this.originalUrl=t))return;t=e.src}else"canvas"===i&&window.HTMLCanvasElement&&(t=e.toDataURL());this.load(t)}}},{key:"load",value:function(t){var e,i,a,n,o,h,r=this;t&&(this.url=t,this.imageData={},e=this.element,(i=this.options).rotatable||i.scalable||(i.checkOrientation=!1),i.checkOrientation&&window.ArrayBuffer?lt.test(t)?pt.test(t)?this.read((h=(h=t).replace(Xt,""),a=atob(h),h=new ArrayBuffer(a.length),z(n=new Uint8Array(h),function(t,e){n[e]=a.charCodeAt(e)}),h)):this.clone():(o=new XMLHttpRequest,h=this.clone.bind(this),this.reloading=!0,(this.xhr=o).onabort=h,o.onerror=h,o.ontimeout=h,o.onprogress=function(){o.getResponseHeader("content-type")!==ct&&o.abort()},o.onload=function(){r.read(o.response)},o.onloadend=function(){r.reloading=!1,r.xhr=null},i.checkCrossOrigin&&Lt(t)&&e.crossOrigin&&(t=zt(t)),o.open("GET",t,!0),o.responseType="arraybuffer",o.withCredentials="use-credentials"===e.crossOrigin,o.send()):this.clone())}},{key:"read",value:function(t){var e=this.options,i=this.imageData,a=Rt(t),n=0,o=1,h=1;1
',o=(n=n.querySelector(".".concat(c,"-container"))).querySelector(".".concat(c,"-canvas")),h=n.querySelector(".".concat(c,"-drag-box")),s=(r=n.querySelector(".".concat(c,"-crop-box"))).querySelector(".".concat(c,"-face")),this.container=a,this.cropper=n,this.canvas=o,this.dragBox=h,this.cropBox=r,this.viewBox=n.querySelector(".".concat(c,"-view-box")),this.face=s,o.appendChild(i),v(t,L),a.insertBefore(n,t.nextSibling),X(i,Z),this.initPreview(),this.bind(),e.initialAspectRatio=Math.max(0,e.initialAspectRatio)||NaN,e.aspectRatio=Math.max(0,e.aspectRatio)||NaN,e.viewMode=Math.max(0,Math.min(3,Math.round(e.viewMode)))||0,v(r,L),e.guides||v(r.getElementsByClassName("".concat(c,"-dashed")),L),e.center||v(r.getElementsByClassName("".concat(c,"-center")),L),e.background&&v(n,"".concat(c,"-bg")),e.highlight||v(s,G),e.cropBoxMovable&&(v(s,V),w(s,d,I)),e.cropBoxResizable||(v(r.getElementsByClassName("".concat(c,"-line")),L),v(r.getElementsByClassName("".concat(c,"-point")),L)),this.render(),this.ready=!0,this.setDragMode(e.dragMode),e.autoCrop&&this.crop(),this.setData(e.data),l(e.ready)&&b(t,"ready",e.ready,{once:!0}),y(t,"ready"))}},{key:"unbuild",value:function(){var t;this.ready&&(this.ready=!1,this.unbind(),this.resetPreview(),(t=this.cropper.parentNode)&&t.removeChild(this.cropper),X(this.element,L))}},{key:"uncreate",value:function(){this.ready?(this.unbuild(),this.ready=!1,this.cropped=!1):this.sizing?(this.sizingImage.onload=null,this.sizing=!1,this.sized=!1):this.reloading?(this.xhr.onabort=null,this.xhr.abort()):this.image&&this.stop()}}])&&A(t.prototype,e),i&&A(t,i),Object.defineProperty(t,"prototype",{writable:!1}),t;var t,e,i}();return g(It.prototype,t,i,e,St,jt,At),It}); \ No newline at end of file diff --git a/app/templates/admin/company_detail.html b/app/templates/admin/company_detail.html new file mode 100644 index 0000000..9491bed --- /dev/null +++ b/app/templates/admin/company_detail.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} +{% block content %} +
+

Company: {{ company.name }}

+
+ Back +
+
+ +
+
+

Danger zone

+

+ Deleting a company will permanently remove all its users, displays, playlists and uploaded media. +

+
+ +
+
+
+ +
+
+

Users

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ {% for u in company.users %} +
+
+ {{ u.username }} +
{{ u.email or "(no email set)" }}
+
+
+
+ + +
+
+ +
+
+
+ {% else %} +
No users.
+ {% endfor %} +
+
+ +
+

Displays

+
+
+ + +
+
+ +
+ {% for d in company.displays %} +
+
+
+ {{ d.name }} +
Token: {{ d.token }}
+ +
+
Assigned: {{ d.assigned_playlist.name if d.assigned_playlist else "(none)" }}
+
+
+ {% else %} +
No displays.
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..9c168ef --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block content %} +
+

Admin dashboard

+
+ +
+
+

Companies

+
+
+ + +
+
+ + +
+ +
+

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_change_password.html b/app/templates/auth_change_password.html new file mode 100644 index 0000000..a7b8334 --- /dev/null +++ b/app/templates/auth_change_password.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

Change password

+
+
+
+
+ + +
+ +
+ + +
Minimum 8 characters.
+
+ +
+ + +
+ +
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/auth_forgot_password.html b/app/templates/auth_forgot_password.html new file mode 100644 index 0000000..02089ee --- /dev/null +++ b/app/templates/auth_forgot_password.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Forgot password

+
+

Enter your email address and we’ll send you a password reset link.

+
+ + +
+
+ + Back to login +
+
+
+
+{% endblock %} diff --git a/app/templates/auth_login.html b/app/templates/auth_login.html new file mode 100644 index 0000000..4f17df3 --- /dev/null +++ b/app/templates/auth_login.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Login

+
+
+ + +
+
+ + +
+ + +
+
+
+{% endblock %} diff --git a/app/templates/auth_reset_password.html b/app/templates/auth_reset_password.html new file mode 100644 index 0000000..e4dc043 --- /dev/null +++ b/app/templates/auth_reset_password.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Reset password

+ + {% if token_error %} +
{{ token_error }}
+ Request a new reset link + {% else %} +
+
+ + +
Minimum 8 characters.
+
+
+ + +
+ +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..9b59d0d --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,54 @@ + + + + + + {{ title or "Signage" }} + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/app/templates/company/dashboard.html b/app/templates/company/dashboard.html new file mode 100644 index 0000000..837bf9e --- /dev/null +++ b/app/templates/company/dashboard.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block content %} +

Company dashboard

+ +
+
+

Playlists

+
+
+ + +
+
+ +
+ {% for p in playlists %} + + {% else %} +
No playlists yet.
+ {% endfor %} +
+
+ +
+

Displays

+
+ {% for d in displays %} +
+
+
+
{{ d.name }}
+
Player URL: open
+
+
+
+ + +
+
+
+
+ {% else %} +
No displays. Ask admin to add displays.
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/app/templates/company/playlist_detail.html b/app/templates/company/playlist_detail.html new file mode 100644 index 0000000..78a6ae4 --- /dev/null +++ b/app/templates/company/playlist_detail.html @@ -0,0 +1,467 @@ +{% extends "base.html" %} +{% block content %} + {# Cropper.js (used for image cropping) #} + +
+

Playlist: {{ playlist.name }}

+
+
+ +
+ Back +
+
+ +
+
+

Add item

+
+ + +
+ +
+ + + + + + + + +
+ +
+ +
+ + +
+ +
+ + +
+ + {# Image section #} +
+ +
+
Drag & drop an image here
+
or click to select a file
+
+ + +
+
Crop to 16:9 (recommended for display screens).
+
+ Crop +
+
+ +
+
+
+
+ + {# Webpage section #} +
+
+ + +
Preview might not work for all sites (some block embedding).
+
+ +
+
+
Preview
+ Open +
+
+ +
+
+
+ + {# Video section #} +
+
+ In production: video support is currently being worked on. +
+
+ + +
+
+ +
+

Items

+
Tip: drag items to reorder. Changes save automatically.
+
+ {% for i in playlist.items %} +
+
+
+
+
+
+ #{{ i.position }} + {{ i.item_type }} + {{ i.title or '' }} +
+
+ +
+
+ +
+ {% if i.item_type in ['image','video'] %} + File: {{ i.file_path }} + {% else %} + URL: {{ i.url }} + {% endif %} + · Duration: {{ i.duration_seconds }}s +
+ +
+ {% if i.item_type == 'image' and i.file_path %} + {{ i.title or 'image' }} + {% elif i.item_type == 'video' and i.file_path %} + + {% elif i.item_type == 'webpage' and i.url %} +
+ Open URL + (opens in new tab) +
+ + {% else %} +
No preview available.
+ {% endif %} +
+
+
+
+ {% else %} +
No items.
+ {% endfor %} +
+ + {# Load Cropper.js BEFORE our inline script so window.Cropper is available #} + + + +
+
+{% endblock %} diff --git a/app/templates/display/player.html b/app/templates/display/player.html new file mode 100644 index 0000000..ef05861 --- /dev/null +++ b/app/templates/display/player.html @@ -0,0 +1,118 @@ + + + + + + {{ display.name }} + + + +
+
+ + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..36cb5ab --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.0.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.1 +python-dotenv==1.0.0 +Pillow==11.3.0 diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py new file mode 100644 index 0000000..535a8ce --- /dev/null +++ b/scripts/smoke_test.py @@ -0,0 +1,33 @@ +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 + + +def main(): + app = create_app() + rules = sorted({str(r) for r in app.url_map.iter_rules()}) + print("App created:", app.name) + print("Routes:") + for r in rules: + print(" -", r) + + required = { + "/admin/companies//delete", + "/auth/change-password", + "/auth/forgot-password", + "/auth/reset-password/", + } + missing = sorted(required.difference(rules)) + if missing: + raise SystemExit(f"Missing expected routes: {missing}") + + +if __name__ == "__main__": + main()