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