From 3c541a2ba54284f36b3b536e3b828b468370cdd7 Mon Sep 17 00:00:00 2001 From: arkonsadter Date: Wed, 14 Jan 2026 10:43:02 +0600 Subject: [PATCH] Initial commit --- .vscode/settings.json | 3 + README.md | 37 ++++ backend/.env.example | 6 + backend/auth.py | 80 +++++++++ backend/config.py | 14 ++ backend/main.py | 161 ++++++++++++++++++ backend/models.py | 32 ++++ backend/requirements.txt | 10 ++ frontend/.env | 2 + frontend/package.json | 39 +++++ frontend/public/index.html | 14 ++ frontend/src/api.ts | 31 ++++ frontend/src/components/StreamBroadcaster.tsx | 0 frontend/src/components/StreamPlayer.tsx | 0 frontend/src/context/AuthContext.tsx | 52 ++++++ frontend/src/types.ts | 25 +++ frontend/tsconfig.json | 20 +++ 17 files changed, 526 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/auth.py create mode 100644 backend/config.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/requirements.txt create mode 100644 frontend/.env create mode 100644 frontend/package.json create mode 100644 frontend/public/index.html create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/StreamBroadcaster.tsx create mode 100644 frontend/src/components/StreamPlayer.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..eabd0c4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.autoClosingTags": false +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f3c2b9 --- /dev/null +++ b/README.md @@ -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 провайдера. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..5d75e4a --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..4cb8d6e --- /dev/null +++ b/backend/auth.py @@ -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") diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..850478f --- /dev/null +++ b/backend/config.py @@ -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() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..69a98c0 --- /dev/null +++ b/backend/main.py @@ -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) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..87fdf04 --- /dev/null +++ b/backend/models.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6699c2e --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..9555536 --- /dev/null +++ b/frontend/.env @@ -0,0 +1,2 @@ +REACT_APP_API_URL=http://localhost:8000 +REACT_APP_WS_URL=ws://localhost:8000 diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..88373d0 --- /dev/null +++ b/frontend/package.json @@ -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" + ] + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..0f4780d --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,14 @@ + + + + + + + + Video Streaming + + + +
+ + diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..9dfb689 --- /dev/null +++ b/frontend/src/api.ts @@ -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; diff --git a/frontend/src/components/StreamBroadcaster.tsx b/frontend/src/components/StreamBroadcaster.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/StreamPlayer.tsx b/frontend/src/components/StreamPlayer.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..c5edd8b --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,52 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { User, AuthContextType } from '../types'; +import { authAPI } from '../api'; + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [token, setToken] = useState(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 ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +}; diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..9feb33c --- /dev/null +++ b/frontend/src/types.ts @@ -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; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..9d379a3 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +}