Initial commit
This commit is contained in:
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"typescript.autoClosingTags": false
|
||||
}
|
||||
37
README.md
Normal file
37
README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Видео-хостинг (Twitch-style)
|
||||
|
||||
Платформа для стриминга видео с аутентификацией через OpenID Connect.
|
||||
|
||||
## Технологии
|
||||
|
||||
- **Frontend**: React, TypeScript, WebRTC
|
||||
- **Backend**: FastAPI, Python
|
||||
- **Auth**: OpenID Connect
|
||||
- **Streaming**: WebRTC, MediaStream API
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
/backend - FastAPI сервер
|
||||
/frontend - React приложение
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Создайте `.env` файлы для настройки OpenID Connect провайдера.
|
||||
6
backend/.env.example
Normal file
6
backend/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
OIDC_ISSUER=https://accounts.google.com
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_CLIENT_SECRET=your-client-secret
|
||||
OIDC_REDIRECT_URI=http://localhost:3000/callback
|
||||
SECRET_KEY=your-secret-key-change-this
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
80
backend/auth.py
Normal file
80
backend/auth.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from fastapi import HTTPException, Depends, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import jwt, JWTError
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from config import settings
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
class OIDCClient:
|
||||
def __init__(self):
|
||||
self.issuer = settings.oidc_issuer
|
||||
self.client_id = settings.oidc_client_id
|
||||
self.client_secret = settings.oidc_client_secret
|
||||
self.redirect_uri = settings.oidc_redirect_uri
|
||||
self._discovery_cache = None
|
||||
|
||||
async def get_discovery_document(self):
|
||||
if self._discovery_cache:
|
||||
return self._discovery_cache
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{self.issuer}/.well-known/openid-configuration")
|
||||
self._discovery_cache = response.json()
|
||||
return self._discovery_cache
|
||||
|
||||
async def exchange_code(self, code: str):
|
||||
discovery = await self.get_discovery_document()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def get_user_info(self, access_token: str):
|
||||
discovery = await self.get_discovery_document()
|
||||
userinfo_endpoint = discovery["userinfo_endpoint"]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
userinfo_endpoint,
|
||||
headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
oidc_client = OIDCClient()
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.secret_key,
|
||||
algorithms=["HS256"]
|
||||
)
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials"
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials"
|
||||
)
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
return jwt.encode(data, settings.secret_key, algorithm="HS256")
|
||||
14
backend/config.py
Normal file
14
backend/config.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
oidc_issuer: str
|
||||
oidc_client_id: str
|
||||
oidc_client_secret: str
|
||||
oidc_redirect_uri: str
|
||||
secret_key: str
|
||||
frontend_url: str
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
161
backend/main.py
Normal file
161
backend/main.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from typing import Dict, List
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from models import User, Stream, StreamCreate, AuthCallback, ChatMessage
|
||||
from auth import oidc_client, get_current_user, create_access_token
|
||||
from config import settings
|
||||
|
||||
app = FastAPI(title="Video Streaming Platform")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.frontend_url],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# In-memory storage
|
||||
streams: Dict[str, Stream] = {}
|
||||
users: Dict[str, User] = {}
|
||||
active_connections: Dict[str, List[WebSocket]] = {}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Video Streaming API"}
|
||||
|
||||
@app.get("/auth/login")
|
||||
async def login():
|
||||
discovery = await oidc_client.get_discovery_document()
|
||||
auth_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
auth_url = (
|
||||
f"{auth_endpoint}?"
|
||||
f"client_id={settings.oidc_client_id}&"
|
||||
f"redirect_uri={settings.oidc_redirect_uri}&"
|
||||
f"response_type=code&"
|
||||
f"scope=openid email profile"
|
||||
)
|
||||
|
||||
return {"auth_url": auth_url}
|
||||
|
||||
@app.post("/auth/callback")
|
||||
async def auth_callback(callback: AuthCallback):
|
||||
try:
|
||||
token_response = await oidc_client.exchange_code(callback.code)
|
||||
access_token = token_response.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=400, detail="Failed to get access token")
|
||||
|
||||
user_info = await oidc_client.get_user_info(access_token)
|
||||
|
||||
user = User(
|
||||
id=user_info.get("sub"),
|
||||
email=user_info.get("email"),
|
||||
name=user_info.get("name", user_info.get("email")),
|
||||
picture=user_info.get("picture")
|
||||
)
|
||||
|
||||
users[user.id] = user
|
||||
|
||||
jwt_token = create_access_token({
|
||||
"sub": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name
|
||||
})
|
||||
|
||||
return {"access_token": jwt_token, "user": user}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.get("/streams")
|
||||
async def get_streams():
|
||||
return list(streams.values())
|
||||
|
||||
@app.get("/streams/{stream_id}")
|
||||
async def get_stream(stream_id: str):
|
||||
if stream_id not in streams:
|
||||
raise HTTPException(status_code=404, detail="Stream not found")
|
||||
return streams[stream_id]
|
||||
|
||||
@app.post("/streams")
|
||||
async def create_stream(
|
||||
stream_data: StreamCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
stream_id = str(uuid.uuid4())
|
||||
stream = Stream(
|
||||
id=stream_id,
|
||||
user_id=current_user["sub"],
|
||||
title=stream_data.title,
|
||||
description=stream_data.description,
|
||||
is_live=True,
|
||||
created_at=datetime.now()
|
||||
)
|
||||
|
||||
streams[stream_id] = stream
|
||||
active_connections[stream_id] = []
|
||||
|
||||
return stream
|
||||
|
||||
@app.delete("/streams/{stream_id}")
|
||||
async def end_stream(
|
||||
stream_id: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
if stream_id not in streams:
|
||||
raise HTTPException(status_code=404, detail="Stream not found")
|
||||
|
||||
stream = streams[stream_id]
|
||||
if stream.user_id != current_user["sub"]:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
stream.is_live = False
|
||||
|
||||
for connection in active_connections.get(stream_id, []):
|
||||
await connection.close()
|
||||
|
||||
active_connections[stream_id] = []
|
||||
|
||||
return {"message": "Stream ended"}
|
||||
|
||||
@app.websocket("/ws/stream/{stream_id}")
|
||||
async def websocket_stream(websocket: WebSocket, stream_id: str):
|
||||
await websocket.accept()
|
||||
|
||||
if stream_id not in streams:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
if stream_id not in active_connections:
|
||||
active_connections[stream_id] = []
|
||||
|
||||
active_connections[stream_id].append(websocket)
|
||||
streams[stream_id].viewer_count = len(active_connections[stream_id])
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
|
||||
for connection in active_connections[stream_id]:
|
||||
if connection != websocket:
|
||||
await connection.send_json(data)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
active_connections[stream_id].remove(websocket)
|
||||
streams[stream_id].viewer_count = len(active_connections[stream_id])
|
||||
|
||||
@app.get("/me")
|
||||
async def get_me(current_user: dict = Depends(get_current_user)):
|
||||
user_id = current_user["sub"]
|
||||
if user_id in users:
|
||||
return users[user_id]
|
||||
return current_user
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
32
backend/models.py
Normal file
32
backend/models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
picture: Optional[str] = None
|
||||
|
||||
class Stream(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
is_live: bool
|
||||
viewer_count: int = 0
|
||||
created_at: datetime
|
||||
thumbnail: Optional[str] = None
|
||||
|
||||
class StreamCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class AuthCallback(BaseModel):
|
||||
code: str
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
user_id: str
|
||||
username: str
|
||||
message: str
|
||||
timestamp: datetime
|
||||
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-multipart==0.0.6
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
httpx==0.26.0
|
||||
python-dotenv==1.0.0
|
||||
websockets==12.0
|
||||
aiofiles==23.2.1
|
||||
2
frontend/.env
Normal file
2
frontend/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
REACT_APP_API_URL=http://localhost:8000
|
||||
REACT_APP_WS_URL=ws://localhost:8000
|
||||
39
frontend/package.json
Normal file
39
frontend/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "video-streaming-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
14
frontend/public/index.html
Normal file
14
frontend/public/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Платформа для стриминга видео" />
|
||||
<title>Video Streaming</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
31
frontend/src/api.ts
Normal file
31
frontend/src/api.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export const authAPI = {
|
||||
getLoginUrl: () => api.get('/auth/login'),
|
||||
callback: (code: string) => api.post('/auth/callback', { code }),
|
||||
getMe: () => api.get('/me'),
|
||||
};
|
||||
|
||||
export const streamAPI = {
|
||||
getStreams: () => api.get('/streams'),
|
||||
getStream: (id: string) => api.get(`/streams/${id}`),
|
||||
createStream: (data: { title: string; description?: string }) =>
|
||||
api.post('/streams', data),
|
||||
endStream: (id: string) => api.delete(`/streams/${id}`),
|
||||
};
|
||||
|
||||
export default api;
|
||||
0
frontend/src/components/StreamBroadcaster.tsx
Normal file
0
frontend/src/components/StreamBroadcaster.tsx
Normal file
0
frontend/src/components/StreamPlayer.tsx
Normal file
0
frontend/src/components/StreamPlayer.tsx
Normal file
52
frontend/src/context/AuthContext.tsx
Normal file
52
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { User, AuthContextType } from '../types';
|
||||
import { authAPI } from '../api';
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
authAPI.getMe()
|
||||
.then(response => setUser(response.data))
|
||||
.catch(() => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
});
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const login = async () => {
|
||||
const response = await authAPI.getLoginUrl();
|
||||
window.location.href = response.data.auth_url;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
token,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated: !!token
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
25
frontend/src/types.ts
Normal file
25
frontend/src/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
is_live: boolean;
|
||||
viewer_count: number;
|
||||
created_at: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: () => void;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user