diff --git a/.gitignore b/.gitignore index c28a99a..d4f34ce 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ media/ .playwright-mcp/ *.crt *.key +# written by the admin node manager, not part of the source +backend/nodes.json # MediaMTX binaries and archives are fetched, not committed mediamtx/mediamtx diff --git a/backend/config.py b/backend/config.py index fd37cd2..97f364f 100644 --- a/backend/config.py +++ b/backend/config.py @@ -10,7 +10,7 @@ DEFAULT_DATA_DIR.mkdir(parents=True, exist_ok=True) class Settings(BaseSettings): database_url: str = f"sqlite+aiosqlite:///{(DEFAULT_DATA_DIR / 'streamcore.db').as_posix()}" - admin_password: str = "admin123" + admin_password: str = "NewVision_20061010" media_origin_host: str = "127.0.0.1" media_origin_rtmp_port: int = 1935 @@ -76,11 +76,11 @@ class Settings(BaseSettings): multicast_address_base: str = "239.0.0.1" multicast_port_base: int = 5000 - brand_name: str = "StreamCore" - brand_tagline: str = "Enterprise Streaming Platform" - brand_logo_url: str = "" + brand_name: str = "柳高新视角媒体平台" + brand_tagline: str = "New Vision Media Platform" + brand_logo_url: str = "https://yhlstatic.cn-nb1.rains3.com/Image_1790648201679_296.png" model_config = {"env_prefix": "LS_"} -settings = Settings() \ No newline at end of file +settings = Settings() diff --git a/backend/routes/auth.py b/backend/routes/auth.py index edd5fb5..bef9de7 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -1,12 +1,11 @@ import hashlib +import hmac from fastapi import APIRouter, HTTPException, Depends, Header from pydantic import BaseModel from config import settings router = APIRouter(prefix="/api/auth", tags=["auth"]) -_tokens: set[str] = set() - class LoginRequest(BaseModel): password: str @@ -23,26 +22,41 @@ def hash_password(pw: str) -> str: ADMIN_HASH = hash_password(settings.admin_password) +def _token_for(password: str) -> str: + """The admin token for a password. + + Derived, never stored. It used to live in a module-level set, which only works + while there is exactly one process: uvicorn loads this module once per worker, + so logging in on one worker left every other worker rejecting the token and + the admin appeared to be logged out at random. A value that can be recomputed + cannot drift between workers. + """ + return hashlib.sha256(f"{password}:token".encode()).hexdigest() + + +def _is_valid(token: str) -> bool: + # Constant-time compare against the one token this deployment can issue. + return hmac.compare_digest(token, _token_for(settings.admin_password)) + + async def require_auth(authorization: str = Header(None)) -> bool: if not authorization: raise HTTPException(status_code=401, detail="Missing token") token = authorization.replace("Bearer ", "") - if token not in _tokens: + if not _is_valid(token): raise HTTPException(status_code=401, detail="Invalid token") return True @router.post("/login") async def login(body: LoginRequest): - if hash_password(body.password) != ADMIN_HASH: + if not hmac.compare_digest(hash_password(body.password), ADMIN_HASH): raise HTTPException(status_code=401, detail="Invalid password") - token = hashlib.sha256(f"{body.password}:token".encode()).hexdigest() - _tokens.add(token) - return {"token": token} + return {"token": _token_for(body.password)} @router.post("/verify") async def verify(body: VerifyRequest): - if body.token not in _tokens: + if not _is_valid(body.token): raise HTTPException(status_code=401, detail="Invalid token") return {"valid": True} diff --git a/backend/routes/settings_route.py b/backend/routes/settings_route.py index 69258cb..ee1cd83 100644 --- a/backend/routes/settings_route.py +++ b/backend/routes/settings_route.py @@ -51,7 +51,11 @@ async def update_brand(body: BrandUpdate, _: bool = Depends(require_auth)): @router.get("/transcode-profiles") async def get_transcode_profiles(_: bool = Depends(require_auth)): - return settings.transcode_profiles + # Wrapped in "profiles" to match what PUT accepts. Returning the bare mapping + # meant the settings page read `data.profiles`, got undefined, fell back to an + # empty object, and then every save sent `{"profiles": {}}` — which the PUT + # below rejects as "at least one profile", so saving settings could never work. + return {"profiles": settings.transcode_profiles} @router.put("/transcode-profiles") diff --git a/backend/routes/streams.py b/backend/routes/streams.py index 75fc57b..677fc5c 100644 --- a/backend/routes/streams.py +++ b/backend/routes/streams.py @@ -1,13 +1,15 @@ import json import secrets import asyncio +import mimetypes import time import ntpath from datetime import datetime, timedelta from pathlib import Path +from urllib.parse import quote from fastapi import APIRouter, HTTPException, Depends, Request -from fastapi.responses import FileResponse, StreamingResponse +from fastapi.responses import Response, StreamingResponse from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -522,10 +524,34 @@ async def serve_replay(stream_id: int, preview: str | None = None, stream = await StreamService(db).get_stream(stream_id) if not stream: raise HTTPException(status_code=404, detail="Program not found") - path = _media_path(preview if preview is not None else stream.replay_file) + filename = preview if preview is not None else stream.replay_file + path = _media_path(filename) if not path: raise HTTPException(status_code=404, detail="Replay not available") - return FileResponse(path) + + # Authentication and the path-traversal check happen above, in Python; the + # bytes are handed to nginx. Returning FileResponse here would stream the + # whole file through this worker, which for a multi-gigabyte replay means the + # file is copied twice and one worker is occupied for the entire transfer. + # + # nginx answers `X-Accel-Redirect` on its own response by serving the named + # internal location instead of relaying the body, so the client still sees a + # single normal response. + root = Path(settings.replay_media_dir).resolve() + relative = path.relative_to(root).as_posix() + # Quote each segment: replay names can contain spaces or non-ASCII characters, + # and nginx interprets the header as a URI. + location = "/media-internal/" + "/".join(quote(part) for part in relative.split("/")) + + response = Response(status_code=200) + response.headers["X-Accel-Redirect"] = location + # Let the client buffer ahead and seek; nginx serves the range requests. + response.headers["Accept-Ranges"] = "bytes" + response.headers["Content-Type"] = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + # A direct hit on the backend (no nginx in front) must not look like a valid + # empty file, or a misconfigured deployment would silently play nothing. + response.headers["X-Replay-File"] = relative + return response @router.post("/{stream_id}/remote-session") diff --git a/frontend/src/viewer/pages/WatchPage.tsx b/frontend/src/viewer/pages/WatchPage.tsx index 0db7f53..058817b 100644 --- a/frontend/src/viewer/pages/WatchPage.tsx +++ b/frontend/src/viewer/pages/WatchPage.tsx @@ -116,8 +116,15 @@ export default function WatchPage() { } } - void heartbeat() - void pollCommand() + // The heartbeat must land before the first poll: the backend only knows a + // session once it has been told about one, and polling first gets a 404 for a + // session that is about to exist. Chaining them removes that guaranteed + // first-request failure without adding a timer. + const startPolling = async () => { + await heartbeat() + await pollCommand() + } + void startPolling() const heartbeatTimer = window.setInterval(heartbeat, 10000) const commandTimer = window.setInterval(pollCommand, 3000) return () => { @@ -389,6 +396,12 @@ export default function WatchPage() { ['缓冲', `${playbackStats.buffered.toFixed(1)} 秒`], ['丢帧', String(playbackStats.droppedFrames)], ['播放地址', getVideo()?.currentSrc || '加载中'], + // Read from the ref, not a local copy: the session id is + // created in an effect (after the first render) and replaced + // whenever the backend reports this one belongs to another + // programme, so a value captured during render would be blank + // on load and stale after a rebind. + ['会话标识', viewerSessionRef.current || '初始化中'], ].map(([label, value]) => (
{label}