Add company dashboard improvements and upload/auth features
This commit is contained in:
16
README.md
16
README.md
@@ -38,6 +38,20 @@ Open http://127.0.0.1:5000
|
|||||||
- SQLite DB is stored at `instance/signage.sqlite`.
|
- SQLite DB is stored at `instance/signage.sqlite`.
|
||||||
- Uploaded files go to `app/static/uploads/`.
|
- Uploaded files go to `app/static/uploads/`.
|
||||||
|
|
||||||
|
## Display player
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
- `http://<host>/display/<token>` for live playback (counts towards the concurrent display limit)
|
||||||
|
- `http://<host>/display/<token>?preview=1` for preview (does not count towards the concurrent display limit)
|
||||||
|
|
||||||
|
### Live updates
|
||||||
|
|
||||||
|
The player keeps itself up-to-date automatically:
|
||||||
|
|
||||||
|
- It listens to `GET /api/display/<token>/events` (Server-Sent Events) and reloads the playlist immediately when it changes.
|
||||||
|
- It also does a fallback playlist refresh every 5 minutes for networks/proxies that block SSE.
|
||||||
|
|
||||||
## SMTP / Forgot password
|
## SMTP / Forgot password
|
||||||
|
|
||||||
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
|
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
|
||||||
@@ -88,3 +102,5 @@ If the reset email is not received:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
27
app/auth_tokens.py
Normal file
27
app/auth_tokens.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Shared auth token helpers.
|
||||||
|
|
||||||
|
We keep password reset/invite token logic in one place so it can be used by:
|
||||||
|
- the normal "forgot password" flow
|
||||||
|
- company "invite user" flow
|
||||||
|
|
||||||
|
Tokens are signed with Flask SECRET_KEY and time-limited.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from itsdangerous import URLSafeTimedSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def _serializer(secret_key: str) -> URLSafeTimedSerializer:
|
||||||
|
return URLSafeTimedSerializer(secret_key, salt="password-reset")
|
||||||
|
|
||||||
|
|
||||||
|
def make_password_reset_token(*, secret_key: str, user_id: int) -> str:
|
||||||
|
s = _serializer(secret_key)
|
||||||
|
return s.dumps({"user_id": int(user_id)})
|
||||||
|
|
||||||
|
|
||||||
|
def load_password_reset_user_id(*, secret_key: str, token: str, max_age_seconds: int) -> int:
|
||||||
|
s = _serializer(secret_key)
|
||||||
|
data = s.loads(token, max_age=max_age_seconds)
|
||||||
|
return int(data.get("user_id"))
|
||||||
@@ -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 flask_login import current_user, login_required, login_user
|
||||||
|
|
||||||
from ..extensions import db
|
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
|
from ..models import Company, Display, DisplaySession, Playlist, PlaylistItem, User
|
||||||
|
|
||||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
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."""
|
"""Best-effort delete of an uploaded media file."""
|
||||||
if not file_path:
|
if not file_path:
|
||||||
return
|
return
|
||||||
if not file_path.startswith("uploads/"):
|
if not is_valid_upload_relpath(file_path):
|
||||||
return
|
return
|
||||||
|
|
||||||
filename = file_path.split("/", 1)[1]
|
abs_path = abs_upload_path(upload_folder, file_path)
|
||||||
abs_path = os.path.join(upload_folder, filename)
|
if not abs_path:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
if os.path.isfile(abs_path):
|
if os.path.isfile(abs_path):
|
||||||
os.remove(abs_path)
|
os.remove(abs_path)
|
||||||
@@ -55,6 +57,13 @@ def create_company():
|
|||||||
c = Company(name=name)
|
c = Company(name=name)
|
||||||
db.session.add(c)
|
db.session.add(c)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# Create the per-company upload directory eagerly (best-effort).
|
||||||
|
try:
|
||||||
|
ensure_company_upload_dir(current_app.config["UPLOAD_FOLDER"], c.id)
|
||||||
|
except Exception:
|
||||||
|
# Upload directory is also created lazily on first upload.
|
||||||
|
pass
|
||||||
flash("Company created", "success")
|
flash("Company created", "success")
|
||||||
return redirect(url_for("admin.company_detail", company_id=c.id))
|
return redirect(url_for("admin.company_detail", company_id=c.id))
|
||||||
|
|
||||||
@@ -193,6 +202,43 @@ def update_user_email(user_id: int):
|
|||||||
return redirect(url_for("admin.company_detail", company_id=u.company_id))
|
return redirect(url_for("admin.company_detail", company_id=u.company_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/users/<int:user_id>/delete")
|
||||||
|
@login_required
|
||||||
|
def delete_user(user_id: int):
|
||||||
|
"""Admin: delete a non-admin user."""
|
||||||
|
|
||||||
|
admin_required()
|
||||||
|
|
||||||
|
u = db.session.get(User, user_id)
|
||||||
|
if not u:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Safety checks
|
||||||
|
if u.is_admin:
|
||||||
|
flash("Cannot delete an admin user", "danger")
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
if u.id == current_user.id:
|
||||||
|
flash("You cannot delete yourself", "danger")
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
company_id = u.company_id
|
||||||
|
company_name = u.company.name if u.company else None
|
||||||
|
email = u.email
|
||||||
|
|
||||||
|
db.session.delete(u)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(
|
||||||
|
f"User '{email}' deleted" + (f" from '{company_name}'." if company_name else "."),
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
|
||||||
|
if company_id:
|
||||||
|
return redirect(url_for("admin.company_detail", company_id=company_id))
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/displays/<int:display_id>/name")
|
@bp.post("/displays/<int:display_id>/name")
|
||||||
@login_required
|
@login_required
|
||||||
def update_display_name(display_id: int):
|
def update_display_name(display_id: int):
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from datetime import datetime, timedelta
|
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 ..extensions import db
|
||||||
from ..models import Display, DisplaySession
|
from ..models import Display, DisplaySession
|
||||||
@@ -12,6 +15,95 @@ MAX_ACTIVE_SESSIONS_PER_DISPLAY = 2
|
|||||||
SESSION_TTL_SECONDS = 90
|
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/<token>/playlist")
|
@bp.get("/display/<token>/playlist")
|
||||||
def display_playlist(token: str):
|
def display_playlist(token: str):
|
||||||
display = Display.query.filter_by(token=token).first()
|
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.
|
# Enforce: a display URL/token can be opened by max 2 concurrently active sessions.
|
||||||
# Player sends a stable `sid` via querystring.
|
# Player sends a stable `sid` via querystring.
|
||||||
sid = (request.args.get("sid") or "").strip()
|
sid = request.args.get("sid")
|
||||||
if sid:
|
ok, resp = _enforce_and_touch_display_session(display, sid)
|
||||||
cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS)
|
if not ok:
|
||||||
DisplaySession.query.filter(
|
return resp
|
||||||
DisplaySession.display_id == display.id,
|
|
||||||
DisplaySession.last_seen_at < cutoff,
|
|
||||||
).delete(synchronize_session=False)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
existing = DisplaySession.query.filter_by(display_id=display.id, sid=sid).first()
|
|
||||||
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
|
playlist = display.assigned_playlist
|
||||||
if not playlist:
|
if not playlist:
|
||||||
@@ -86,3 +142,79 @@ def display_playlist(token: str):
|
|||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/display/<token>/events")
|
||||||
|
def display_events(token: str):
|
||||||
|
"""Server-Sent Events stream to notify the player when its playlist changes."""
|
||||||
|
|
||||||
|
display = Display.query.filter_by(token=token).first()
|
||||||
|
if not display:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
sid = request.args.get("sid")
|
||||||
|
ok, resp = _enforce_and_touch_display_session(display, sid)
|
||||||
|
if not ok:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
display_id = display.id
|
||||||
|
sid = (sid or "").strip() or None
|
||||||
|
|
||||||
|
@stream_with_context
|
||||||
|
def _gen():
|
||||||
|
last_hash = None
|
||||||
|
last_touch = 0.0
|
||||||
|
keepalive_counter = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Refresh from DB each loop so changes become visible.
|
||||||
|
d = Display.query.filter_by(id=display_id).first()
|
||||||
|
if not d:
|
||||||
|
yield "event: closed\ndata: {}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
playlist_id, h = _playlist_signature(d)
|
||||||
|
if h != last_hash:
|
||||||
|
last_hash = h
|
||||||
|
payload = json.dumps({"playlist_id": playlist_id, "hash": h})
|
||||||
|
yield f"event: changed\ndata: {payload}\n\n"
|
||||||
|
|
||||||
|
# Touch session periodically so SSE-only viewers don't time out.
|
||||||
|
now = time.time()
|
||||||
|
if sid and (now - last_touch) >= 30:
|
||||||
|
last_touch = now
|
||||||
|
existing = DisplaySession.query.filter_by(display_id=display_id, sid=sid).first()
|
||||||
|
if existing:
|
||||||
|
existing.last_seen_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Keep-alive comment (prevents some proxies from closing idle streams).
|
||||||
|
keepalive_counter += 1
|
||||||
|
if keepalive_counter >= 10: # ~20s with the sleep below
|
||||||
|
keepalive_counter = 0
|
||||||
|
yield ": keep-alive\n\n"
|
||||||
|
|
||||||
|
# Release DB connections between iterations.
|
||||||
|
db.session.remove()
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
except GeneratorExit:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
# Avoid tight error loops.
|
||||||
|
try:
|
||||||
|
db.session.remove()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
_gen(),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,29 +8,26 @@ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from ..email_utils import send_email
|
from ..email_utils import send_email
|
||||||
from ..models import User
|
from ..models import User
|
||||||
|
from ..auth_tokens import load_password_reset_user_id, make_password_reset_token
|
||||||
|
|
||||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _reset_serializer_v2() -> URLSafeTimedSerializer:
|
def _make_reset_token(user: User) -> str:
|
||||||
# Use Flask SECRET_KEY; fallback to app config via current_app.
|
|
||||||
# (defined as separate function to keep import cycle minimal)
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="password-reset")
|
return make_password_reset_token(secret_key=current_app.config["SECRET_KEY"], user_id=user.id)
|
||||||
|
|
||||||
|
|
||||||
def _make_reset_token(user: User) -> str:
|
|
||||||
s = _reset_serializer_v2()
|
|
||||||
return s.dumps({"user_id": user.id})
|
|
||||||
|
|
||||||
|
|
||||||
def _load_reset_token(token: str, *, max_age_seconds: int) -> int:
|
def _load_reset_token(token: str, *, max_age_seconds: int) -> int:
|
||||||
s = _reset_serializer_v2()
|
from flask import current_app
|
||||||
data = s.loads(token, max_age=max_age_seconds)
|
|
||||||
user_id = int(data.get("user_id"))
|
return load_password_reset_user_id(
|
||||||
return user_id
|
secret_key=current_app.config["SECRET_KEY"],
|
||||||
|
token=token,
|
||||||
|
max_age_seconds=max_age_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/forgot-password")
|
@bp.get("/forgot-password")
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import urlparse, parse_qs
|
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 import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, url_for
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
from ..extensions import db
|
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"}
|
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}"
|
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.
|
"""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/<uuid>.webp)
|
Returns relative file path under /static (e.g. uploads/<uuid>.webp)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
unique = f"{uuid.uuid4().hex}.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)
|
img = Image.open(uploaded_file)
|
||||||
|
# Respect EXIF orientation (common for phone photos)
|
||||||
|
img = ImageOps.exif_transpose(img)
|
||||||
|
|
||||||
# Normalize mode for webp
|
# Normalize mode for webp
|
||||||
if img.mode not in ("RGB", "RGBA"):
|
if img.mode not in ("RGB", "RGBA"):
|
||||||
img = img.convert("RGB")
|
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)
|
# 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)
|
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."""
|
"""Best-effort delete of an uploaded media file."""
|
||||||
if not file_path:
|
if not file_path:
|
||||||
return
|
return
|
||||||
if not file_path.startswith("uploads/"):
|
if not is_valid_upload_relpath(file_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
abs_path = abs_upload_path(upload_root, file_path)
|
||||||
|
if not abs_path:
|
||||||
return
|
return
|
||||||
filename = file_path.split("/", 1)[1]
|
|
||||||
abs_path = os.path.join(upload_folder, filename)
|
|
||||||
try:
|
try:
|
||||||
if os.path.isfile(abs_path):
|
if os.path.isfile(abs_path):
|
||||||
os.remove(abs_path)
|
os.remove(abs_path)
|
||||||
@@ -123,6 +181,138 @@ def company_user_required():
|
|||||||
abort(403)
|
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/<int:user_id>/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("/")
|
@bp.get("/")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
@@ -157,6 +347,34 @@ def playlist_detail(playlist_id: int):
|
|||||||
return render_template("company/playlist_detail.html", playlist=playlist)
|
return render_template("company/playlist_detail.html", playlist=playlist)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/playlists/<int:playlist_id>")
|
||||||
|
@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/<int:playlist_id>/delete")
|
@bp.post("/playlists/<int:playlist_id>/delete")
|
||||||
@login_required
|
@login_required
|
||||||
def delete_playlist(playlist_id: int):
|
def delete_playlist(playlist_id: int):
|
||||||
@@ -283,6 +501,7 @@ def add_playlist_item(playlist_id: int):
|
|||||||
ext = os.path.splitext(filename)[1].lower()
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
|
||||||
if item_type == "image":
|
if item_type == "image":
|
||||||
|
crop_mode = (request.form.get("crop_mode") or "16:9").strip().lower()
|
||||||
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return _json_error(
|
return _json_error(
|
||||||
@@ -291,7 +510,12 @@ def add_playlist_item(playlist_id: int):
|
|||||||
flash("Unsupported image type", "danger")
|
flash("Unsupported image type", "danger")
|
||||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
try:
|
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:
|
except Exception:
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return _json_error("Failed to process image upload", 500)
|
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.
|
# Keep as-is but always rename to a UUID.
|
||||||
unique = uuid.uuid4().hex + ext
|
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)
|
f.save(save_path)
|
||||||
|
|
||||||
# Safety check: validate using the actual saved file size.
|
# Safety check: validate using the actual saved file size.
|
||||||
@@ -351,7 +576,7 @@ def add_playlist_item(playlist_id: int):
|
|||||||
flash(msg, "danger")
|
flash(msg, "danger")
|
||||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
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":
|
elif item_type == "webpage":
|
||||||
url = request.form.get("url", "").strip()
|
url = request.form.get("url", "").strip()
|
||||||
|
|||||||
@@ -93,9 +93,20 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>{{ u.email or "(no email)" }}</strong>
|
<strong>{{ u.email or "(no email)" }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-brand btn-sm" type="submit">Impersonate</button>
|
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
|
||||||
</form>
|
<button class="btn btn-brand btn-sm" type="submit">Impersonate</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('admin.delete_user', user_id=u.id) }}"
|
||||||
|
data-confirm="Delete user {{ u.email or '(no email)' }}? This cannot be undone."
|
||||||
|
onsubmit="return confirm(this.dataset.confirm);"
|
||||||
|
>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="list-group-item text-muted">No users.</div>
|
<div class="list-group-item text-muted">No users.</div>
|
||||||
|
|||||||
@@ -28,6 +28,23 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="mainNav">
|
<div class="collapse navbar-collapse" id="mainNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin.dashboard') }}">Admin</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('company.dashboard') }}">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('company.my_company') }}">My Company</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="d-flex align-items-lg-center flex-column flex-lg-row gap-2 ms-lg-auto">
|
<div class="d-flex align-items-lg-center flex-column flex-lg-row gap-2 ms-lg-auto">
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<div class="small text-muted">{{ current_user.email }}</div>
|
<div class="small text-muted">{{ current_user.email }}</div>
|
||||||
|
|||||||
@@ -29,9 +29,6 @@
|
|||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="d-inline-flex gap-2">
|
<div class="d-inline-flex gap-2">
|
||||||
<a class="btn btn-ink btn-sm" href="{{ url_for('company.playlist_detail', playlist_id=p.id) }}">Open</a>
|
<a class="btn btn-ink btn-sm" href="{{ url_for('company.playlist_detail', playlist_id=p.id) }}">Open</a>
|
||||||
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=p.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
|
|
||||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -59,6 +56,7 @@
|
|||||||
<div class="display-preview">
|
<div class="display-preview">
|
||||||
<iframe
|
<iframe
|
||||||
title="Preview — {{ d.name }}"
|
title="Preview — {{ d.name }}"
|
||||||
|
data-display-id="{{ d.id }}"
|
||||||
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
|
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
@@ -173,6 +171,22 @@
|
|||||||
return data.display;
|
return data.display;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshPreviewIframe(displayId) {
|
||||||
|
const iframe = document.querySelector(`iframe[data-display-id="${displayId}"]`);
|
||||||
|
if (!iframe || !iframe.src) return;
|
||||||
|
try {
|
||||||
|
const u = new URL(iframe.src, window.location.origin);
|
||||||
|
// Ensure preview flag is present (and bust cache).
|
||||||
|
u.searchParams.set('preview', '1');
|
||||||
|
u.searchParams.set('_ts', String(Date.now()));
|
||||||
|
iframe.src = u.toString();
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: naive cache buster
|
||||||
|
const sep = iframe.src.includes('?') ? '&' : '?';
|
||||||
|
iframe.src = `${iframe.src}${sep}_ts=${Date.now()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Playlist auto-save
|
// Playlist auto-save
|
||||||
document.querySelectorAll('.js-playlist-select').forEach((sel) => {
|
document.querySelectorAll('.js-playlist-select').forEach((sel) => {
|
||||||
sel.addEventListener('change', async () => {
|
sel.addEventListener('change', async () => {
|
||||||
@@ -182,6 +196,7 @@
|
|||||||
try {
|
try {
|
||||||
await postDisplayUpdate(displayId, { playlist_id: playlistId });
|
await postDisplayUpdate(displayId, { playlist_id: playlistId });
|
||||||
showToast('Playlist saved', 'text-bg-success');
|
showToast('Playlist saved', 'text-bg-success');
|
||||||
|
refreshPreviewIframe(displayId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
119
app/templates/company/my_company.html
Normal file
119
app/templates/company/my_company.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">My Company</h1>
|
||||||
|
<div class="text-muted">{{ company.name }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a class="btn btn-outline-ink" href="{{ url_for('company.dashboard') }}">Back</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4 g-3">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card card-elevated h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Company stats</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Users</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['users'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Displays</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['displays'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Playlists</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['playlists'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Playlist items</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['items'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Active display sessions</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['active_sessions'] }}</div>
|
||||||
|
<div class="text-muted small">(last ~90 seconds)</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small">Storage used</div>
|
||||||
|
<div class="fs-4 fw-bold">{{ stats['storage_human'] }}</div>
|
||||||
|
<div class="text-muted small">({{ stats['storage_bytes'] }} bytes)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card card-elevated h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Invite user</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('company.invite_user') }}" class="d-flex gap-2 flex-wrap">
|
||||||
|
<input class="form-control" type="email" name="email" placeholder="Email address" required />
|
||||||
|
<button class="btn btn-brand" type="submit">Send invite</button>
|
||||||
|
</form>
|
||||||
|
<div class="text-muted small mt-2">
|
||||||
|
The user will receive an email with a password set link (valid for 30 minutes).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-elevated mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Users</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th class="text-muted">Created</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ u.email or "(no email)" }}</strong>
|
||||||
|
{% if u.id == current_user.id %}
|
||||||
|
<span class="badge bg-secondary ms-2">you</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">{{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else "—" }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if u.id != current_user.id %}
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('company.delete_company_user', user_id=u.id) }}"
|
||||||
|
class="d-inline"
|
||||||
|
data-confirm="Delete user {{ u.email or "(no email)" }}? This cannot be undone."
|
||||||
|
onsubmit="return confirm(this.dataset.confirm);"
|
||||||
|
>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-muted">No users.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -65,6 +65,14 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
|
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#renamePlaylistModal"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=playlist.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
|
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=playlist.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
|
||||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete playlist</button>
|
<button class="btn btn-outline-danger btn-sm" type="submit">Delete playlist</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -72,6 +80,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Rename Playlist Modal #}
|
||||||
|
<div class="modal fade" id="renamePlaylistModal" tabindex="-1" aria-labelledby="renamePlaylistModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="renamePlaylistModalLabel">Rename playlist</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('company.update_playlist', playlist_id=playlist.id) }}">
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="form-label" for="playlist-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="playlist-name"
|
||||||
|
class="form-control"
|
||||||
|
name="name"
|
||||||
|
value="{{ playlist.name }}"
|
||||||
|
placeholder="Playlist name"
|
||||||
|
maxlength="120"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<div class="text-muted small mt-2">Rename only changes the playlist title; items and assignments stay the same.</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-ink" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-brand">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="h5 mb-0">Items</h2>
|
<h2 class="h5 mb-0">Items</h2>
|
||||||
@@ -164,6 +204,7 @@
|
|||||||
<form id="add-item-form" method="post" action="{{ url_for('company.add_playlist_item', playlist_id=playlist.id) }}" enctype="multipart/form-data">
|
<form id="add-item-form" method="post" action="{{ url_for('company.add_playlist_item', playlist_id=playlist.id) }}" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="response" value="json" />
|
<input type="hidden" name="response" value="json" />
|
||||||
<input type="hidden" name="item_type" id="item_type" value="image" />
|
<input type="hidden" name="item_type" id="item_type" value="image" />
|
||||||
|
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
|
||||||
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Title (optional)</label>
|
<label class="form-label">Title (optional)</label>
|
||||||
@@ -192,6 +233,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3" id="crop-mode-group">
|
||||||
|
<label class="form-label">Image crop</label>
|
||||||
|
<div class="btn-group w-100" role="group" aria-label="Crop mode">
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
|
||||||
|
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
|
||||||
|
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.dropzone {
|
.dropzone {
|
||||||
border: 2px dashed #6c757d;
|
border: 2px dashed #6c757d;
|
||||||
@@ -289,7 +345,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="step-crop" class="step">
|
<div id="step-crop" class="step">
|
||||||
<div class="text-muted small mb-2">Crop to <strong>16:9</strong> (recommended for display screens).</div>
|
<div class="text-muted small mb-2" id="crop-step-hint">Crop to <strong>16:9</strong> (recommended for display screens).</div>
|
||||||
<div style="width: 100%; background: #111; border-radius: .25rem; overflow: hidden;">
|
<div style="width: 100%; background: #111; border-radius: .25rem; overflow: hidden;">
|
||||||
<img id="image-crop-target" alt="Crop" style="max-width: 100%; display: block;" />
|
<img id="image-crop-target" alt="Crop" style="max-width: 100%; display: block;" />
|
||||||
</div>
|
</div>
|
||||||
@@ -324,8 +380,11 @@
|
|||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
const typeHidden = document.getElementById('item_type');
|
const typeHidden = document.getElementById('item_type');
|
||||||
|
const cropModeHidden = document.getElementById('crop_mode');
|
||||||
const submitBtn = document.getElementById('add-item-submit');
|
const submitBtn = document.getElementById('add-item-submit');
|
||||||
const durationGroup = document.getElementById('duration-group');
|
const durationGroup = document.getElementById('duration-group');
|
||||||
|
const cropModeGroup = document.getElementById('crop-mode-group');
|
||||||
|
const cropHint = document.getElementById('crop-step-hint');
|
||||||
|
|
||||||
const sectionImage = document.getElementById('section-image');
|
const sectionImage = document.getElementById('section-image');
|
||||||
const sectionWebpage = document.getElementById('section-webpage');
|
const sectionWebpage = document.getElementById('section-webpage');
|
||||||
@@ -357,6 +416,7 @@
|
|||||||
sectionVideo.classList.toggle('d-none', t !== 'video');
|
sectionVideo.classList.toggle('d-none', t !== 'video');
|
||||||
// duration applies to image/webpage/youtube. Video plays until ended.
|
// duration applies to image/webpage/youtube. Video plays until ended.
|
||||||
durationGroup.classList.toggle('d-none', t === 'video');
|
durationGroup.classList.toggle('d-none', t === 'video');
|
||||||
|
cropModeGroup?.classList.toggle('d-none', t !== 'image');
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.title = '';
|
submitBtn.title = '';
|
||||||
|
|
||||||
@@ -373,6 +433,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentCropMode() {
|
||||||
|
// crop_mode_choice is only UI; we submit hidden crop_mode for server fallback
|
||||||
|
return (cropModeHidden?.value || '16:9').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCropHint() {
|
||||||
|
const cm = currentCropMode();
|
||||||
|
if (!cropHint) return;
|
||||||
|
if (cm === 'none') {
|
||||||
|
cropHint.innerHTML = 'No crop selected. The image will be resized/compressed, keeping its original aspect ratio.';
|
||||||
|
if (cropResetBtn) cropResetBtn.disabled = true;
|
||||||
|
} else if (cm === '9:16') {
|
||||||
|
cropHint.innerHTML = 'Crop to <strong>9:16</strong> (portrait).';
|
||||||
|
if (cropResetBtn) cropResetBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
cropHint.innerHTML = 'Crop to <strong>16:9</strong> (landscape, recommended for display screens).';
|
||||||
|
if (cropResetBtn) cropResetBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
|
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
|
||||||
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
|
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
|
||||||
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
|
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
|
||||||
@@ -437,13 +517,19 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cropper = new window.Cropper(cropImg, {
|
// Create cropper only when cropping is enabled
|
||||||
aspectRatio: 16 / 9,
|
const cm = currentCropMode();
|
||||||
viewMode: 1,
|
if (cm !== 'none') {
|
||||||
autoCropArea: 1,
|
cropper = new window.Cropper(cropImg, {
|
||||||
responsive: true,
|
aspectRatio: (cm === '9:16') ? (9 / 16) : (16 / 9),
|
||||||
background: false,
|
viewMode: 1,
|
||||||
});
|
autoCropArea: 1,
|
||||||
|
responsive: true,
|
||||||
|
background: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCropHint();
|
||||||
|
|
||||||
// Enable Add button now that cropper exists
|
// Enable Add button now that cropper exists
|
||||||
if (typeHidden.value === 'image') submitBtn.disabled = false;
|
if (typeHidden.value === 'image') submitBtn.disabled = false;
|
||||||
@@ -486,26 +572,33 @@
|
|||||||
|
|
||||||
// If image, replace file with cropped version before sending.
|
// If image, replace file with cropped version before sending.
|
||||||
if (typeHidden.value === 'image') {
|
if (typeHidden.value === 'image') {
|
||||||
if (!cropper) {
|
const cm = currentCropMode();
|
||||||
cropStatus.textContent = 'Please select an image first.';
|
|
||||||
submitBtn.disabled = false;
|
// If no crop is selected, just upload the original file.
|
||||||
return;
|
if (cm !== 'none') {
|
||||||
|
if (!cropper) {
|
||||||
|
cropStatus.textContent = 'Please select an image first.';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cropStatus.textContent = 'Preparing cropped image…';
|
||||||
|
const isPortrait = cm === '9:16';
|
||||||
|
const canvas = cropper.getCroppedCanvas({
|
||||||
|
width: isPortrait ? 720 : 1280,
|
||||||
|
height: isPortrait ? 1280 : 720,
|
||||||
|
imageSmoothingQuality: 'high',
|
||||||
|
});
|
||||||
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||||
|
if (!blob) {
|
||||||
|
cropStatus.textContent = 'Failed to crop image.';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const croppedFile = new File([blob], 'cropped.png', { type: 'image/png' });
|
||||||
|
setFileOnInput(fileInput, croppedFile);
|
||||||
|
cropStatus.textContent = '';
|
||||||
}
|
}
|
||||||
cropStatus.textContent = 'Preparing cropped image…';
|
|
||||||
const canvas = cropper.getCroppedCanvas({
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
imageSmoothingQuality: 'high',
|
|
||||||
});
|
|
||||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
|
||||||
if (!blob) {
|
|
||||||
cropStatus.textContent = 'Failed to crop image.';
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const croppedFile = new File([blob], 'cropped.png', { type: 'image/png' });
|
|
||||||
setFileOnInput(fileInput, croppedFile);
|
|
||||||
cropStatus.textContent = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fd = new FormData(form);
|
const fd = new FormData(form);
|
||||||
@@ -553,6 +646,8 @@
|
|||||||
form.reset();
|
form.reset();
|
||||||
typeHidden.value = 'image';
|
typeHidden.value = 'image';
|
||||||
document.getElementById('type-image')?.click();
|
document.getElementById('type-image')?.click();
|
||||||
|
if (cropModeHidden) cropModeHidden.value = '16:9';
|
||||||
|
document.getElementById('crop-16-9')?.click();
|
||||||
destroyCropper();
|
destroyCropper();
|
||||||
showStep('select');
|
showStep('select');
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
@@ -648,6 +743,9 @@
|
|||||||
setEnabled(urlInput, t === 'webpage');
|
setEnabled(urlInput, t === 'webpage');
|
||||||
setEnabled(youtubeUrlInput, t === 'youtube');
|
setEnabled(youtubeUrlInput, t === 'youtube');
|
||||||
|
|
||||||
|
// Crop mode only applies to images
|
||||||
|
setEnabled(cropModeHidden, t === 'image');
|
||||||
|
|
||||||
if (t === 'webpage') {
|
if (t === 'webpage') {
|
||||||
// Keep preview behavior
|
// Keep preview behavior
|
||||||
schedulePreview();
|
schedulePreview();
|
||||||
@@ -661,8 +759,10 @@
|
|||||||
|
|
||||||
// Set initial state
|
// Set initial state
|
||||||
setType('image');
|
setType('image');
|
||||||
|
if (cropModeHidden) cropModeHidden.value = '16:9';
|
||||||
showStep('select');
|
showStep('select');
|
||||||
syncEnabledInputs();
|
syncEnabledInputs();
|
||||||
|
updateCropHint();
|
||||||
|
|
||||||
// Modal open
|
// Modal open
|
||||||
openBtn?.addEventListener('click', () => {
|
openBtn?.addEventListener('click', () => {
|
||||||
@@ -678,6 +778,43 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Crop mode selection
|
||||||
|
function setCropMode(mode) {
|
||||||
|
if (!cropModeHidden) return;
|
||||||
|
cropModeHidden.value = mode;
|
||||||
|
|
||||||
|
// If cropper exists, update aspect ratio or destroy it
|
||||||
|
const cm = currentCropMode();
|
||||||
|
if (typeHidden.value !== 'image') return;
|
||||||
|
if (!cropImg?.src) {
|
||||||
|
updateCropHint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure cropper state matches selection
|
||||||
|
if (cm === 'none') {
|
||||||
|
destroyCropper();
|
||||||
|
// Re-load the selected file into the preview without cropper.
|
||||||
|
const f = fileInput?.files?.[0];
|
||||||
|
if (f) loadImageFile(f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cropper) {
|
||||||
|
cropper.setAspectRatio((cm === '9:16') ? (9 / 16) : (16 / 9));
|
||||||
|
updateCropHint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cropper yet (e.g. user changed crop mode after selecting file but before cropper init)
|
||||||
|
const f = fileInput?.files?.[0];
|
||||||
|
if (f) loadImageFile(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('crop-16-9')?.addEventListener('change', () => setCropMode('16:9'));
|
||||||
|
document.getElementById('crop-9-16')?.addEventListener('change', () => setCropMode('9:16'));
|
||||||
|
document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none'));
|
||||||
|
|
||||||
// Whenever type changes, keep enabled inputs in sync
|
// Whenever type changes, keep enabled inputs in sync
|
||||||
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => {
|
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => {
|
||||||
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
|
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
|
||||||
|
|||||||
@@ -8,16 +8,15 @@
|
|||||||
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
||||||
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
|
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
|
||||||
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
|
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
|
||||||
.notice { position: fixed; left: 12px; bottom: 12px; color: #bbb; font: 14px/1.3 sans-serif; }
|
/* removed bottom-left status text */
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="stage"></div>
|
<div id="stage"></div>
|
||||||
<div class="notice" id="notice"></div>
|
|
||||||
<script>
|
<script>
|
||||||
const token = "{{ display.token }}";
|
const token = "{{ display.token }}";
|
||||||
const stage = document.getElementById('stage');
|
const stage = document.getElementById('stage');
|
||||||
const notice = document.getElementById('notice');
|
function setNotice(_text) { /* intentionally no-op: notice UI removed */ }
|
||||||
|
|
||||||
const isPreview = new URLSearchParams(window.location.search).get('preview') === '1';
|
const isPreview = new URLSearchParams(window.location.search).get('preview') === '1';
|
||||||
|
|
||||||
@@ -39,6 +38,9 @@
|
|||||||
let idx = 0;
|
let idx = 0;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
|
||||||
|
let es = null;
|
||||||
|
let esRetryMs = 1000;
|
||||||
|
|
||||||
async function fetchPlaylist() {
|
async function fetchPlaylist() {
|
||||||
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
||||||
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
|
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
|
||||||
@@ -56,7 +58,7 @@
|
|||||||
|
|
||||||
function next() {
|
function next() {
|
||||||
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
||||||
notice.textContent = 'No playlist assigned.';
|
setNotice('No playlist assigned.');
|
||||||
clearStage();
|
clearStage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -65,7 +67,7 @@
|
|||||||
idx = (idx + 1) % playlist.items.length;
|
idx = (idx + 1) % playlist.items.length;
|
||||||
|
|
||||||
clearStage();
|
clearStage();
|
||||||
notice.textContent = playlist.playlist ? `${playlist.display} — ${playlist.playlist.name}` : playlist.display;
|
setNotice(playlist.playlist ? `${playlist.display} — ${playlist.playlist.name}` : playlist.display);
|
||||||
|
|
||||||
if (item.type === 'image') {
|
if (item.type === 'image') {
|
||||||
const el = document.createElement('img');
|
const el = document.createElement('img');
|
||||||
@@ -109,10 +111,14 @@
|
|||||||
next();
|
next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearStage();
|
clearStage();
|
||||||
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
|
setNotice(e && e.message ? e.message : 'Unable to load playlist.');
|
||||||
// keep retrying; if a slot frees up the display will start automatically.
|
// keep retrying; if a slot frees up the display will start automatically.
|
||||||
}
|
}
|
||||||
// refresh playlist every 60s
|
|
||||||
|
// Open live event stream: when server signals a change, reload playlist immediately.
|
||||||
|
connectEvents();
|
||||||
|
|
||||||
|
// Fallback refresh (in case SSE is blocked by a proxy/network): every 5 minutes.
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
playlist = await fetchPlaylist();
|
playlist = await fetchPlaylist();
|
||||||
@@ -122,9 +128,47 @@
|
|||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
clearStage();
|
clearStage();
|
||||||
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
|
setNotice(e && e.message ? e.message : 'Unable to load playlist.');
|
||||||
}
|
}
|
||||||
}, 60000);
|
}, 300000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectEvents() {
|
||||||
|
if (isPreview) return; // preview shouldn't consume a slot / keep a long-lived connection
|
||||||
|
|
||||||
|
try { if (es) es.close(); } catch(e) { /* ignore */ }
|
||||||
|
|
||||||
|
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
||||||
|
es = new EventSource(`/api/display/${token}/events${qs}`);
|
||||||
|
|
||||||
|
es.addEventListener('changed', async () => {
|
||||||
|
try {
|
||||||
|
const newPlaylist = await fetchPlaylist();
|
||||||
|
|
||||||
|
// If content changed, restart from the beginning.
|
||||||
|
const oldStr = JSON.stringify(playlist);
|
||||||
|
const newStr = JSON.stringify(newPlaylist);
|
||||||
|
playlist = newPlaylist;
|
||||||
|
if (oldStr !== newStr) {
|
||||||
|
idx = 0;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
esRetryMs = 1000; // reset backoff on success
|
||||||
|
} catch(e) {
|
||||||
|
// leave current playback; we'll retry via reconnect handler
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
try { es.close(); } catch(e) { /* ignore */ }
|
||||||
|
es = null;
|
||||||
|
|
||||||
|
// Exponential backoff up to 30s
|
||||||
|
const wait = esRetryMs;
|
||||||
|
esRetryMs = Math.min(30000, Math.floor(esRetryMs * 1.7));
|
||||||
|
setTimeout(connectEvents, wait);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
start();
|
start();
|
||||||
|
|||||||
103
app/uploads.py
Normal file
103
app/uploads.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_company_segment(company_id: int | None) -> str:
|
||||||
|
"""Return the directory name for a company's upload folder.
|
||||||
|
|
||||||
|
We intentionally use the numeric company_id (not company name) to avoid
|
||||||
|
rename issues and any path traversal concerns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cid = int(company_id) if company_id is not None else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cid = 0
|
||||||
|
return str(max(0, cid))
|
||||||
|
|
||||||
|
|
||||||
|
def get_company_upload_dir(upload_root: str, company_id: int | None) -> str:
|
||||||
|
"""Return absolute directory path for a company's uploads."""
|
||||||
|
|
||||||
|
return os.path.join(upload_root, _safe_company_segment(company_id))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_company_upload_dir(upload_root: str, company_id: int | None) -> str:
|
||||||
|
"""Ensure the company's upload directory exists; return its absolute path."""
|
||||||
|
|
||||||
|
d = get_company_upload_dir(upload_root, company_id)
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def get_company_upload_bytes(upload_root: str, company_id: int | None) -> int:
|
||||||
|
"""Return best-effort total bytes used by a company's upload directory.
|
||||||
|
|
||||||
|
This walks the directory tree under uploads/<company_id> and sums file sizes.
|
||||||
|
Any errors (missing directories, permission issues, broken links) are ignored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
root = get_company_upload_dir(upload_root, company_id)
|
||||||
|
try:
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
return 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for base, _dirs, files in os.walk(root):
|
||||||
|
for fn in files:
|
||||||
|
try:
|
||||||
|
p = os.path.join(base, fn)
|
||||||
|
if os.path.isfile(p):
|
||||||
|
total += os.path.getsize(p)
|
||||||
|
except Exception:
|
||||||
|
# Ignore unreadable files
|
||||||
|
continue
|
||||||
|
return int(total)
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_upload_relpath(file_path: str | None) -> bool:
|
||||||
|
"""True if file_path looks like a path we manage under /static.
|
||||||
|
|
||||||
|
Supports both layouts:
|
||||||
|
- uploads/<filename>
|
||||||
|
- uploads/<company_id>/<filename>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return False
|
||||||
|
fp = (file_path or "").replace("\\", "/")
|
||||||
|
if not fp.startswith("uploads/"):
|
||||||
|
return False
|
||||||
|
# Prevent weird absolute/relative tricks; we only allow a normal relative path.
|
||||||
|
if fp.startswith("uploads//") or ":" in fp:
|
||||||
|
return False
|
||||||
|
# No parent dir segments.
|
||||||
|
if "../" in fp or fp.endswith("/..") or fp.startswith("../"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def abs_upload_path(upload_root: str, file_path: str | None) -> str | None:
|
||||||
|
"""Resolve an item.file_path (uploads/...) to an absolute file path.
|
||||||
|
|
||||||
|
Returns None if file_path is not a managed uploads path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not is_valid_upload_relpath(file_path):
|
||||||
|
return None
|
||||||
|
rel = (file_path or "").replace("\\", "/")
|
||||||
|
rel = rel.split("uploads/", 1)[1]
|
||||||
|
|
||||||
|
# Split into segments and harden against traversal.
|
||||||
|
parts = [p for p in rel.split("/") if p]
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidate = os.path.abspath(os.path.join(upload_root, *parts))
|
||||||
|
root_abs = os.path.abspath(upload_root)
|
||||||
|
|
||||||
|
# Ensure resolved path stays inside upload_root.
|
||||||
|
if os.path.commonpath([candidate, root_abs]) != root_abs:
|
||||||
|
return None
|
||||||
|
return candidate
|
||||||
@@ -27,6 +27,9 @@ def main():
|
|||||||
"/auth/change-password",
|
"/auth/change-password",
|
||||||
"/auth/forgot-password",
|
"/auth/forgot-password",
|
||||||
"/auth/reset-password/<token>",
|
"/auth/reset-password/<token>",
|
||||||
|
"/company/my-company",
|
||||||
|
"/company/my-company/invite",
|
||||||
|
"/company/my-company/users/<int:user_id>/delete",
|
||||||
}
|
}
|
||||||
missing = sorted(required.difference(rules))
|
missing = sorted(required.difference(rules))
|
||||||
if missing:
|
if missing:
|
||||||
|
|||||||
Reference in New Issue
Block a user