Release v1.3

This commit is contained in:
2026-01-25 15:57:38 +01:00
parent 47aca9d64d
commit 56760e380d
10 changed files with 370 additions and 5 deletions

View File

@@ -26,6 +26,13 @@ from ..auth_tokens import make_password_reset_token
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"}
# Overlay is a transparent PNG that sits on top of a display.
ALLOWED_OVERLAY_EXTENSIONS = {".png"}
# Keep overlay size reasonable; it will be stretched to fit anyway.
# (PNG overlays are typically small-ish; 10MB is generous.)
MAX_OVERLAY_BYTES = 10 * 1024 * 1024
# Videos should have a maximum upload size of 250MB
MAX_VIDEO_BYTES = 250 * 1024 * 1024
@@ -175,6 +182,68 @@ def _try_delete_upload(file_path: str | None, upload_root: str):
# Ignore cleanup failures
pass
def _save_overlay_png(
uploaded_file,
upload_root: str,
company_id: int | None,
) -> str:
"""Save a company overlay as PNG under the company's upload dir.
Returns relative file path under /static (uploads/<company_id>/overlay_<uuid>.png)
"""
unique = f"overlay_{uuid.uuid4().hex}.png"
company_dir = ensure_company_upload_dir(upload_root, company_id)
save_path = os.path.join(company_dir, unique)
# Validate file is a PNG and is 16:9-ish.
# Use magic bytes (signature) instead of relying on Pillow's img.format,
# which can be unreliable if the stream position isn't at 0.
try:
if hasattr(uploaded_file, "stream"):
uploaded_file.stream.seek(0)
except Exception:
pass
try:
sig = uploaded_file.stream.read(8) if hasattr(uploaded_file, "stream") else uploaded_file.read(8)
except Exception:
sig = b""
# PNG file signature: 89 50 4E 47 0D 0A 1A 0A
if sig != b"\x89PNG\r\n\x1a\n":
raise ValueError("not_png")
# Rewind before Pillow parses.
try:
if hasattr(uploaded_file, "stream"):
uploaded_file.stream.seek(0)
except Exception:
pass
img = Image.open(uploaded_file)
img = ImageOps.exif_transpose(img)
w, h = img.size
if not w or not h:
raise ValueError("invalid")
# Allow some tolerance (overlays may include extra transparent padding).
aspect = w / h
target = 16 / 9
if abs(aspect - target) > 0.15: # ~15% tolerance
raise ValueError("not_16_9")
# Ensure we preserve alpha; normalize mode.
if img.mode not in ("RGBA", "LA"):
# Convert to RGBA so transparency is supported consistently.
img = img.convert("RGBA")
img.save(save_path, format="PNG", optimize=True)
company_seg = str(int(company_id)) if company_id is not None else "0"
return f"uploads/{company_seg}/{unique}"
bp = Blueprint("company", __name__, url_prefix="/company")
@@ -276,10 +345,15 @@ def my_company():
users = User.query.filter_by(company_id=company.id, is_admin=False).order_by(User.email.asc()).all()
overlay_url = None
if company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
overlay_url = url_for("static", filename=company.overlay_file_path)
return render_template(
"company/my_company.html",
company=company,
users=users,
overlay_url=overlay_url,
stats={
"users": user_count,
"displays": display_count,
@@ -295,6 +369,111 @@ def my_company():
)
@bp.post("/my-company/overlay")
@login_required
def upload_company_overlay():
"""Upload/replace the per-company 16:9 PNG overlay."""
company_user_required()
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
f = request.files.get("overlay")
if not f or not f.filename:
flash("Overlay file is required", "danger")
return redirect(url_for("company.my_company"))
filename = secure_filename(f.filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_OVERLAY_EXTENSIONS:
flash("Unsupported overlay type. Please upload a PNG file.", "danger")
return redirect(url_for("company.my_company"))
# Enforce size limit best-effort.
size = None
try:
size = getattr(f, "content_length", None)
if (size is None or size <= 0) and hasattr(f, "stream"):
pos = f.stream.tell()
f.stream.seek(0, os.SEEK_END)
size = f.stream.tell()
f.stream.seek(pos, os.SEEK_SET)
except Exception:
size = None
if size is not None and size > MAX_OVERLAY_BYTES:
flash("Overlay file too large. Maximum allowed size is 10MB.", "danger")
return redirect(url_for("company.my_company"))
# Enforce storage quota too (overlay is stored in the same uploads folder).
upload_root = current_app.config["UPLOAD_FOLDER"]
used_bytes = get_company_upload_bytes(upload_root, company.id)
usage = compute_storage_usage(used_bytes=used_bytes, max_bytes=company.storage_max_bytes)
storage_max_human = _format_bytes(usage["max_bytes"]) if usage.get("max_bytes") else None
if usage.get("is_exceeded"):
flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger")
return redirect(url_for("company.my_company"))
old_path = company.overlay_file_path
try:
new_relpath = _save_overlay_png(f, upload_root, company.id)
except ValueError as e:
code = str(e)
if code == "not_png":
flash("Overlay must be a PNG file.", "danger")
elif code == "not_16_9":
flash("Overlay should be 16:9 (landscape).", "danger")
else:
flash("Failed to process overlay upload.", "danger")
return redirect(url_for("company.my_company"))
except Exception:
flash("Failed to process overlay upload.", "danger")
return redirect(url_for("company.my_company"))
# Post-save quota check (like images) because PNG size is unknown until saved.
if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0:
try:
used_after = get_company_upload_bytes(upload_root, company.id)
except Exception:
used_after = None
if used_after is not None:
usage_after = compute_storage_usage(used_bytes=used_after, max_bytes=company.storage_max_bytes)
if usage_after.get("is_exceeded"):
_try_delete_upload(new_relpath, upload_root)
flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger")
return redirect(url_for("company.my_company"))
company.overlay_file_path = new_relpath
db.session.commit()
# Clean up the old overlay file.
if old_path and old_path != new_relpath:
_try_delete_upload(old_path, upload_root)
flash("Overlay updated.", "success")
return redirect(url_for("company.my_company"))
@bp.post("/my-company/overlay/delete")
@login_required
def delete_company_overlay():
company_user_required()
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
upload_root = current_app.config["UPLOAD_FOLDER"]
old_path = company.overlay_file_path
company.overlay_file_path = None
db.session.commit()
_try_delete_upload(old_path, upload_root)
flash("Overlay removed.", "success")
return redirect(url_for("company.my_company"))
@bp.post("/my-company/invite")
@login_required
def invite_user():
@@ -1004,6 +1183,26 @@ def update_display(display_id: int):
# Form POST implies full update
display.transition = _normalize_transition(request.form.get("transition"))
# Overlay toggle
if request.is_json:
if payload is None:
return _json_error("Invalid JSON")
if "show_overlay" in payload:
raw = payload.get("show_overlay")
# Accept common truthy representations.
if isinstance(raw, bool):
display.show_overlay = raw
elif raw in (1, 0):
display.show_overlay = bool(raw)
else:
s = ("" if raw is None else str(raw)).strip().lower()
display.show_overlay = s in {"1", "true", "yes", "on"}
else:
# Form POST implies full update
raw = request.form.get("show_overlay")
if raw is not None:
display.show_overlay = (raw or "").strip().lower() in {"1", "true", "yes", "on"}
# Playlist assignment
if request.is_json:
if "playlist_id" in payload:
@@ -1044,6 +1243,7 @@ def update_display(display_id: int):
"name": display.name,
"description": display.description,
"transition": display.transition,
"show_overlay": bool(display.show_overlay),
"assigned_playlist_id": display.assigned_playlist_id,
},
}