全局整改: 布局重构、鉴权加固、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:
1 parent
f3b2a9cb46
commit
b81a7bf293
7 files changed
+109
-63
No files matched your search
+24
-7
@@ -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
@@ -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}
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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('更新失败') }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user