全局整改: 布局重构、鉴权加固、API修复

- 首页/播放页: 去掉 antd Layout 改用 div 避免 flex 布局问题
- 播放页: 返回按钮改为 navigate('/') 替代 window.close()
- 后端登录/验证: 改用 POST JSON body 替代 query param
- 后端鉴权: require_auth 依赖注入保护所有管理接口
- nodes路由: 修复 /strategy 被 /{node_id} 拦截的顺序问题
- 前端 Settings: 策略更新改为 JSON body
This commit is contained in:
admin committed 2026-08-07 21:22:52 +08:00
1 parent f3b2a9cb46
commit b81a7bf293
7 files changed
+109 -63

No files matched your search

+24 -7
View File
@@ -1,6 +1,6 @@
import hashlib import hashlib
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends, Header
from fastapi.responses import JSONResponse from pydantic import BaseModel
from config import settings from config import settings
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -8,6 +8,14 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
_tokens: set[str] = set() _tokens: set[str] = set()
class LoginRequest(BaseModel):
password: str
class VerifyRequest(BaseModel):
token: str
def hash_password(pw: str) -> str: def hash_password(pw: str) -> str:
return hashlib.sha256(f"{pw}:livestream-salt".encode()).hexdigest() return hashlib.sha256(f"{pw}:livestream-salt".encode()).hexdigest()
@@ -15,17 +23,26 @@ def hash_password(pw: str) -> str:
ADMIN_HASH = hash_password(settings.admin_password) ADMIN_HASH = hash_password(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:
raise HTTPException(status_code=401, detail="Invalid token")
return True
@router.post("/login") @router.post("/login")
async def login(password: str): async def login(body: LoginRequest):
if hash_password(password) != ADMIN_HASH: if hash_password(body.password) != ADMIN_HASH:
raise HTTPException(status_code=401, detail="Invalid password") raise HTTPException(status_code=401, detail="Invalid password")
token = hashlib.sha256(f"{password}:token".encode()).hexdigest() token = hashlib.sha256(f"{body.password}:token".encode()).hexdigest()
_tokens.add(token) _tokens.add(token)
return {"token": token} return {"token": token}
@router.post("/verify") @router.post("/verify")
async def verify(token: str): async def verify(body: VerifyRequest):
if token not in _tokens: if body.token not in _tokens:
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Invalid token")
return {"valid": True} return {"valid": True}
+28 -23
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel from pydantic import BaseModel
from config import settings, Settings from config import settings
from routes.auth import require_auth
from services.load_balancer import lb from services.load_balancer import lb
from services.multicast_detector import multicast_detector from services.multicast_detector import multicast_detector
@@ -12,8 +13,13 @@ class NodeUpdate(BaseModel):
enabled: bool | None = None enabled: bool | None = None
class StrategyUpdate(BaseModel):
strategy: str
multicast_enabled: bool | None = None
@router.get("") @router.get("")
async def list_nodes(): async def list_nodes(_: bool = Depends(require_auth)):
nodes = [] nodes = []
for n in settings.edge_nodes: for n in settings.edge_nodes:
nodes.append({ nodes.append({
@@ -28,8 +34,26 @@ async def list_nodes():
return nodes return nodes
@router.get("/strategy")
async def get_strategy(_: bool = Depends(require_auth)):
return {
"strategy": settings.load_balance_strategy,
"multicast_enabled": settings.multicast_enabled,
"multicast_available": multicast_detector.is_available(),
}
@router.put("/strategy")
async def update_strategy(body: StrategyUpdate, _: bool = Depends(require_auth)):
if body.strategy in ("round-robin", "weighted", "least-conn"):
settings.load_balance_strategy = body.strategy
if body.multicast_enabled is not None:
settings.multicast_enabled = body.multicast_enabled
return {"ok": True}
@router.put("/{node_id}") @router.put("/{node_id}")
async def update_node(node_id: str, body: NodeUpdate): async def update_node(node_id: str, body: NodeUpdate, _: bool = Depends(require_auth)):
found = False found = False
for n in settings.edge_nodes: for n in settings.edge_nodes:
if n["id"] == node_id: if n["id"] == node_id:
@@ -41,22 +65,3 @@ async def update_node(node_id: str, body: NodeUpdate):
if not found: if not found:
raise HTTPException(status_code=404, detail="Node not found") raise HTTPException(status_code=404, detail="Node not found")
return {"ok": True} return {"ok": True}
@router.get("/strategy")
async def get_strategy():
return {
"strategy": settings.load_balance_strategy,
"multicast_enabled": settings.multicast_enabled,
"multicast_available": multicast_detector.is_available(),
}
@router.put("/strategy")
async def update_strategy(strategy: str, multicast_enabled: bool | None = None):
global settings
if strategy in ("round-robin", "weighted", "least-conn"):
settings.load_balance_strategy = strategy
if multicast_enabled is not None:
settings.multicast_enabled = multicast_enabled
return {"ok": True}
+11 -5
View File
@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from database import get_db from database import get_db
from routes.auth import require_auth
from services.stream_service import StreamService from services.stream_service import StreamService
from services.media_service import media_service from services.media_service import media_service
from services.load_balancer import lb from services.load_balancer import lb
@@ -48,7 +49,8 @@ async def list_streams(db: AsyncSession = Depends(get_db)):
@router.post("") @router.post("")
async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db)): async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)):
svc = StreamService(db) svc = StreamService(db)
stream = await svc.create_stream( stream = await svc.create_stream(
title=body.title, title=body.title,
@@ -88,7 +90,8 @@ async def get_stream(stream_id: int, db: AsyncSession = Depends(get_db)):
@router.put("/{stream_id}") @router.put("/{stream_id}")
async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = Depends(get_db)): async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)):
svc = StreamService(db) svc = StreamService(db)
existing = await svc.get_stream(stream_id) existing = await svc.get_stream(stream_id)
if not existing: if not existing:
@@ -100,7 +103,8 @@ async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = D
@router.delete("/{stream_id}") @router.delete("/{stream_id}")
async def delete_stream(stream_id: int, db: AsyncSession = Depends(get_db)): async def delete_stream(stream_id: int, db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)):
svc = StreamService(db) svc = StreamService(db)
existing = await svc.get_stream(stream_id) existing = await svc.get_stream(stream_id)
if not existing: if not existing:
@@ -127,7 +131,8 @@ async def get_watch_urls(stream_id: int, db: AsyncSession = Depends(get_db)):
@router.post("/{stream_id}/go-live") @router.post("/{stream_id}/go-live")
async def stream_go_live(stream_id: int, db: AsyncSession = Depends(get_db)): async def stream_go_live(stream_id: int, db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)):
svc = StreamService(db) svc = StreamService(db)
stream = await svc.get_stream(stream_id) stream = await svc.get_stream(stream_id)
if not stream: if not stream:
@@ -137,7 +142,8 @@ async def stream_go_live(stream_id: int, db: AsyncSession = Depends(get_db)):
@router.post("/{stream_id}/end") @router.post("/{stream_id}/end")
async def stream_end(stream_id: int, db: AsyncSession = Depends(get_db)): async def stream_end(stream_id: int, db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)):
svc = StreamService(db) svc = StreamService(db)
stream = await svc.get_stream(stream_id) stream = await svc.get_stream(stream_id)
if not stream: if not stream:
+2 -2
View File
@@ -34,7 +34,7 @@ export default function SettingsPage() {
async function saveStrategy(val: string) { async function saveStrategy(val: string) {
setSaving(true) setSaving(true)
try { try {
await api.put(`/nodes/strategy?strategy=${val}&multicast_enabled=${multicastEnabled}`) await api.put('/nodes/strategy', { strategy: val, multicast_enabled: multicastEnabled })
setStrategy(val) setStrategy(val)
message.success('策略已更新') message.success('策略已更新')
} catch { message.error('更新失败') } } catch { message.error('更新失败') }
@@ -44,7 +44,7 @@ export default function SettingsPage() {
async function toggleMulticast(val: boolean) { async function toggleMulticast(val: boolean) {
setMulticastEnabled(val) setMulticastEnabled(val)
try { try {
await api.put(`/nodes/strategy?strategy=${strategy}&multicast_enabled=${val}`) await api.put('/nodes/strategy', { strategy, multicast_enabled: val })
message.success('组播设置已更新') message.success('组播设置已更新')
} catch { message.error('更新失败') } } catch { message.error('更新失败') }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import api, { setToken, getToken } from './client' import api, { setToken, getToken } from './client'
export async function login(password: string): Promise<string> { export async function login(password: string): Promise<string> {
const { data } = await api.post(`/auth/login?password=${encodeURIComponent(password)}`) const { data } = await api.post('/auth/login', { password })
setToken(data.token) setToken(data.token)
return data.token return data.token
} }
@@ -10,7 +10,7 @@ export async function verifyToken(): Promise<boolean> {
const token = getToken() const token = getToken()
if (!token) return false if (!token) return false
try { try {
await api.post(`/auth/verify?token=${encodeURIComponent(token)}`) await api.post('/auth/verify', { token })
return true return true
} catch { } catch {
return false return false
+7 -8
View File
@@ -1,12 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
Layout, Row, Col, Card, Typography, Tag, Button, Skeleton, Empty, Space, Row, Col, Card, Typography, Tag, Button, Skeleton, Empty, Space,
} from 'antd' } from 'antd'
import { LoginOutlined, FireOutlined, ClockCircleOutlined } from '@ant-design/icons' import { LoginOutlined, FireOutlined, ClockCircleOutlined } from '@ant-design/icons'
import { fetchStreams, type StreamInfo } from '../../api/streams' import { fetchStreams, type StreamInfo } from '../../api/streams'
const { Header, Content } = Layout
const { Title, Text } = Typography const { Title, Text } = Typography
const statusMeta: Record<string, { color: string; label: string; icon: React.ReactNode }> = { const statusMeta: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
@@ -34,8 +33,8 @@ export default function HomePage() {
}) })
return ( return (
<Layout style={{ minHeight: '100vh', background: '#FAF8F5' }}> <div style={{ minHeight: '100vh', background: '#FAF8F5' }}>
<Header style={{ <div style={{
background: '#FFFFFF', background: '#FFFFFF',
borderBottom: '1px solid #E8E4DD', borderBottom: '1px solid #E8E4DD',
display: 'flex', display: 'flex',
@@ -57,8 +56,8 @@ export default function HomePage() {
> >
管理后台 管理后台
</Button> </Button>
</Header> </div>
<Content style={{ padding: '32px 48px', maxWidth: 1280, margin: '0 auto' }}> <div style={{ maxWidth: 1280, margin: '0 auto', padding: '32px 48px' }}>
<Title level={3} style={{ marginBottom: 24 }}>全部直播</Title> <Title level={3} style={{ marginBottom: 24 }}>全部直播</Title>
{loading ? ( {loading ? (
@@ -120,7 +119,7 @@ export default function HomePage() {
})} })}
</Row> </Row>
)} )}
</Content> </div>
</Layout> </div>
) )
} }
+35 -16
View File
@@ -1,16 +1,16 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { import {
Layout, Card, Select, Button, Typography, Space, Tag, Spin, Skeleton, Empty, message, Select, Button, Typography, Space, Tag, Spin, Skeleton, Empty, message,
} from 'antd' } from 'antd'
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons' import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
import Hls from 'hls.js' import Hls from 'hls.js'
import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams' import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
const { Header, Content } = Layout
const { Title, Text } = Typography const { Title, Text } = Typography
export default function WatchPage() { export default function WatchPage() {
const navigate = useNavigate()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const [info, setInfo] = useState<WatchInfo | null>(null) const [info, setInfo] = useState<WatchInfo | null>(null)
@@ -41,7 +41,7 @@ export default function WatchPage() {
setActiveQuality(data.qualities[0].label) setActiveQuality(data.qualities[0].label)
} }
} }
} catch { /* 直播尚未就绪 */ } } catch { }
setLoading(false) setLoading(false)
} }
@@ -98,7 +98,13 @@ export default function WatchPage() {
if (!info) { if (!info) {
return ( return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#FAF8F5' }}> <div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#FAF8F5',
}}>
<Empty description="未找到该直播" /> <Empty description="未找到该直播" />
</div> </div>
) )
@@ -107,19 +113,25 @@ export default function WatchPage() {
const statusLabel = info.status === 'live' ? '直播中' : info.status === 'pending' ? '待开播' : info.status === 'ended' ? '已结束' : info.status const statusLabel = info.status === 'live' ? '直播中' : info.status === 'pending' ? '待开播' : info.status === 'ended' ? '已结束' : info.status
return ( return (
<Layout style={{ minHeight: '100vh', background: '#1A1A16' }}> <div style={{
<Header style={{ minHeight: '100vh',
background: '#1A1A16',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{
height: 56,
minHeight: 56,
background: 'rgba(26,26,22,0.95)', background: 'rgba(26,26,22,0.95)',
borderBottom: '1px solid rgba(255,255,255,0.08)', borderBottom: '1px solid rgba(255,255,255,0.08)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '0 24px', padding: '0 24px',
height: 56,
}}> }}>
<Space> <Space>
<Button type="text" icon={<ArrowLeftOutlined />} style={{ color: '#fff' }} <Button type="text" icon={<ArrowLeftOutlined />} style={{ color: '#fff' }}
onClick={() => window.close()}> onClick={() => navigate('/')}>
返回 返回
</Button> </Button>
<div> <div>
@@ -157,21 +169,28 @@ export default function WatchPage() {
placeholder="选择线路" placeholder="选择线路"
/> />
</Space> </Space>
</Header> </div>
<Content style={{ <div style={{
flex: 1,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 24, padding: 24,
maxHeight: 'calc(100vh - 56px)',
}}> }}>
<div style={{ width: '100%', maxWidth: 1280, aspectRatio: '16/9', background: '#000', position: 'relative' }}> <div style={{
width: '100%',
maxWidth: 1280,
aspectRatio: '16 / 9',
maxHeight: 'calc(100vh - 56px - 48px)',
background: '#000',
position: 'relative',
}}>
<video <video
ref={videoRef} ref={videoRef}
controls controls
autoPlay autoPlay
playsInline playsInline
style={{ width: '100%', height: '100%', objectFit: 'contain' }} style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }}
/> />
{info.status !== 'live' && ( {info.status !== 'live' && (
<div style={{ <div style={{
@@ -189,7 +208,7 @@ export default function WatchPage() {
</div> </div>
)} )}
</div> </div>
</Content> </div>
</Layout> </div>
) )
} }