diff --git a/README.md b/README.md index 2e5323f..e5a9cef 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ ## ✨ Features - **Public stream list** - Live and archive tabs, password-protected streams, custom site branding, bilingual UI (Chinese / English) with per-language site description -- **Player** - HLS, FLV, MPEG-DASH playback via ArtPlayer; AES-128 key override and DASH ClearKey support +- **Player** - HLS, FLV, MPEG-DASH playback via ArtPlayer; AES-128 key override and DASH ClearKey support; Widevine and FairPlay DRM playback via Shaka Player (multi-DRM configs per source, Android Telegram WebView detection) - **Admin panel** - Add, edit, reorder, enable/disable streams; manage sources; drag-and-drop ordering - **Viewer analytics** - Session tracking, unique visitors, peak concurrent viewers, average watch duration, device / browser / OS / geography breakdown, real-time dashboard, CSV export - **Telegram notifications** - Per-stream push messages on stream start and stop @@ -36,6 +36,7 @@ - **VOD / file serving** - Signed `/video/` URLs with HTTP Range support (seek-capable); publish any local video file or folder as an archive stream directly from the file browser - **HLS proxy** - Signed `/proxy/hls/` routes for cross-origin HLS playback - **API key auth** - Generate per-key tokens in the admin panel for programmatic access to all admin and analytics endpoints +- **Mobile responsive** - Admin panel sidebar, file browser rows, and push directory sidebar all collapse gracefully on narrow screens
@@ -142,7 +143,7 @@ Set these environment variables in `docker-compose.yml`: | Variable | Default | Required | Description | |---|---|---|---| -| `SECRET_KEY` | `change-this-secret` | **Yes** | HMAC signing key for sessions and stream routes | +| `SECRET_KEY` | `REPLACE_ME` | **Yes** | HMAC signing key for sessions and stream routes. Generate with `openssl rand -hex 32` | | `DATABASE_URL` | see compose file | **Yes** | PostgreSQL connection string | | `POSTGRES_PASSWORD` | see compose file | **Yes** | PostgreSQL password | | `TZ` | `UTC` | No | Container timezone, e.g. `Asia/Shanghai` | @@ -234,7 +235,7 @@ The hidden public HLS URL (`/h//...`) routes through nginx on port `8889`, ### Authentication - **Browser session** - cookie set by `POST /api?action=login` -- **API key** - send `Authorization: Bearer ` header, or append `?api_key=` to any admin endpoint. Keys are managed in the admin panel under **Site Settings → API Keys**. +- **API key** - send `Authorization: Bearer ` header. Keys are managed in the admin panel under **Site Settings → API Keys**. ### Public Endpoints @@ -280,7 +281,7 @@ The hidden public HLS URL (`/h//...`) routes through nginx on port `8889`, | `GET` | `/api?action=stats_geo` | Country-level visitor counts | | `GET` | `/api?action=stats_stream_detail&id=` | Single-stream detail | | `GET` | `/api?action=stats_sessions_page&id=` | Paginated session list | -| `GET` | `/api?action=stats_export_csv` | Export sessions as CSV | +| `GET` | `/api?action=stats_export_csv&range=` | Export sessions as CSV (`range`: `today`, `7d`, `30d` (default), `all`) |
diff --git a/README.zh-CN.md b/README.zh-CN.md index 107bed5..77a292f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,7 +28,7 @@ ## ✨ 功能特性 - **公开直播列表** - 直播 / 存档双 Tab,支持密码保护、自定义站点品牌,内置中英双语界面(含分语言站点简介) -- **播放器** - 基于 ArtPlayer,支持 HLS、FLV、MPEG-DASH 播放;支持 AES-128 密钥覆盖及 DASH ClearKey +- **播放器** - 基于 ArtPlayer,支持 HLS、FLV、MPEG-DASH 播放;支持 AES-128 密钥覆盖及 DASH ClearKey;通过 Shaka Player 支持 Widevine 和 FairPlay DRM 播放(每路播放源可独立配置多 DRM 方案,内置 Android Telegram WebView 检测) - **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理 - **观看统计** - 会话追踪、独立访客数、峰值并发、平均时长、设备 / 浏览器 / 操作系统 / 地理分布实时看板,支持 CSV 导出 - **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知 @@ -36,6 +36,7 @@ - **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播 - **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题 - **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问 +- **移动端适配** - 管理后台侧边栏、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
@@ -142,7 +143,7 @@ python server.py | 变量 | 默认值 | 是否必填 | 说明 | |---|---|---|---| -| `SECRET_KEY` | `change-this-secret` | **必填** | 会话与推流路由的 HMAC 签名密钥 | +| `SECRET_KEY` | `REPLACE_ME` | **必填** | 会话与推流路由的 HMAC 签名密钥。可用 `openssl rand -hex 32` 生成 | | `DATABASE_URL` | 见 compose 文件 | **必填** | PostgreSQL 连接字符串 | | `POSTGRES_PASSWORD` | 见 compose 文件 | **必填** | PostgreSQL 数据库密码 | | `TZ` | `UTC` | 否 | 容器时区,如 `Asia/Shanghai` | @@ -234,7 +235,7 @@ RTMP 服务器: rtmp://HOST:1935/live ### 鉴权方式 - **浏览器会话** - 通过 `POST /api?action=login` 登录后设置的 Cookie -- **API 密钥** - 在请求头携带 `Authorization: Bearer `,或在 URL 追加 `?api_key=`。密钥在后台**网站设置 → API 密钥**处管理。 +- **API 密钥** - 在请求头携带 `Authorization: Bearer `。密钥在后台**网站设置 → API 密钥**处管理。 ### 公开接口 @@ -280,7 +281,7 @@ RTMP 服务器: rtmp://HOST:1935/live | `GET` | `/api?action=stats_geo` | 地理分布(国家级) | | `GET` | `/api?action=stats_stream_detail&id=` | 单场直播详情 | | `GET` | `/api?action=stats_sessions_page&id=` | 分页会话列表 | -| `GET` | `/api?action=stats_export_csv` | 导出会话 CSV | +| `GET` | `/api?action=stats_export_csv&range=` | 导出会话 CSV(`range`:`today`、`7d`、`30d`(默认)、`all`) |
diff --git a/docker-compose.yml b/docker-compose.yml index adfeb0a..0b15120 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,9 @@ services: ports: - "8085:8080" environment: - SECRET_KEY: "change-this-secret" - DATABASE_URL: "postgresql://streamhall:streamhall_pg_password@postgres:5432/streamhall" + # Generate with: openssl rand -hex 32 + SECRET_KEY: "REPLACE_ME" + DATABASE_URL: "postgresql://streamhall:REPLACE_DB_PASSWORD@postgres:5432/streamhall" TZ: "UTC" RTMP_HOST: "srs" # Comma-separated list of video directories (optional label:path format) @@ -29,7 +30,7 @@ services: environment: POSTGRES_DB: "streamhall" POSTGRES_USER: "streamhall" - POSTGRES_PASSWORD: "streamhall_pg_password" + POSTGRES_PASSWORD: "REPLACE_DB_PASSWORD" TZ: "UTC" volumes: - ./postgres-data:/var/lib/postgresql/data diff --git a/public/admin.html b/public/admin.html index cdd9bb1..806bb44 100644 --- a/public/admin.html +++ b/public/admin.html @@ -390,8 +390,9 @@ } .link-remove-btn { - align-self: start; - margin-top: 0; + grid-column: -1; + grid-row: 1 / -1; + align-self: center; min-height: 42px; } @@ -1144,6 +1145,8 @@ .dash-range-btn { border:1px solid var(--line); border-radius:5px; padding:4px 10px; color:var(--muted); background:transparent; font-size:.78rem; font-weight:800; cursor:pointer; box-shadow:none; transform:none !important; } .dash-range-btn:hover { color:var(--blue); border-color:rgba(78,126,232,.3); background:rgba(78,126,232,.06); filter:none; } .dash-range-btn.active { color:#fff; background:var(--blue); border-color:transparent; } + .dash-export-range-item { display:block; width:100%; text-align:left; padding:7px 14px; background:none; border:none; color:var(--text); cursor:pointer; font-size:.84em; box-shadow:none; transform:none !important; } + .dash-export-range-item:hover { background:rgba(78,126,232,.1); filter:none; } .dash-bar-chart { display:flex; align-items:flex-end; gap:2px; height:110px; padding-bottom:3px; } .dash-bar-col-wrap { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-end; height:100%; position:relative; } .dash-bar-col { width:100%; min-height:2px; border-radius:3px 3px 0 0; background:var(--blue); opacity:.75; transition:height .4s cubic-bezier(.22,1,.36,1); cursor:default; } @@ -1605,7 +1608,17 @@

数据看板

连接中... - +
+ + +
@@ -1807,7 +1820,7 @@ 'dash.total_sub': '所有观看会话', 'dash.unique': '独立访客', 'dash.unique_sub': '去重 Visitor ID', - 'dash.export': '⬇ 导出 CSV', + 'dash.export': '导出 CSV', 'dash.exporting': '导出中...', 'dash.stream_detail': '直播统计明细', 'dash.per_page': '每页', @@ -2207,7 +2220,7 @@ 'dash.total_sub': 'All sessions', 'dash.unique': 'Unique Visitors', 'dash.unique_sub': 'Deduped visitor IDs', - 'dash.export': '⬇ Export CSV', + 'dash.export': 'Export CSV', 'dash.exporting': 'Exporting...', 'dash.stream_detail': 'Stream Statistics', 'dash.per_page': 'Per page', @@ -5029,18 +5042,29 @@ }); // Dashboard export CSV - document.getElementById('dash-export-btn')?.addEventListener('click', async () => { + const exportMenu = document.getElementById('dash-export-menu'); + document.getElementById('dash-export-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + exportMenu.style.display = exportMenu.style.display === 'none' ? 'block' : 'none'; + }); + document.addEventListener('click', () => { if (exportMenu) exportMenu.style.display = 'none'; }); + exportMenu?.addEventListener('click', async (e) => { + const item = e.target.closest('.dash-export-range-item'); + if (!item) return; + exportMenu.style.display = 'none'; + const range = item.dataset.range || '30d'; const btn = document.getElementById('dash-export-btn'); - btn.disabled = true; btn.textContent = t('dash.exporting'); + const label = btn.querySelector('[data-i18n="dash.export"]'); + btn.disabled = true; if (label) label.textContent = t('dash.exporting'); try { - const res = await fetch(`/api?action=stats_export_csv&_t=${Date.now()}`); + const res = await fetch(`/api?action=stats_export_csv&range=${range}&_t=${Date.now()}`); if (!res.ok) throw new Error('Export failed'); const blob = await res.blob(), url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `viewer_stats_${new Date().toISOString().slice(0,10)}.csv`; + a.download = `viewer_stats_${range}_${new Date().toISOString().slice(0,10)}.csv`; a.click(); URL.revokeObjectURL(url); } catch (e) { showToast(e.message, 'error'); } - finally { btn.disabled = false; btn.textContent = t('dash.export'); } + finally { btn.disabled = false; if (label) label.textContent = t('dash.export'); } }); // Modal range tabs diff --git a/public/player.html b/public/player.html index 7f54ae5..3ec7191 100644 --- a/public/player.html +++ b/public/player.html @@ -402,7 +402,7 @@ function isAndroidRestrictedWebView() { const ua = navigator.userAgent || ''; if (!/Android/i.test(ua)) return false; - return /Telegram/i.test(ua) || /; wv\)/i.test(ua) || /\bwv\b/i.test(ua); + return /Telegram/i.test(ua); } function selectDrmConfig(link) { @@ -555,6 +555,7 @@ let viewerHeartbeatTimer = null; let viewerSessionId = ''; let viewerVisitorId = localStorage.getItem('streamhall_visitor_id') || ''; + let viewerToken = ''; let playerInstance = null; let playbackMisses = 0; if (!viewerVisitorId) { @@ -632,6 +633,7 @@ const data = await postViewerEvent('viewer_start', { id: streamId, visitorId: viewerVisitorId, + viewerToken, referer: document.referrer || '', state: viewerState() }); @@ -739,6 +741,7 @@ els.pwdModal.classList.remove('hidden'); document.getElementById('pwd-in').focus(); } else { + viewerToken = data.viewerToken || ''; startViewerStats(); waitForLiveAndStart(data); } @@ -754,6 +757,7 @@ applyPlayerTitle(data); applySiteIcon(data.siteIconUrl || ''); els.pwdModal.classList.add('hidden'); + viewerToken = data.viewerToken || ''; startViewerStats(); waitForLiveAndStart(data, pwd); } catch (err) { @@ -814,7 +818,7 @@ quality: quality, title: data.eventName, autoplay: true, - isLive: linkUsesShakaDrm(initialLink), + isLive: data.streamLabel === 'LIVE', volume: 0.5, autoSize: true, fullscreen: true, diff --git a/public/vendor/shaka-player.compiled.js b/public/vendor/shaka-player.compiled.js index 49b901f..d84477b 100644 --- a/public/vendor/shaka-player.compiled.js +++ b/public/vendor/shaka-player.compiled.js @@ -1,8 +1,9 @@ /* @license - Shaka Player + Shaka Player v4.10.6 Copyright 2016 Google LLC SPDX-License-Identifier: Apache-2.0 + Source: https://github.com/shaka-project/shaka-player/releases/tag/v4.10.6 */ (function(){var innerGlobal=typeof window!="undefined"?window:global;var exportTo={};(function(window,global,module){/* diff --git a/server.py b/server.py index 131f941..0681581 100644 --- a/server.py +++ b/server.py @@ -90,6 +90,7 @@ VIDEO_EXTS = frozenset({".mp4", ".mkv", ".avi", ".flv", ".ts", ".mov", ".wmv", " active_pushes: dict[str, dict] = {} _pushes_lock = threading.Lock() HLS_PROXY_PREFIX = "/proxy/hls" +VIEWER_TOKEN_TTL = 300 # seconds HLS_URI_RE = re.compile(r'URI="([^"]+)"') # matches URI="..." attributes in HLS tag lines (e.g. EXT-X-KEY) DEFAULT_SITE_SETTINGS = { @@ -421,14 +422,37 @@ def decode_proxy_target(value: str) -> str: return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8") -def hls_proxy_host_token(host: str) -> str: - return sign(f"hls-proxy-host:{host.lower()}") +def hls_proxy_url_token(url: str) -> str: + return sign(f"hls-proxy-url:{url}") + + +def make_viewer_token(stream_id: int) -> str: + expiry = now() + VIEWER_TOKEN_TTL + payload = f"{stream_id}:{expiry}" + encoded = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=") + return f"{encoded}.{sign(f'viewer-token:{payload}')}" + + +def verify_viewer_token(token: str, stream_id: int) -> bool: + if not token or "." not in token: + return False + encoded, sig = token.rsplit(".", 1) + try: + padded = encoded + "=" * (-len(encoded) % 4) + payload = base64.urlsafe_b64decode(padded.encode()).decode() + tid_str, expiry_str = payload.split(":", 1) + except Exception: + return False + if int(tid_str) != stream_id: + return False + if now() > int(expiry_str): + return False + return hmac.compare_digest(sig, sign(f"viewer-token:{payload}")) def hls_proxy_path(url: str) -> str: - host = urlparse(url).netloc encoded = encode_proxy_target(url) - return f"{HLS_PROXY_PREFIX}/{hls_proxy_host_token(host)}/{encoded}" + return f"{HLS_PROXY_PREFIX}/{hls_proxy_url_token(url)}/{encoded}" PLAYABLE_VIDEO_EXTS = frozenset({".mp4", ".mkv", ".mov", ".webm", ".m4v"}) @@ -712,6 +736,25 @@ def hls_manifest_drm_types(text: str) -> set[str]: return types +def hls_first_variant_url(text: str, base_url: str) -> str | None: + lines = text.splitlines() + for i, line in enumerate(lines): + if line.strip().startswith("#EXT-X-STREAM-INF"): + for j in range(i + 1, len(lines)): + candidate = lines[j].strip() + if candidate and not candidate.startswith("#"): + return urljoin(base_url, candidate) + return None + + +def dash_manifest_drm_types(text: str) -> set[str]: + lower = text.lower() + types: set[str] = set() + if "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" in lower or "com.widevine" in lower: + types.add("widevine") + return types + + def drm_config_types(configs: object) -> set[str]: normalized: set[str] = set() if not isinstance(configs, list): @@ -813,11 +856,24 @@ def probe_stream_url( for marker in ("#EXTINF", "#EXT-X-STREAM-INF", "#EXT-X-MEDIA-SEQUENCE", "#EXT-X-PART") ) drm_types = hls_manifest_drm_types(text) + if "#EXT-X-STREAM-INF" in text: + variant_url = hls_first_variant_url(text, url) + if variant_url: + try: + with urlopen(Request(variant_url, headers=headers), timeout=STREAM_PROBE_TIMEOUT) as vresp: + vdata = vresp.read(65536) + drm_types |= hls_manifest_drm_types(decode_probe_text(vdata)) + except Exception: + pass configured_types = drm_config_types(drm_configs) return stream_probe_drm_response(has_playlist and has_live_media, status_code, drm_types, configured_types) if is_dash or "dash+xml" in content_type: - return stream_probe_response(b" 0, status_code) @@ -916,8 +972,6 @@ def verify_admin_password(password: str) -> bool: def client_ip_hash(headers: object) -> str: - # Hash the client IP with HMAC-SHA256 so unique visitors can be counted - # without storing the raw IP address in the database. raw = headers.get("CF-Connecting-IP") or headers.get("X-Forwarded-For") or headers.get("X-Real-IP") or "" ip = str(raw).split(",", 1)[0].strip() if not ip: @@ -1066,9 +1120,11 @@ def player_data(row: dict[str, object], settings: dict[str, str] | None = None) site = settings or site_settings() return { "eventName": row["event_name"], + "streamLabel": normalize_stream_label(row["stream_label"]), "siteTitle": site["site_title"], "siteIconUrl": site.get("site_icon_url", ""), "links": add_playback_urls(normalize_links(links)), + "viewerToken": make_viewer_token(int(row["id"])), } @@ -1333,13 +1389,10 @@ class StreamHallHandler(BaseHTTPRequestHandler): return self._verify_api_key() def _verify_api_key(self) -> bool: - token = None auth = self.headers.get("Authorization", "") - if auth.startswith("Bearer "): - token = auth[7:].strip() - if not token: - qs = parse_qs(urlparse(self.path).query) - token = qs.get("api_key", [""])[0] + if not auth.startswith("Bearer "): + return False + token = auth[7:].strip() if not token: return False token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() @@ -1646,6 +1699,7 @@ class StreamHallHandler(BaseHTTPRequestHandler): def api_viewer_start(self) -> None: body = self.read_json() + viewer_token = str(body.get("viewerToken", "")).strip() visitor_id = str(body.get("visitorId", "")).strip()[:120] or secrets.token_urlsafe(18) stream_ref = body.get("id", "") referer = str(body.get("referer", self.headers.get("Referer", ""))).strip()[:500] @@ -1653,6 +1707,8 @@ class StreamHallHandler(BaseHTTPRequestHandler): row = find_stream(conn, stream_ref) if not row or int(row["is_enabled"] or 0) != 1: raise AppError("stream_not_found_or_disabled") + if not verify_viewer_token(viewer_token, int(row["id"])): + raise AppError("auth_required") session_id = secrets.token_urlsafe(18) timestamp = now() conn.execute( @@ -2164,18 +2220,37 @@ class StreamHallHandler(BaseHTTPRequestHandler): }}) def api_stats_export_csv(self) -> None: + qs = parse_qs(urlparse(self.path).query) + range_param = qs.get("range", ["30d"])[0] + since_map = {"today": 86400, "7d": 7 * 86400, "30d": 30 * 86400} + since = now() - since_map[range_param] if range_param in since_map else 0 with db() as conn: - rows = conn.execute( - """ - SELECT vs.session_id, vs.visitor_id, s.event_name, - vs.device_type, vs.referer, - vs.started_at, vs.ended_at, vs.last_seen_at, - vs.is_active, vs.play_state - FROM viewer_sessions vs - LEFT JOIN streams s ON vs.stream_id = s.id - ORDER BY vs.started_at DESC - """ - ).fetchall() + if since: + rows = conn.execute( + """ + SELECT vs.session_id, vs.visitor_id, s.event_name, + vs.device_type, vs.referer, + vs.started_at, vs.ended_at, vs.last_seen_at, + vs.is_active, vs.play_state + FROM viewer_sessions vs + LEFT JOIN streams s ON vs.stream_id = s.id + WHERE vs.started_at >= ? + ORDER BY vs.started_at DESC + """, + (since,), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT vs.session_id, vs.visitor_id, s.event_name, + vs.device_type, vs.referer, + vs.started_at, vs.ended_at, vs.last_seen_at, + vs.is_active, vs.play_state + FROM viewer_sessions vs + LEFT JOIN streams s ON vs.stream_id = s.id + ORDER BY vs.started_at DESC + """ + ).fetchall() buf = io.StringIO() writer = csv.writer(buf) writer.writerow(["session_id", "visitor_id", "event_name", "device_type", "referer", @@ -2650,7 +2725,7 @@ class StreamHallHandler(BaseHTTPRequestHandler): def proxy_hls_route(self, request_path: str, send_body: bool = True) -> None: # URL format: /proxy/hls// - # The token is an HMAC of the target hostname; it ensures that only URLs + # The token is an HMAC of the full target URL; it ensures that only URLs # generated by hls_proxy_path() can be proxied, preventing open-proxy abuse. parts = request_path.strip("/").split("/") if len(parts) != 4 or parts[0] != "proxy" or parts[1] != "hls": @@ -2666,7 +2741,7 @@ class StreamHallHandler(BaseHTTPRequestHandler): if parsed.scheme not in ("http", "https"): self.send_error(HTTPStatus.BAD_REQUEST) return - if not hmac.compare_digest(token, hls_proxy_host_token(parsed.netloc)): + if not hmac.compare_digest(token, hls_proxy_url_token(target_url)): self.send_error(HTTPStatus.FORBIDDEN) return @@ -3202,7 +3277,12 @@ def resume_push_jobs() -> None: pass +_WEAK_KEYS = {b"change-this-secret", b"REPLACE_ME"} + def main() -> None: + if SECRET_KEY in _WEAK_KEYS: + print("WARNING: SECRET_KEY is set to a known default value. " + "Generate a secure key with: openssl rand -hex 32", flush=True) init_db() threading.Thread(target=resume_push_jobs, daemon=True).start() threading.Thread(target=monitor_streams_loop, daemon=True).start()