fix: serve replay media from nginx and unbreak settings saves

Replay
- Hand the replay file to nginx with X-Accel-Redirect instead of streaming it
  through the backend. A replay is tens of gigabytes, so Python was copying the
  whole file and holding a worker for the entire transfer: measured 8 MB in
  8.46s (~1 MB/s). nginx serves it from disk with sendfile at ~400 MB/s (1 GB in
  2.68s) and the backend process does not appear in CPU usage during the transfer.
  Authentication and the path-traversal check still run in Python; only the bytes
  moved. The internal location is what keeps the path from being reachable
  directly and bypassing that check.
- nginx reloads and the media directory is aliased internally.

Settings
- GET /settings/transcode-profiles returned the bare mapping while the settings
  page reads `data.profiles`, so it always saw undefined, fell back to an empty
  object, and every save posted `{"profiles": {}}` — which the PUT rejects as
  "at least one profile". Saving settings could never succeed. The response now
  matches the shape PUT accepts, verified by round-tripping it.

Auth
- Derive the admin token instead of storing it. The token is
  sha256(password + ":token"), but it was kept in a module-level set, which only
  works with one process: with uvicorn --workers N, logging in on one worker left
  the others rejecting the token and the admin appeared logged out at random.

Viewer
- Show the real session id in the stats panel; the previous code assigned it
  from a local variable that was never populated, so it always read "不可用".
- Send the first heartbeat before the first command poll. Both fired together on
  mount, so the poll reached the backend before the session existed and 404'd on
  every page load.

Housekeeping
- Ignore backend/nodes.json; the admin node manager writes it at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-09-29 11:47:52 +08:00
1 parent 4ee945ca0d
commit cdf591f933
6 files changed
+78 -19

No files matched your search

+5 -5
View File
@@ -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()
settings = Settings()
+22 -8
View File
@@ -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}
+5 -1
View File
@@ -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")
+29 -3
View File
@@ -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")