Release v1.3
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user