Release 1.6
This commit is contained in:
@@ -2,6 +2,10 @@ from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
|
||||
|
||||
@@ -15,6 +19,10 @@ bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
|
||||
SESSION_TTL_SECONDS = 90
|
||||
|
||||
# RSS ticker cache (in-memory; OK for this small app; avoids hammering feeds)
|
||||
TICKER_CACHE_TTL_SECONDS = 120
|
||||
_TICKER_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
|
||||
"""Return True if playlist is active based on its optional schedule window."""
|
||||
@@ -168,6 +176,127 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]:
|
||||
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _is_http_url_allowed(url: str) -> bool:
|
||||
"""Basic SSRF hardening: only allow http(s) and disallow obvious local targets."""
|
||||
|
||||
try:
|
||||
u = urlparse(url)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if u.scheme not in {"http", "https"}:
|
||||
return False
|
||||
|
||||
host = (u.hostname or "").strip().lower()
|
||||
if not host:
|
||||
return False
|
||||
|
||||
# Block localhost and common local domains.
|
||||
if host in {"localhost", "127.0.0.1", "::1"}:
|
||||
return False
|
||||
|
||||
# Block RFC1918-ish and link-local targets when host is an IP.
|
||||
# Note: this is best-effort; proper SSRF protection would require DNS resolution too.
|
||||
if re.match(r"^\d+\.\d+\.\d+\.\d+$", host):
|
||||
parts = [int(x) for x in host.split(".")]
|
||||
if parts[0] == 10:
|
||||
return False
|
||||
if parts[0] == 127:
|
||||
return False
|
||||
if parts[0] == 169 and parts[1] == 254:
|
||||
return False
|
||||
if parts[0] == 192 and parts[1] == 168:
|
||||
return False
|
||||
if parts[0] == 172 and 16 <= parts[1] <= 31:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _strip_text(s: str) -> str:
|
||||
s = (s or "").strip()
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
return s
|
||||
|
||||
|
||||
def _fetch_rss_titles(url: str, *, limit: int = 20) -> list[str]:
|
||||
"""Fetch RSS/Atom titles from a feed URL.
|
||||
|
||||
We intentionally avoid adding dependencies (feedparser) for this project.
|
||||
This implementation is tolerant enough for typical RSS2/Atom feeds.
|
||||
"""
|
||||
|
||||
req = Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "SignageTicker/1.0 (+https://example.invalid)",
|
||||
"Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
|
||||
with urlopen(req, timeout=8) as resp:
|
||||
# Basic size cap (avoid reading huge responses into memory)
|
||||
raw = resp.read(2_000_000) # 2MB
|
||||
|
||||
try:
|
||||
root = ET.fromstring(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
titles: list[str] = []
|
||||
|
||||
# RSS2: <rss><channel><item><title>
|
||||
for el in root.findall(".//item/title"):
|
||||
t = _strip_text("".join(el.itertext()))
|
||||
if t:
|
||||
titles.append(t)
|
||||
|
||||
# Atom: <feed><entry><title>
|
||||
if not titles:
|
||||
for el in root.findall(".//{*}entry/{*}title"):
|
||||
t = _strip_text("".join(el.itertext()))
|
||||
if t:
|
||||
titles.append(t)
|
||||
|
||||
# Some feeds may have <channel><title> etc; we only want entry titles.
|
||||
# Deduplicate while preserving order.
|
||||
deduped: list[str] = []
|
||||
seen = set()
|
||||
for t in titles:
|
||||
if t in seen:
|
||||
continue
|
||||
seen.add(t)
|
||||
deduped.append(t)
|
||||
if len(deduped) >= limit:
|
||||
break
|
||||
|
||||
return deduped
|
||||
|
||||
|
||||
def _get_ticker_titles_cached(url: str) -> tuple[list[str], bool]:
|
||||
"""Return (titles, from_cache)."""
|
||||
|
||||
now = time.time()
|
||||
key = (url or "").strip()
|
||||
if not key:
|
||||
return [], True
|
||||
|
||||
entry = _TICKER_CACHE.get(key)
|
||||
if entry and (now - float(entry.get("ts") or 0)) < TICKER_CACHE_TTL_SECONDS:
|
||||
return (entry.get("titles") or []), True
|
||||
|
||||
titles: list[str] = []
|
||||
try:
|
||||
if _is_http_url_allowed(key):
|
||||
titles = _fetch_rss_titles(key)
|
||||
except Exception:
|
||||
titles = []
|
||||
|
||||
_TICKER_CACHE[key] = {"ts": now, "titles": titles}
|
||||
return titles, False
|
||||
|
||||
|
||||
@bp.get("/display/<token>/playlist")
|
||||
def display_playlist(token: str):
|
||||
display = Display.query.filter_by(token=token).first()
|
||||
@@ -188,6 +317,17 @@ def display_playlist(token: str):
|
||||
if not ok:
|
||||
return resp
|
||||
|
||||
ticker_cfg = {
|
||||
"enabled": bool(display.ticker_enabled),
|
||||
"rss_url": display.ticker_rss_url,
|
||||
"color": display.ticker_color,
|
||||
"bg_color": display.ticker_bg_color,
|
||||
"bg_opacity": display.ticker_bg_opacity,
|
||||
"font_family": display.ticker_font_family,
|
||||
"font_size_px": display.ticker_font_size_px,
|
||||
"speed": display.ticker_speed,
|
||||
}
|
||||
|
||||
# Determine active playlists. If display_playlist has any rows, use those.
|
||||
# Otherwise fall back to the legacy assigned_playlist_id.
|
||||
mapped_ids = [
|
||||
@@ -209,6 +349,7 @@ def display_playlist(token: str):
|
||||
"display": display.name,
|
||||
"transition": display.transition or "none",
|
||||
"overlay_src": overlay_src,
|
||||
"ticker": ticker_cfg,
|
||||
"playlists": [],
|
||||
"items": [],
|
||||
}
|
||||
@@ -273,12 +414,48 @@ def display_playlist(token: str):
|
||||
"display": display.name,
|
||||
"transition": display.transition or "none",
|
||||
"overlay_src": overlay_src,
|
||||
"ticker": ticker_cfg,
|
||||
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
|
||||
"items": items,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/display/<token>/ticker")
|
||||
def display_ticker(token: str):
|
||||
"""Return ticker headlines for a display.
|
||||
|
||||
We keep it separate from /playlist so the player can refresh headlines on its own interval.
|
||||
"""
|
||||
|
||||
display = Display.query.filter_by(token=token).first()
|
||||
if not display:
|
||||
abort(404)
|
||||
|
||||
# Enforce concurrent session limit the same way as /playlist.
|
||||
sid = request.args.get("sid")
|
||||
ok, resp = _enforce_and_touch_display_session(display, sid)
|
||||
if not ok:
|
||||
return resp
|
||||
|
||||
if not display.ticker_enabled:
|
||||
return jsonify({"enabled": False, "headlines": []})
|
||||
|
||||
rss_url = (display.ticker_rss_url or "").strip()
|
||||
if not rss_url:
|
||||
return jsonify({"enabled": True, "headlines": []})
|
||||
|
||||
titles, from_cache = _get_ticker_titles_cached(rss_url)
|
||||
return jsonify(
|
||||
{
|
||||
"enabled": True,
|
||||
"rss_url": rss_url,
|
||||
"headlines": titles,
|
||||
"cached": bool(from_cache),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/display/<token>/events")
|
||||
def display_events(token: str):
|
||||
"""Server-Sent Events stream to notify the player when its playlist changes."""
|
||||
|
||||
@@ -1161,6 +1161,64 @@ def update_display(display_id: int):
|
||||
return None
|
||||
return v
|
||||
|
||||
def _normalize_css_color(val: str | None) -> str | None:
|
||||
"""Accept a limited set of CSS color inputs (primarily hex + a few keywords).
|
||||
|
||||
This is used to avoid storing arbitrary CSS strings while still being user friendly.
|
||||
"""
|
||||
|
||||
v = (val or "").strip()
|
||||
if not v:
|
||||
return None
|
||||
|
||||
low = v.lower()
|
||||
if low in {"white", "black", "red", "green", "blue", "yellow", "orange", "purple", "gray", "grey"}:
|
||||
return low
|
||||
|
||||
# Hex colors: #RGB, #RRGGBB, #RRGGBBAA
|
||||
if low.startswith("#"):
|
||||
h = low[1:]
|
||||
if len(h) in {3, 6, 8} and all(c in "0123456789abcdef" for c in h):
|
||||
return "#" + h
|
||||
return None
|
||||
|
||||
def _normalize_percent(val) -> int | None:
|
||||
if val in (None, ""):
|
||||
return None
|
||||
try:
|
||||
n = int(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return min(100, max(0, n))
|
||||
|
||||
def _normalize_speed(val) -> int | None:
|
||||
if val in (None, ""):
|
||||
return None
|
||||
try:
|
||||
n = int(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return min(100, max(1, n))
|
||||
|
||||
def _normalize_font_family(val: str | None) -> str | None:
|
||||
v = (val or "").strip()
|
||||
if not v:
|
||||
return None
|
||||
# keep it short and avoid quotes/newlines that could be abused in CSS.
|
||||
v = v.replace("\n", " ").replace("\r", " ").replace('"', "").replace("'", "")
|
||||
v = " ".join(v.split())
|
||||
return v[:120] if v else None
|
||||
|
||||
def _normalize_font_size_px(val) -> int | None:
|
||||
if val in (None, ""):
|
||||
return None
|
||||
try:
|
||||
n = int(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
# reasonable bounds for signage displays
|
||||
return min(200, max(10, n))
|
||||
|
||||
# Inputs from either form or JSON
|
||||
payload = request.get_json(silent=True) if request.is_json else None
|
||||
|
||||
@@ -1210,6 +1268,75 @@ def update_display(display_id: int):
|
||||
if raw is not None:
|
||||
display.show_overlay = (raw or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
# Ticker tape settings
|
||||
if request.is_json:
|
||||
if payload is None:
|
||||
return _json_error("Invalid JSON")
|
||||
|
||||
if "ticker_enabled" in payload:
|
||||
raw = payload.get("ticker_enabled")
|
||||
if isinstance(raw, bool):
|
||||
display.ticker_enabled = raw
|
||||
elif raw in (1, 0):
|
||||
display.ticker_enabled = bool(raw)
|
||||
else:
|
||||
s = ("" if raw is None else str(raw)).strip().lower()
|
||||
display.ticker_enabled = s in {"1", "true", "yes", "on"}
|
||||
|
||||
if "ticker_rss_url" in payload:
|
||||
u = (payload.get("ticker_rss_url") or "").strip() or None
|
||||
# Keep within column limit and avoid whitespace-only.
|
||||
if u is not None:
|
||||
u = u[:1000]
|
||||
display.ticker_rss_url = u
|
||||
|
||||
if "ticker_color" in payload:
|
||||
display.ticker_color = _normalize_css_color(payload.get("ticker_color"))
|
||||
|
||||
if "ticker_bg_color" in payload:
|
||||
display.ticker_bg_color = _normalize_css_color(payload.get("ticker_bg_color"))
|
||||
|
||||
if "ticker_bg_opacity" in payload:
|
||||
display.ticker_bg_opacity = _normalize_percent(payload.get("ticker_bg_opacity"))
|
||||
|
||||
if "ticker_font_family" in payload:
|
||||
display.ticker_font_family = _normalize_font_family(payload.get("ticker_font_family"))
|
||||
|
||||
if "ticker_font_size_px" in payload:
|
||||
display.ticker_font_size_px = _normalize_font_size_px(payload.get("ticker_font_size_px"))
|
||||
|
||||
if "ticker_speed" in payload:
|
||||
display.ticker_speed = _normalize_speed(payload.get("ticker_speed"))
|
||||
else:
|
||||
# Form POST implies full update
|
||||
raw = request.form.get("ticker_enabled")
|
||||
if raw is not None:
|
||||
display.ticker_enabled = (raw or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
if "ticker_rss_url" in request.form:
|
||||
u = (request.form.get("ticker_rss_url") or "").strip() or None
|
||||
if u is not None:
|
||||
u = u[:1000]
|
||||
display.ticker_rss_url = u
|
||||
|
||||
if "ticker_color" in request.form:
|
||||
display.ticker_color = _normalize_css_color(request.form.get("ticker_color"))
|
||||
|
||||
if "ticker_bg_color" in request.form:
|
||||
display.ticker_bg_color = _normalize_css_color(request.form.get("ticker_bg_color"))
|
||||
|
||||
if "ticker_bg_opacity" in request.form:
|
||||
display.ticker_bg_opacity = _normalize_percent(request.form.get("ticker_bg_opacity"))
|
||||
|
||||
if "ticker_font_family" in request.form:
|
||||
display.ticker_font_family = _normalize_font_family(request.form.get("ticker_font_family"))
|
||||
|
||||
if "ticker_font_size_px" in request.form:
|
||||
display.ticker_font_size_px = _normalize_font_size_px(request.form.get("ticker_font_size_px"))
|
||||
|
||||
if "ticker_speed" in request.form:
|
||||
display.ticker_speed = _normalize_speed(request.form.get("ticker_speed"))
|
||||
|
||||
# Playlist assignment
|
||||
if request.is_json:
|
||||
if "playlist_id" in payload:
|
||||
@@ -1251,6 +1378,14 @@ def update_display(display_id: int):
|
||||
"description": display.description,
|
||||
"transition": display.transition,
|
||||
"show_overlay": bool(display.show_overlay),
|
||||
"ticker_enabled": bool(display.ticker_enabled),
|
||||
"ticker_rss_url": display.ticker_rss_url,
|
||||
"ticker_color": display.ticker_color,
|
||||
"ticker_bg_color": display.ticker_bg_color,
|
||||
"ticker_bg_opacity": display.ticker_bg_opacity,
|
||||
"ticker_font_family": display.ticker_font_family,
|
||||
"ticker_font_size_px": display.ticker_font_size_px,
|
||||
"ticker_speed": display.ticker_speed,
|
||||
"assigned_playlist_id": display.assigned_playlist_id,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user