diff --git a/backend/routes/streams.py b/backend/routes/streams.py index 7107406..16562c9 100644 --- a/backend/routes/streams.py +++ b/backend/routes/streams.py @@ -2,6 +2,7 @@ import json import secrets import asyncio import time +import ntpath from pathlib import Path from fastapi import APIRouter, HTTPException, Depends, Request @@ -62,7 +63,8 @@ def _media_path(filename: str | None) -> Path | None: return None root = Path(settings.replay_media_dir).resolve() supplied = Path(filename) - if supplied.is_absolute() or supplied.drive or ".." in supplied.parts: + windows_drive, _ = ntpath.splitdrive(filename) + if supplied.is_absolute() or supplied.drive or windows_drive or ".." in supplied.parts: raise HTTPException(status_code=422, detail="回放文件不在允许的媒体目录中") candidate = (root / supplied).resolve() if candidate != root and root not in candidate.parents: diff --git a/backend/tests/test_replay_workflow.py b/backend/tests/test_replay_workflow.py new file mode 100644 index 0000000..56f10ac --- /dev/null +++ b/backend/tests/test_replay_workflow.py @@ -0,0 +1,61 @@ +import json +import tempfile +import unittest +from pathlib import Path, PureWindowsPath +from types import SimpleNamespace +from unittest.mock import patch + +from fastapi import HTTPException + +from routes.streams import _media_path, _normalize_chapters, _status_payload + + +class ReplayWorkflowTest(unittest.TestCase): + def test_media_path_accepts_nested_file_in_media_root(self): + with tempfile.TemporaryDirectory() as directory: + replay = Path(directory, "events", "session.mp4") + replay.parent.mkdir() + replay.write_bytes(b"video") + with patch("routes.streams.settings.replay_media_dir", directory): + self.assertEqual(_media_path("events/session.mp4"), replay.resolve()) + + def test_media_path_rejects_parent_traversal_and_absolute_paths(self): + with tempfile.TemporaryDirectory() as directory: + with patch("routes.streams.settings.replay_media_dir", directory): + for unsafe in ("../secret.mp4", "/tmp/secret.mp4"): + with self.subTest(unsafe=unsafe), self.assertRaises(HTTPException): + _media_path(unsafe) + + def test_media_path_rejects_windows_absolute_path_on_any_platform(self): + value = str(PureWindowsPath("C:/media/replay.mp4")) + with tempfile.TemporaryDirectory() as directory: + with patch("routes.streams.settings.replay_media_dir", directory): + with self.assertRaises(HTTPException): + _media_path(value) + + def test_chapters_are_trimmed_validated_and_sorted(self): + chapters = _normalize_chapters([ + {"title": " Closing ", "time": "62.5"}, + {"title": "Opening", "time": 0}, + ]) + self.assertEqual(chapters, [ + {"title": "Opening", "time": 0.0}, + {"title": "Closing", "time": 62.5}, + ]) + + def test_chapters_reject_invalid_title_and_time(self): + for chapters in ([{"title": "", "time": 1}], [{"title": "Bad", "time": -1}]): + with self.subTest(chapters=chapters), self.assertRaises(HTTPException): + _normalize_chapters(chapters) + + def test_status_payload_exposes_pending_live_and_ended_updates(self): + stream = SimpleNamespace(id=7, replay_file=None, chapters_json="[]") + for status in ("pending", "live", "ended"): + stream.status = SimpleNamespace(value=status) + self.assertEqual(_status_payload(stream)["status"], status) + + stream.replay_file = "session.mp4" + stream.chapters_json = json.dumps([{"title": "Intro", "time": 0}]) + payload = _status_payload(stream) + self.assertEqual(payload["replay_url"], "/api/streams/7/replay") + self.assertEqual(payload["chapters"], [{"title": "Intro", "time": 0}]) diff --git a/frontend/src/admin/pages/ReplayEditor.tsx b/frontend/src/admin/pages/ReplayEditor.tsx index 521877d..07dc2de 100644 --- a/frontend/src/admin/pages/ReplayEditor.tsx +++ b/frontend/src/admin/pages/ReplayEditor.tsx @@ -63,7 +63,7 @@ export default function ReplayEditor() { if (!stream) return return ( -
+
回放与章节 {stream.title} @@ -88,12 +88,12 @@ export default function ReplayEditor() { {chapters.map((chapter, index) => ( - + setChapters(current => current.map((item, i) => i === index ? { ...item, title: event.target.value } : item))} - style={{ width: 320, maxWidth: '100%' }} + style={{ width: 320, maxWidth: '100%', flex: '1 1 220px' }} /> ([]) const [loading, setLoading] = useState(true) + const [query, setQuery] = useState('') + const [filter, setFilter] = useState('all') const navigate = useNavigate() useEffect(() => { @@ -25,10 +27,21 @@ export default function HomePage() { const o: Record = { live: 0, pending: 1, ended: 2, cancelled: 3 } return (o[a.status] ?? 9) - (o[b.status] ?? 9) }) + const visibleStreams = sorted.filter(stream => { + const matchesFilter = filter === 'all' || stream.status === filter + const haystack = `${stream.title} ${stream.description || ''}`.toLowerCase() + return matchesFilter && haystack.includes(query.trim().toLowerCase()) + }) return (
+
LIVE PROGRAMME
{brand.brand_logo_url && ( {brand.brand_name} -
+

直播频道 @@ -54,6 +67,35 @@ export default function HomePage() {

+
+ +
+ {[ + ['all', '全部'], + ['live', '直播中'], + ['pending', '待开播'], + ['ended', '回放'], + ].map(([value, label]) => ( + + ))} +
+
+ {loading ? (
{[1,2,3,4,5,6].map(i => ( @@ -65,7 +107,7 @@ export default function HomePage() {
))}
- ) : sorted.length === 0 ? ( + ) : visibleStreams.length === 0 ? (
) : (
- {sorted.map(s => { + {visibleStreams.map(s => { const isLive = s.status === 'live' const isPending = s.status === 'pending' return ( diff --git a/frontend/src/viewer/pages/WatchPage.tsx b/frontend/src/viewer/pages/WatchPage.tsx index 9b4f27d..bab325e 100644 --- a/frontend/src/viewer/pages/WatchPage.tsx +++ b/frontend/src/viewer/pages/WatchPage.tsx @@ -23,14 +23,28 @@ export default function WatchPage() { useEffect(() => { if (!id) return + let pollTimer: ReturnType | undefined + let disposed = false + + const startPolling = () => { + if (pollTimer || disposed) return + loadInfo() + pollTimer = setInterval(loadInfo, 5000) + } + loadInfo() const events = new EventSource(getStreamStatusUrl(Number(id))) events.addEventListener('status', loadInfo) events.addEventListener('deleted', () => setInfo(null)) events.onerror = () => { events.close() + startPolling() + } + return () => { + disposed = true + events.close() + if (pollTimer) clearInterval(pollTimer) } - return () => events.close() }, [id]) async function loadInfo() { diff --git a/frontend/src/viewer/pages/viewer.css b/frontend/src/viewer/pages/viewer.css index 9b6544d..203176c 100644 --- a/frontend/src/viewer/pages/viewer.css +++ b/frontend/src/viewer/pages/viewer.css @@ -5,6 +5,61 @@ font-family: "Noto Serif SC", "Songti SC", SimSun, Georgia, serif; } +.viewer-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 72px; + color: #6f695f; + font-family: Inter, "Noto Sans SC", sans-serif; + font-size: 13px; +} + +.viewer-nav span { color: #292720; font-weight: 650; } +.viewer-nav a { color: inherit; text-decoration: none; } +.viewer-nav a:hover { color: #292720; } + +.viewer-directory-tools { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 30px; +} + +.viewer-search { + display: flex; + align-items: center; + width: min(420px, 100%); + gap: 10px; + padding: 11px 13px; + border: 1px solid #d8d2c8; + border-radius: 8px; + background: rgba(255, 255, 255, 0.58); + color: #767066; +} + +.viewer-search:focus-within { border-color: #aaa296; box-shadow: 0 0 0 3px rgba(117, 108, 94, 0.08); } +.viewer-search input { width: 100%; border: 0; outline: 0; background: transparent; color: #292720; font: 14px/1.4 Inter, "Noto Sans SC", sans-serif; } +.viewer-search input::placeholder { color: #9d978d; } + +.viewer-filters { display: flex; flex-wrap: wrap; gap: 6px; } +.viewer-filters button { + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: #777168; + cursor: pointer; + font: 13px/1 Inter, "Noto Sans SC", sans-serif; +} +.viewer-filters button:hover { border-color: #d8d2c8; color: #292720; background: rgba(255, 255, 255, 0.45); } +.viewer-filters button.is-active { border-color: #cbc4b9; background: #eae5dc; color: #292720; } + +.stream-card { border-color: #d9d3c9; box-shadow: none; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; } +.stream-card:hover { transform: translateY(-2px); border-color: #bcb4a8; } +.stream-copy h3 { font-size: clamp(17px, 1.6vw, 21px); } + .viewer-hero { max-width: 1180px; margin: 0 auto; @@ -364,6 +419,13 @@ button.stream-card:focus-visible { } @media (max-width: 600px) { + .viewer-nav { margin-bottom: 48px; } + + .viewer-directory-tools { align-items: stretch; flex-direction: column; } + .viewer-search { width: auto; } + .viewer-filters { overflow-x: auto; flex-wrap: nowrap; padding-bottom: 3px; } + .viewer-filters button { flex: none; } + .viewer-hero { padding: 58px 20px 48px; }